diff --git a/source/Calamari.Tests/ArgoCD/ContainerImageReplacerTests.cs b/source/Calamari.Tests/ArgoCD/ContainerImageReplacerTests.cs index 689dde03c1..36aad0fa37 100644 --- a/source/Calamari.Tests/ArgoCD/ContainerImageReplacerTests.cs +++ b/source/Calamari.Tests/ArgoCD/ContainerImageReplacerTests.cs @@ -177,6 +177,74 @@ public void UpdateImages_WithQuotedReference_PreservesQuotes() result.UpdatedImageReferences.Should().ContainSingle(r => r == "nginx:1.25"); } + [Test] + public void UpdateImages_WithImageOnNonDefaultRegistry_UpdatesTagAndReportsImage() + { + // Images on a non-default registry (e.g. GAR/GCR/ECR) must be matched and updated. The + // first path segment (us-docker.pkg.dev) is recognised as a registry, and the rest is the + // image name; only the tag should change. + const string inputYaml = @"apiVersion: v1 +kind: Pod +spec: + containers: + - name: helloworld + image: us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1"; + const string expectedYaml = @"apiVersion: v1 +kind: Pod +spec: + containers: + - name: helloworld + image: us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2"; + + var imageReplacer = new ContainerImageReplacer(inputYaml, DefaultContainerRegistry); + + var updatedImage = new List + { + new(ContainerImageReference.FromReferenceString( + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2", + DefaultContainerRegistry)) + }; + + var result = imageReplacer.UpdateImages(updatedImage); + + result.UpdatedContents.Should().Be(expectedYaml); + result.UpdatedImageReferences.Should().ContainSingle() + .Which.Should().Be("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2"); + } + + [Theory] + [TestCase("docker.io/nginx:1.27.1", "docker.io/nginx:1.28.0")] + [TestCase("nginx:1.27.1", "nginx:1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1", + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2")] + public void ReturnsSameImageBaseAsInYaml(string originalImage, string expectedImage) + { + var inputYaml = $@"apiVersion: v1 +kind: Pod +spec: + containers: + - name: my-container + image: {originalImage}"; + var expectedYaml = $@"apiVersion: v1 +kind: Pod +spec: + containers: + - name: my-container + image: {expectedImage}"; + + var imageReplacer = new ContainerImageReplacer(inputYaml, DefaultContainerRegistry); + + var update = new List + { + new(ContainerImageReference.FromReferenceString(expectedImage, DefaultContainerRegistry)) + }; + + var result = imageReplacer.UpdateImages(update); + + result.UpdatedContents.Should().Be(expectedYaml); + result.UpdatedImageReferences.Should().ContainSingle().Which.Should().Be(expectedImage); + } + [Test] public void DoesNotUpdateComments() { diff --git a/source/Calamari.Tests/ArgoCD/DirectoryUpdaterTests.cs b/source/Calamari.Tests/ArgoCD/DirectoryUpdaterTests.cs new file mode 100644 index 0000000000..27b1d7d426 --- /dev/null +++ b/source/Calamari.Tests/ArgoCD/DirectoryUpdaterTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Calamari.ArgoCD; +using Calamari.ArgoCD.Conventions; +using Calamari.ArgoCD.Conventions.UpdateImageTag; +using Calamari.ArgoCD.Domain; +using Calamari.ArgoCD.Models; +using Calamari.Common.Plumbing.FileSystem; +using Calamari.Common.Plumbing.Logging; +using Calamari.Testing.Helpers; +using Calamari.Tests.Fixtures.Integration.FileSystem; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.Tests.ArgoCD +{ + /// + /// Integration tests for DirectoryUpdater.Process() workflow. + /// Mirrors KustomizeUpdaterTests, focusing on non-default container registries. + /// + [TestFixture] + public class DirectoryUpdaterTests + { + ILog log; + ICalamariFileSystem fileSystem; + string tempDir; + + [SetUp] + public void SetUp() + { + log = new InMemoryLog(); + fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); + tempDir = fileSystem.CreateTemporaryDirectory(); + } + + [TearDown] + public void TearDown() + { + fileSystem?.DeleteDirectory(tempDir); + } + + [Test] + public void Process_WithImageOnNonDefaultRegistry_ProducesPatchSoChangesAreCommitted() + { + // Regression: an image on a non-default registry (e.g. GAR/GCR/ECR) must update the + // manifest AND produce a JSON patch, so HasChanges() is true and the commit/push isn't + // silently skipped. + const string deployment = @"apiVersion: apps/v1 +kind: Deployment +metadata: + name: helloworld +spec: + template: + spec: + containers: + - name: helloworld + image: us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1 +"; + fileSystem.OverwriteFile(Path.Combine(tempDir, "deployment.yaml"), deployment); + + var garImages = new List + { + new(ContainerImageReference.FromReferenceString( + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2", + ArgoCDConstants.DefaultContainerRegistry)), + }; + + var sourceWithMetadata = new ApplicationSourceWithMetadata( + new ApplicationSource { Path = "." }, + SourceType.Directory, + 0); + + var updater = new DirectoryUpdater(garImages, ArgoCDConstants.DefaultContainerRegistry, log, fileSystem); + + var result = updater.Process(sourceWithMetadata, tempDir); + + result.UpdatedImages.Should().NotBeEmpty(); + result.HasChanges().Should().BeTrue("a JSON patch must be produced so the commit/push is not skipped"); + result.PatchedFiles.Should().NotBeEmpty(); + + var updatedContent = fileSystem.ReadFile(Path.Combine(tempDir, "deployment.yaml")); + updatedContent.Should().Contain("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2"); + updatedContent.Should().NotContain("helloworld:v1"); + } + } +} diff --git a/source/Calamari.Tests/ArgoCD/Helm/HelmContainerImageReplacerTests.cs b/source/Calamari.Tests/ArgoCD/Helm/HelmContainerImageReplacerTests.cs new file mode 100644 index 0000000000..8646de632d --- /dev/null +++ b/source/Calamari.Tests/ArgoCD/Helm/HelmContainerImageReplacerTests.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using Calamari.ArgoCD.Conventions; +using Calamari.ArgoCD.Helm; +using Calamari.ArgoCD.Models; +using Calamari.Testing.Helpers; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.Tests.ArgoCD.Helm; + +[TestFixture] +public class HelmContainerImageReplacerTests +{ + const string DefaultRegistry = "docker.io"; + readonly InMemoryLog log = new(); + + [Theory] + [TestCase("docker.io/nginx:1.27.1", "docker.io/nginx:1.28.0")] + [TestCase("nginx:1.27.1", "nginx:1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1", + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2")] + public void ReturnsSameImageBaseAsInYaml(string originalImage, string expectedImage) + { + var yaml = $@"image: {originalImage} +"; + var annotations = new[] { "{{ .Values.image }}" }; + var replacer = new HelmContainerImageReplacer(yaml, DefaultRegistry, annotations, log); + + var images = new List + { + new(ContainerImageReference.FromReferenceString(expectedImage, DefaultRegistry)) + }; + + var result = replacer.UpdateImages(images); + + result.UpdatedImageReferences.Should().ContainSingle().Which.Should().Be(expectedImage); + result.UpdatedContents.Should().Contain($"image: {expectedImage}"); + } +} diff --git a/source/Calamari.Tests/ArgoCD/Helm/HelmValuesImageReplaceStepVariablesTests.cs b/source/Calamari.Tests/ArgoCD/Helm/HelmValuesImageReplaceStepVariablesTests.cs index 515481c62f..e68151e7b3 100644 --- a/source/Calamari.Tests/ArgoCD/Helm/HelmValuesImageReplaceStepVariablesTests.cs +++ b/source/Calamari.Tests/ArgoCD/Helm/HelmValuesImageReplaceStepVariablesTests.cs @@ -95,6 +95,30 @@ public void StructuredValue_AlreadyAtTarget_TracksWithFriendlyName() result.AlreadyUpToDateImages.Should().BeEquivalentTo(["nginx:1.27.1"]); } + [Test] + public void StructuredValue_ImageOnNonDefaultRegistry_UpdatesFullRefAndTracksWithRegistry() + { + // Helm/Ref report updates using the registry-qualified FriendlyName, so an image on a + // non-default registry (e.g. GAR/GCR/ECR) round-trips with its registry intact. + const string yaml = @" +image: + name: us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1 +"; + var replacer = new HelmValuesImageReplaceStepVariables(yaml, DefaultRegistry, log); + var images = new List + { + new(ContainerImageReference.FromReferenceString( + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2", + DefaultRegistry), "image.name") + }; + + var result = replacer.UpdateImages(images); + + using var scope = new AssertionScope(); + result.UpdatedImageReferences.Should().BeEquivalentTo(["us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2"]); + result.UpdatedContents.Should().Contain("name: us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2"); + } + [Test] public void TwoImagesWithSameTag_OnlyUpdatesConfiguredPath() { @@ -159,6 +183,30 @@ public void PathNotFoundInYaml_SkipsImage() result.UpdatedContents.Should().Be(yaml); } + [Theory] + [TestCase("docker.io/nginx:1.27.1", "docker.io/nginx:1.28.0")] + [TestCase("nginx:1.27.1", "nginx:1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1", + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2")] + public void ReturnsSameImageBaseAsInYaml(string originalImage, string expectedImage) + { + var yaml = $@" +image: + name: {originalImage} +"; + var replacer = new HelmValuesImageReplaceStepVariables(yaml, DefaultRegistry, log); + var images = new List + { + new(ContainerImageReference.FromReferenceString(expectedImage, DefaultRegistry), "image.name") + }; + + var result = replacer.UpdateImages(images); + + using var scope = new AssertionScope(); + result.UpdatedImageReferences.Should().BeEquivalentTo(new[] { expectedImage }); + result.UpdatedContents.Should().Contain($"name: {expectedImage}"); + } + [Test] public void StructuredValue_MismatchedImageName_DoesNotUpdate() { diff --git a/source/Calamari.Tests/ArgoCD/InlineJson6902ImageReplacerTest.cs b/source/Calamari.Tests/ArgoCD/InlineJson6902ImageReplacerTest.cs index cc4f8341d1..8b7cda88c2 100644 --- a/source/Calamari.Tests/ArgoCD/InlineJson6902ImageReplacerTest.cs +++ b/source/Calamari.Tests/ArgoCD/InlineJson6902ImageReplacerTest.cs @@ -41,6 +41,36 @@ public void ProcessInlineJson6902Patches_WithInlinePatches_UpdatesImages() result.UpdatedContents.Should().Contain("nginx:1.25"); } + [Theory] + [TestCase("docker.io/nginx:1.27.1", "docker.io/nginx:1.28.0")] + [TestCase("nginx:1.27.1", "nginx:1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1", + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2")] + public void ReturnsSameImageBaseAsInYaml(string originalImage, string expectedImage) + { + var content = $@"apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +patchesJson6902: +- target: + kind: Deployment + name: my-deployment + patch: |- + - op: replace + path: /spec/template/spec/containers/0/image + value: {originalImage}"; + + var imagesToUpdate = new List + { + new(ContainerImageReference.FromReferenceString(expectedImage, ArgoCDConstants.DefaultContainerRegistry)) + }; + + var replacer = new InlineJson6902ImageReplacer(content, ArgoCDConstants.DefaultContainerRegistry, log); + var result = replacer.UpdateImages(imagesToUpdate); + + result.UpdatedImageReferences.Should().ContainSingle().Which.Should().Be(expectedImage); + result.UpdatedContents.Should().Contain(expectedImage); + } + [Test] public void ProcessInlineJson6902Patches_WithNoMatches_ReturnsOriginalContent() { diff --git a/source/Calamari.Tests/ArgoCD/InlineJsonPatchReplacerTests.cs b/source/Calamari.Tests/ArgoCD/InlineJsonPatchReplacerTests.cs index 3190988f59..1037b720d0 100644 --- a/source/Calamari.Tests/ArgoCD/InlineJsonPatchReplacerTests.cs +++ b/source/Calamari.Tests/ArgoCD/InlineJsonPatchReplacerTests.cs @@ -95,7 +95,7 @@ public void UpdateImages_WithMultiplePatchesAndImages_UpdatesAllMatchingImages() result.UpdatedContents.Should().Contain("my-registry.com/busybox:stable"); result.UpdatedImageReferences.Count.Should().Be(2); result.UpdatedImageReferences.Should().Contain("nginx:1.25"); - result.UpdatedImageReferences.Should().Contain("busybox:stable"); + result.UpdatedImageReferences.Should().Contain("my-registry.com/busybox:stable"); } [Test] @@ -146,7 +146,7 @@ public void UpdateImages_WithNestedContainerStructures_UpdatesNestedImages() result.UpdatedContents.Should().NotBeNull(); result.UpdatedImageReferences.Count.Should().Be(2); result.UpdatedImageReferences.Should().Contain("nginx:1.25"); - result.UpdatedImageReferences.Should().Contain("busybox:stable"); + result.UpdatedImageReferences.Should().Contain("my-registry.com/busybox:stable"); } [Test] @@ -388,7 +388,7 @@ public void UpdateImages_WithJson6902PatchAddOperation_UpdatesImageReferences() result.UpdatedContents.Should().Contain("my-registry.com/busybox:stable"); result.UpdatedImageReferences.Count.Should().Be(2); result.UpdatedImageReferences.Should().Contain("nginx:1.25"); - result.UpdatedImageReferences.Should().Contain("busybox:stable"); + result.UpdatedImageReferences.Should().Contain("my-registry.com/busybox:stable"); } [Test] @@ -428,7 +428,7 @@ public void UpdateImages_WithMixedStrategicMergeAndJson6902Patches_UpdatesBothTy result.UpdatedContents.Should().Contain("my-registry.com/busybox:stable"); result.UpdatedImageReferences.Count.Should().Be(2); result.UpdatedImageReferences.Should().Contain("nginx:1.25"); - result.UpdatedImageReferences.Should().Contain("busybox:stable"); + result.UpdatedImageReferences.Should().Contain("my-registry.com/busybox:stable"); } [Test] @@ -457,6 +457,44 @@ public void UpdateImages_WithJson6902PatchInitContainerOperation_UpdatesInitCont result.UpdatedImageReferences.Should().ContainSingle(r => r == "nginx:1.25"); } + [Theory] + [TestCase("docker.io/nginx:1.27.1", "docker.io/nginx:1.28.0")] + [TestCase("nginx:1.27.1", "nginx:1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1", + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2")] + public void ReturnsSameImageBaseAsInYaml(string originalImage, string expectedImage) + { + var inputYaml = $@" +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +patches: +- target: + kind: Deployment + name: my-deployment + patch: |- + apiVersion: apps/v1 + kind: Deployment + spec: + template: + spec: + containers: + - name: my-container + image: {originalImage} +"; + + var imageReplacer = new InlineJsonPatchReplacer(inputYaml, ArgoCDConstants.DefaultContainerRegistry, log); + + var update = new List + { + new(ContainerImageReference.FromReferenceString(expectedImage, ArgoCDConstants.DefaultContainerRegistry)) + }; + + var result = imageReplacer.UpdateImages(update); + + result.UpdatedImageReferences.Should().ContainSingle().Which.Should().Be(expectedImage); + result.UpdatedContents.Should().Contain(expectedImage); + } + [Test] public void UpdateImages_WithJson6902PatchNonImageOperation_DoesNotUpdate() { diff --git a/source/Calamari.Tests/ArgoCD/InlineStrategicMergeTest.cs b/source/Calamari.Tests/ArgoCD/InlineStrategicMergeTest.cs index ac91bc22c0..106a25198b 100644 --- a/source/Calamari.Tests/ArgoCD/InlineStrategicMergeTest.cs +++ b/source/Calamari.Tests/ArgoCD/InlineStrategicMergeTest.cs @@ -42,6 +42,38 @@ public void ProcessInlineStrategicMergePatches_WithInlinePatches_UpdatesImages() result.UpdatedContents.Should().Contain("nginx:1.25"); } + [Theory] + [TestCase("docker.io/nginx:1.27.1", "docker.io/nginx:1.28.0")] + [TestCase("nginx:1.27.1", "nginx:1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1", + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2")] + public void ReturnsSameImageBaseAsInYaml(string originalImage, string expectedImage) + { + var content = $@"apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +patchesStrategicMerge: +- | + apiVersion: apps/v1 + kind: Deployment + spec: + template: + spec: + containers: + - name: my-container + image: {originalImage}"; + + var imagesToUpdate = new List + { + new(ContainerImageReference.FromReferenceString(expectedImage, ArgoCDConstants.DefaultContainerRegistry)) + }; + + var replacer = new InlineStrategicMergeImageReplacer(content, ArgoCDConstants.DefaultContainerRegistry, log); + var result = replacer.UpdateImages(imagesToUpdate); + + result.UpdatedImageReferences.Should().ContainSingle().Which.Should().Be(expectedImage); + result.UpdatedContents.Should().Contain(expectedImage); + } + [Test] public void ProcessInlineStrategicMergePatches_WithNoMatches_ReturnsOriginalContent() { diff --git a/source/Calamari.Tests/ArgoCD/JsonPatchImageReplacerTests.cs b/source/Calamari.Tests/ArgoCD/JsonPatchImageReplacerTests.cs index 8ff081838e..d2360be640 100644 --- a/source/Calamari.Tests/ArgoCD/JsonPatchImageReplacerTests.cs +++ b/source/Calamari.Tests/ArgoCD/JsonPatchImageReplacerTests.cs @@ -67,7 +67,7 @@ public void UpdateImages_WithAddOperation_UpdatesImageReference() result.UpdatedContents.Should().NotBeNull(); result.UpdatedImageReferences.Count.Should().Be(1); - result.UpdatedImageReferences.Should().ContainSingle(r => r == "busybox:stable"); + result.UpdatedImageReferences.Should().ContainSingle(r => r == "my-registry.com/busybox:stable"); result.UpdatedContents.Should().Contain("my-registry.com/busybox:stable"); } @@ -100,7 +100,7 @@ public void UpdateImages_WithObjectValue_UpdatesNestedImageReferences() result.UpdatedContents.Should().NotBeNull(); result.UpdatedImageReferences.Count.Should().Be(2); result.UpdatedImageReferences.Should().Contain("nginx:1.25"); - result.UpdatedImageReferences.Should().Contain("busybox:stable"); + result.UpdatedImageReferences.Should().Contain("my-registry.com/busybox:stable"); result.UpdatedContents.Should().Contain("nginx:1.25"); result.UpdatedContents.Should().Contain("my-registry.com/busybox:stable"); } @@ -166,7 +166,7 @@ public void UpdateImages_WithComplexNestedStructure_UpdatesAllMatchingImages() result.UpdatedContents.Should().NotBeNull(); result.UpdatedImageReferences.Count.Should().Be(2); result.UpdatedImageReferences.Should().Contain("nginx:1.25"); - result.UpdatedImageReferences.Should().Contain("busybox:stable"); + result.UpdatedImageReferences.Should().Contain("my-registry.com/busybox:stable"); } [Test] @@ -197,7 +197,7 @@ public void UpdateImages_WithMultiplePatchOperations_UpdatesAllMatchingImages() result.UpdatedContents.Should().NotBeNull(); result.UpdatedImageReferences.Count.Should().Be(2); result.UpdatedImageReferences.Should().Contain("nginx:1.25"); - result.UpdatedImageReferences.Should().Contain("busybox:stable"); + result.UpdatedImageReferences.Should().Contain("my-registry.com/busybox:stable"); } [Test] @@ -281,6 +281,34 @@ public void UpdateImages_WithNonArrayJson_ReturnsNoChanges() result.UpdatedImageReferences.Should().BeEmpty(); } + [Theory] + [TestCase("docker.io/nginx:1.27.1", "docker.io/nginx:1.28.0")] + [TestCase("nginx:1.27.1", "nginx:1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1", + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2")] + public void ReturnsSameImageBaseAsInYaml(string originalImage, string expectedImage) + { + var inputJson = $@"[ + {{ + ""op"": ""replace"", + ""path"": ""/spec/template/spec/containers/0/image"", + ""value"": ""{originalImage}"" + }} +]"; + + var imageReplacer = new JsonPatchImageReplacer(inputJson, ArgoCDConstants.DefaultContainerRegistry, log); + + var update = new List + { + new(ContainerImageReference.FromReferenceString(expectedImage, ArgoCDConstants.DefaultContainerRegistry)) + }; + + var result = imageReplacer.UpdateImages(update); + + result.UpdatedImageReferences.Should().ContainSingle().Which.Should().Be(expectedImage); + result.UpdatedContents.Should().Contain(expectedImage); + } + [Test] public void UpdateImages_WithNonImageStringValues_IgnoresNonImageStrings() { diff --git a/source/Calamari.Tests/ArgoCD/KustomizeContainerImageReplacerTests.cs b/source/Calamari.Tests/ArgoCD/KustomizeContainerImageReplacerTests.cs index bb88a50aac..8571ec2576 100644 --- a/source/Calamari.Tests/ArgoCD/KustomizeContainerImageReplacerTests.cs +++ b/source/Calamari.Tests/ArgoCD/KustomizeContainerImageReplacerTests.cs @@ -1,8 +1,12 @@ using System; +using System.Collections.Generic; using Calamari.ArgoCD; +using Calamari.ArgoCD.Conventions; using Calamari.ArgoCD.Conventions.UpdateImageTag; using Calamari.ArgoCD.Domain; +using Calamari.ArgoCD.Models; using Calamari.Common.Plumbing.Logging; +using Calamari.Testing.Helpers; using FluentAssertions; using NSubstitute; using NUnit.Framework; @@ -12,6 +16,38 @@ namespace Calamari.Tests.ArgoCD [TestFixture] public class KustomizeContainerImageReplacerTests { + [Theory] + [TestCase("docker.io/nginx", "1.28.0")] + [TestCase("nginx", "1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld", "v2")] + public void ReturnsSameImageBaseAsInYaml(string originalName, string newTag) + { + var inputYaml = $@"apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: {originalName} +"; + var expectedYaml = $@"apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: {originalName} + newTag: ""{newTag}"" +"; + + var log = new InMemoryLog(); + var replacer = new KustomizeContainerImageReplacer(inputYaml, ArgoCDConstants.DefaultContainerRegistry, false, log); + + var update = new List + { + new(ContainerImageReference.FromReferenceString($"{originalName}:{newTag}", ArgoCDConstants.DefaultContainerRegistry)) + }; + + var result = replacer.UpdateImages(update); + + result.UpdatedContents.Should().Be(expectedYaml); + result.UpdatedImageReferences.Should().ContainSingle().Which.Should().Be($"{originalName}:{newTag}"); + } + [TestFixture] public class DeterminePatchTypeFromFileTests { diff --git a/source/Calamari.Tests/ArgoCD/KustomizeImageReplacerTests.cs b/source/Calamari.Tests/ArgoCD/KustomizeImageReplacerTests.cs index 2005d59fea..65f5f5b6fd 100644 --- a/source/Calamari.Tests/ArgoCD/KustomizeImageReplacerTests.cs +++ b/source/Calamari.Tests/ArgoCD/KustomizeImageReplacerTests.cs @@ -43,7 +43,7 @@ public void UpdateImages_WithQualifiedNameOnly_AddsNewTagNode() result.UpdatedContents.Should().NotBeNull(); result.UpdatedContents.Should().Be(expectedYaml); result.UpdatedImageReferences.Count.Should().Be(1); - result.UpdatedImageReferences.Should().ContainSingle(r => r == "nginx:1.25"); + result.UpdatedImageReferences.Should().ContainSingle(r => r == "docker.io/nginx:1.25"); } [Test] @@ -240,7 +240,7 @@ public void UpdateImages_ExistingContainerHasDigest_StripsDigestAndAddsNewTagNod result.UpdatedContents.Should().NotBeNull(); result.UpdatedContents.Should().Be(expectedYaml); result.UpdatedImageReferences.Count.Should().Be(1); - result.UpdatedImageReferences.Should().ContainSingle(r => r == "busybox:stable"); + result.UpdatedImageReferences.Should().ContainSingle(r => r == "my-registry.com/busybox:stable"); } [Test] @@ -266,7 +266,7 @@ public void UpdateImages_HasNewNameNode_PreferencesNewNameNodeWhenMatching() result.UpdatedContents.Should().NotBeNull(); result.UpdatedContents.Should().Be(expectedYaml); result.UpdatedImageReferences.Count.Should().Be(1); - result.UpdatedImageReferences.Should().ContainSingle(r => r == "busybox:stable"); + result.UpdatedImageReferences.Should().ContainSingle(r => r == "my-registry.com/busybox:stable"); } [Test] @@ -300,10 +300,39 @@ public void UpdateImages_MultipleImages_AllCorrectlyUpdated() result.UpdatedContents.Should().NotBeNull(); result.UpdatedContents.Should().Be(expectedYaml); result.UpdatedImageReferences.Count.Should().Be(2); - result.UpdatedImageReferences.Should().ContainSingle(r => r == "busybox:stable"); + result.UpdatedImageReferences.Should().ContainSingle(r => r == "my-registry.com/busybox:stable"); result.UpdatedImageReferences.Should().ContainSingle(r => r == "nginx:1.25"); } + [Theory] + [TestCase("docker.io/nginx", "1.28.0")] + [TestCase("nginx", "1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld", "v2")] + public void ReturnsSameImageBaseAsInYaml(string originalName, string newTag) + { + var inputYaml = $@" +images: +- name: {originalName} +"; + var expectedYaml = $@" +images: +- name: {originalName} + newTag: ""{newTag}"" +"; + + var imageReplacer = new KustomizeImageReplacer(inputYaml, ArgoCDConstants.DefaultContainerRegistry, log); + + var update = new List + { + new(ContainerImageReference.FromReferenceString($"{originalName}:{newTag}", ArgoCDConstants.DefaultContainerRegistry)) + }; + + var result = imageReplacer.UpdateImages(update); + + result.UpdatedContents.Should().Be(expectedYaml); + result.UpdatedImageReferences.Should().ContainSingle().Which.Should().Be($"{originalName}:{newTag}"); + } + [Test] public void UpdateImages_EmptyYamlContent_LogsAppropriateWarning() { @@ -454,7 +483,7 @@ public void UpdateImages_FullKustomizationFile_ShouldOnlyChangeTheImagesNode() result.UpdatedContents.Should().Be(expectedYaml); result.UpdatedImageReferences.Count.Should().Be(2); result.UpdatedImageReferences.Should().ContainSingle(r => r == "monopole:100"); - result.UpdatedImageReferences.Should().ContainSingle(r => r == "nginx:1.25"); + result.UpdatedImageReferences.Should().ContainSingle(r => r == "docker.io/nginx:1.25"); } } } diff --git a/source/Calamari.Tests/ArgoCD/KustomizeUpdaterTests.cs b/source/Calamari.Tests/ArgoCD/KustomizeUpdaterTests.cs index 15475f36b7..526f2d2c06 100644 --- a/source/Calamari.Tests/ArgoCD/KustomizeUpdaterTests.cs +++ b/source/Calamari.Tests/ArgoCD/KustomizeUpdaterTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using Calamari.ArgoCD; using Calamari.ArgoCD.Conventions; using Calamari.ArgoCD.Conventions.UpdateImageTag; @@ -13,7 +12,6 @@ using Calamari.Testing.Helpers; using Calamari.Tests.Fixtures.Integration.FileSystem; using FluentAssertions; -using NSubstitute; using NUnit.Framework; namespace Calamari.Tests.ArgoCD @@ -27,8 +25,8 @@ public class KustomizeUpdaterTests { readonly List imagesToUpdate = new() { - new(ContainerImageReference.FromReferenceString("nginx:1.25", ArgoCDConstants.DefaultContainerRegistry)), - new(ContainerImageReference.FromReferenceString("busybox:stable", "my-registry.com")), + new(ContainerImageReference.FromReferenceString("docker.io/nginx:1.25")), + new(ContainerImageReference.FromReferenceString("my-registry.com/busybox:stable")) }; ILog log; @@ -279,7 +277,7 @@ public void Process_WithMixedPatchTypes_UpdatesAllFiles() var result = updater.Process(sourceWithMetadata, tempDir); result.UpdatedImages.Should().Contain("nginx:1.25"); - result.UpdatedImages.Should().Contain("busybox:stable"); + result.UpdatedImages.Should().Contain("my-registry.com/busybox:stable"); result.UpdatedImages.Count.Should().Be(2); var updatedDeploymentContent = fileSystem.ReadFile(Path.Combine(tempDir,"deployment-patch.yaml")); @@ -289,7 +287,45 @@ public void Process_WithMixedPatchTypes_UpdatesAllFiles() updatedServiceContent.Should().Contain("busybox:stable"); var updatedKustomizationContent = fileSystem.ReadFile(Path.Combine(tempDir, "kustomization.yaml")); - updatedKustomizationContent.Should().Contain("busybox:stable"); + updatedKustomizationContent.Should().Contain("my-registry.com/busybox:stable"); + } + + [Test] + public void Process_WithImageOnNonDefaultRegistry_ProducesPatchSoChangesAreCommitted() + { + // Regression test: an image whose registry differs from the default (e.g. GAR/GCR/ECR) + // must still produce a JSON patch. Previously the recorded image reference had its + // registry stripped, so CreateJsonPatch re-parsed it, defaulted the registry to + // docker.io, failed to match, and returned no patch — meaning HasChanges() was false + // and the working-copy edit was silently discarded (never committed). + const string kustomizationContent = @"apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld + newTag: ""latest"" +"; + + var garImages = new List + { + new(ContainerImageReference.FromReferenceString( + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2", + ArgoCDConstants.DefaultContainerRegistry)), + }; + + var sourceWithMetadata = new ApplicationSourceWithMetadata( + new ApplicationSource { Path = "." }, + SourceType.Kustomize, + 0); + + CreateKustomizationFile(kustomizationContent); + + var updater = new KustomizeUpdater(CreateMockDeploymentConfig(garImages), ArgoCDConstants.DefaultContainerRegistry, log, fileSystem); + + var result = updater.Process(sourceWithMetadata, tempDir); + + result.UpdatedImages.Should().Contain("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2"); + result.HasChanges().Should().BeTrue("a JSON patch must be produced so the commit/push is not skipped"); + result.PatchedFiles.Should().NotBeEmpty(); } [Test] diff --git a/source/Calamari.Tests/ArgoCD/Models/ContainerImageReferenceTests.cs b/source/Calamari.Tests/ArgoCD/Models/ContainerImageReferenceTests.cs index 37853ccb8e..57e672e9f8 100644 --- a/source/Calamari.Tests/ArgoCD/Models/ContainerImageReferenceTests.cs +++ b/source/Calamari.Tests/ArgoCD/Models/ContainerImageReferenceTests.cs @@ -123,7 +123,7 @@ public void WithTag_WithNoRepository_ReturnsImageWithTag() var image = ContainerImageReference.FromReferenceString("nginx:latest"); var result = image.WithTag("1.27"); - result.Should().Be("nginx:1.27"); + result.FriendlyName().Should().Be("nginx:1.27"); } [Test] @@ -133,7 +133,7 @@ public void WithTag_WithRepository_ReturnsRepositoryImageWithTag() var result = image.WithTag("1.27"); - result.Should().Be("docker.io/nginx:1.27"); + result.FriendlyName().Should().Be("docker.io/nginx:1.27"); } [Test] @@ -143,7 +143,7 @@ public void WithTag_WithDefaultRegistry_ReturnsImageWithTag() var result = image.WithTag("1.27"); - result.Should().Be("nginx:1.27"); + result.FriendlyName().Should().Be("nginx:1.27"); } [Theory] diff --git a/source/Calamari.Tests/ArgoCD/YamlJson6902PatchImageReplacerTests.cs b/source/Calamari.Tests/ArgoCD/YamlJson6902PatchImageReplacerTests.cs index 9519ad263e..1ace1e4aec 100644 --- a/source/Calamari.Tests/ArgoCD/YamlJson6902PatchImageReplacerTests.cs +++ b/source/Calamari.Tests/ArgoCD/YamlJson6902PatchImageReplacerTests.cs @@ -80,9 +80,9 @@ public void UpdateImages_WithAddContainersOperation_UpdatesImageReferences() var result = replacer.UpdateImages(imagesToUpdate); result.UpdatedImageReferences.Should().Contain("nginx:1.25"); - result.UpdatedImageReferences.Should().Contain("busybox:stable"); + result.UpdatedImageReferences.Should().Contain("my-registry.com/busybox:stable"); result.UpdatedContents.Should().Contain("nginx:1.25"); - result.UpdatedContents.Should().Contain("busybox:stable"); + result.UpdatedContents.Should().Contain("my-registry.com/busybox:stable"); } [Test] @@ -165,10 +165,10 @@ public void UpdateImages_WithMixedOperations_UpdatesOnlyMatchingImages() var result = replacer.UpdateImages(imagesToUpdate); result.UpdatedImageReferences.Should().Contain("nginx:1.25"); - result.UpdatedImageReferences.Should().Contain("busybox:stable"); + result.UpdatedImageReferences.Should().Contain("my-registry.com/busybox:stable"); result.UpdatedImageReferences.Should().HaveCount(2); result.UpdatedContents.Should().Contain("nginx:1.25"); - result.UpdatedContents.Should().Contain("busybox:stable"); + result.UpdatedContents.Should().Contain("my-registry.com/busybox:stable"); result.UpdatedContents.Should().Contain("redis:6.0"); // Should remain unchanged } @@ -237,6 +237,31 @@ public void UpdateImages_WithComplexPatch_UpdatesCorrectly() updatedLines.Should().HaveCount(3); } + [Theory] + [TestCase("docker.io/nginx:1.27.1", "docker.io/nginx:1.28.0")] + [TestCase("nginx:1.27.1", "nginx:1.28.0")] + [TestCase("us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v1", + "us-docker.pkg.dev/shared-gke-dev-gqtrxy/argo-test/helloworld:v2")] + public void ReturnsSameImageBaseAsInYaml(string originalImage, string expectedImage) + { + var yamlContent = $@" +- op: replace + path: /spec/template/spec/containers/0/image + value: {originalImage}"; + + var replacer = new YamlJson6902PatchImageReplacer(yamlContent, ArgoCDConstants.DefaultContainerRegistry, log); + + var update = new List + { + new(ContainerImageReference.FromReferenceString(expectedImage, ArgoCDConstants.DefaultContainerRegistry)) + }; + + var result = replacer.UpdateImages(update); + + result.UpdatedImageReferences.Should().ContainSingle().Which.Should().Be(expectedImage); + result.UpdatedContents.Should().Contain(expectedImage); + } + [Test] public void CombineResults_WithMultipleResults_MergesReplacementsAndUsesLatestContent() { diff --git a/source/Calamari/ArgoCD/ContainerImageReplacer.cs b/source/Calamari/ArgoCD/ContainerImageReplacer.cs index 7edbea38f9..e8b966f0f9 100644 --- a/source/Calamari/ArgoCD/ContainerImageReplacer.cs +++ b/source/Calamari/ArgoCD/ContainerImageReplacer.cs @@ -242,13 +242,13 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection i.Comparison.MatchesImage()); + if (matchedUpdate != null) { + var resultingReference = currentReference.WithTag(matchedUpdate.Reference.Tag); // Only do replacement if the tag is different if (!matchedUpdate.Comparison.TagMatch) { - var newReference = currentReference.WithTag(matchedUpdate.Reference.Tag); - // Pattern ensures we only update lines with `image: ` OR `- image: `. // Ignores comments and white space, while preserving any quotes around the image name var pattern = $@"(?<=^\s*-?\s*image:\s*)([""']?){Regex.Escape(container.Image)}\1(?=\s*(#.*)?$)"; @@ -258,15 +258,15 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection { kustomizationFile }; + log.Verbose("kustomization file found, processing images and discovering patch files"); if (updateKustomizePatches) { - log.Verbose("kustomization file found, processing images and discovering patch files"); - var patchDiscovery = new KustomizePatchDiscovery(fileSystem, log); var patchFiles = patchDiscovery.DiscoverPatch(kustomizationFile); diff --git a/source/Calamari/ArgoCD/Helm/HelmContainerImageReplacer.cs b/source/Calamari/ArgoCD/Helm/HelmContainerImageReplacer.cs index 539268f753..0131a9f3fb 100644 --- a/source/Calamari/ArgoCD/Helm/HelmContainerImageReplacer.cs +++ b/source/Calamari/ArgoCD/Helm/HelmContainerImageReplacer.cs @@ -36,8 +36,8 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection i.ContainerReference.ToString()).ToList(); + log.Verbose($"Apply template {existingImageReference.TagPath}, {existingImageReference.ImageReference.FriendlyName()}"); + var imagesString = imagesToUpdate.Select(i => i.ContainerReference.FriendlyName()).ToList(); log.Verbose($"Images to Update = {string.Join(",", imagesString)}"); var matchedUpdate = imagesToUpdate.Select(i => new @@ -62,7 +62,7 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection imagesToUpdate) diff --git a/source/Calamari/ArgoCD/KustomizeImageReplacer.cs b/source/Calamari/ArgoCD/KustomizeImageReplacer.cs index 1245420f0f..4a70295ea9 100644 --- a/source/Calamari/ArgoCD/KustomizeImageReplacer.cs +++ b/source/Calamari/ArgoCD/KustomizeImageReplacer.cs @@ -91,6 +91,8 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection new ImageReferenceMatch(i, i.CompareWith(currentReference), existingTagNode)) + return imagesToUpdate.Select(i => new ImageReferenceMatch(i, i.CompareWith(currentReference), existingTagNode, currentReference)) .FirstOrDefault(i => i.Comparison.MatchesImage()); } @@ -186,7 +188,7 @@ string UpdateYamlWithUpdatedNode(bool isIndentedSequence, .Insert(originalImagesSequenceStartIndex, updatedImagesYaml); } - record ImageReferenceMatch(ContainerImageReference Reference, ContainerImageComparison Comparison, YamlScalarNode? ExistingTagNode); + record ImageReferenceMatch(ContainerImageReference Reference, ContainerImageComparison Comparison, YamlScalarNode? ExistingTagNode, ContainerImageReference CurrentReference); } } diff --git a/source/Calamari/ArgoCD/Models/ContainerImageReference.cs b/source/Calamari/ArgoCD/Models/ContainerImageReference.cs index 2591d3434c..cb4cd9a67d 100644 --- a/source/Calamari/ArgoCD/Models/ContainerImageReference.cs +++ b/source/Calamari/ArgoCD/Models/ContainerImageReference.cs @@ -95,9 +95,9 @@ string ToOriginalFormatName() return string.IsNullOrEmpty(Registry) ? ImageName : $"{Registry}/{ImageName}"; } - public string WithTag(string tag) + public ContainerImageReference WithTag(string tag) { - return $"{ToOriginalFormatName()}:{tag}"; + return new ContainerImageReference(Registry, ImageName, tag, DefaultRegistry); } static bool RegistriesMatch(ContainerImageReference reference1, ContainerImageReference reference2) diff --git a/source/Calamari/ArgoCD/YamlJson6902PatchImageReplacer.cs b/source/Calamari/ArgoCD/YamlJson6902PatchImageReplacer.cs index 1e910ad7e6..0796ea902c 100644 --- a/source/Calamari/ArgoCD/YamlJson6902PatchImageReplacer.cs +++ b/source/Calamari/ArgoCD/YamlJson6902PatchImageReplacer.cs @@ -175,18 +175,17 @@ ImageReplacementResult ProcessImageReference(YamlScalarNode imageScalar, if (matchedUpdate != null && !matchedUpdate.Comparison.TagMatch) { - var newImageRef = matchedUpdate.Reference.WithTag(matchedUpdate.Reference.Tag); - imageScalar.Value = newImageRef; + var newImageRef = currentImageRef.WithTag(matchedUpdate.Reference.Tag); + imageScalar.Value = newImageRef.FriendlyName(); if (imageScalar.Style != ScalarStyle.SingleQuoted && imageScalar.Style != ScalarStyle.DoubleQuoted) { imageScalar.Style = ScalarStyle.DoubleQuoted; } - var replacement = $"{matchedUpdate.Reference.ImageName}:{matchedUpdate.Reference.Tag}"; - log.Verbose($"Updated container image in YAML JSON 6902 patch: {newImageRef}"); + log.Verbose($"Updated container image in YAML JSON 6902 patch: {newImageRef.FriendlyName()}"); - return new ImageReplacementResult(yamlContent, new HashSet { replacement }, new HashSet()); + return new ImageReplacementResult(yamlContent, new HashSet { newImageRef.FriendlyName() }, new HashSet()); } return NoChangeResult;