Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,40 @@ public void DirectorySource_ImageMatches_Update()
AssertOutputVariables();
}

[Test]
public void DirectorySource_UnknownCrd_LogsWarning()
{
// Arrange
var updater = CreateConvention();
var runningDeployment = CreateRunningDeployment(("nginx", "index.docker.io/nginx:1.27.1"));

var yamlFilename = "include/file1.yaml";
var fileContents = """
apiVersion: my-company.io/v1
kind: MyCustomApp
metadata:
name: sample
spec:
template:
spec:
containers:
- name: nginx
image: nginx:1.19
""";
originRepo.AddFilesToBranch(argoCDBranchName, [(yamlFilename, fileContents)]);

// Act
updater.Install(runningDeployment);

// Assert — file is unchanged and a warning was emitted
var clonedRepoPath = RepositoryHelpers.CloneOrigin(tempDirectory, OriginPath, argoCDBranchName);
AssertFileContents(clonedRepoPath, yamlFilename, fileContents);

log.MessagesWarnFormatted.Should().Contain(m => m.Contains("Type 'my-company.io/v1/MyCustomApp' is not recognised by the Image Update step"));

AssertOutputVariables(false);
}

[Test]
public void DirectorySource_NoPath_DontUpdate()
{
Expand Down
186 changes: 186 additions & 0 deletions source/Calamari.Tests/ArgoCD/ContainerImageReplacerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,192 @@ public void ReplacesTagIfCaseDoesNotMatch()
result.UpdatedImageReferences.Should().ContainSingle(r => r == "nginx:CurrentVersion");
}

[Test]
public void UpdateImages_WithArgoRollout_ReturnsUpdatedYaml()
{
const string inputYaml = @"
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: sample-rollout
spec:
replicas: 1
selector:
matchLabels:
app: sample-rollout
template:
metadata:
labels:
app: sample-rollout
spec:
containers:
- name: nginx
image: nginx:1.19 #Update
- name: alpine
image: alpine:3.21 #Ignore
initContainers:
- name: init-busybox
image: busybox:unstable #Update Init
command: [""echo"", ""Init container added""]
strategy:
canary:
steps:
- setWeight: 20
- pause: {}
";
const string expectedYaml = @"
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: sample-rollout
spec:
replicas: 1
selector:
matchLabels:
app: sample-rollout
template:
metadata:
labels:
app: sample-rollout
spec:
containers:
- name: nginx
image: nginx:1.25 #Update
- name: alpine
image: alpine:3.21 #Ignore
initContainers:
- name: init-busybox
image: busybox:stable #Update Init
command: [""echo"", ""Init container added""]
strategy:
canary:
steps:
- setWeight: 20
- pause: {}
";
var imageReplacer = new ContainerImageReplacer(inputYaml, DefaultContainerRegistry);

var result = imageReplacer.UpdateImages(imagesToUpdate);

result.UpdatedContents.Should().NotBeNull();
result.UpdatedContents.Should().Be(expectedYaml);
result.UpdatedImageReferences.Count.Should().Be(2);
result.UpdatedImageReferences.Should().ContainSingle(r => r == "nginx:1.25");
result.UpdatedImageReferences.Should().ContainSingle(r => r == "busybox:stable");
}

[Test]
public void UpdateImages_WithArgoRollout_NoMatchingImages_LeavesYamlUnchanged()
{
const string inputYaml = @"
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: sample-rollout
spec:
template:
spec:
containers:
- name: alpine
image: alpine:3.21
";
var imageReplacer = new ContainerImageReplacer(inputYaml, DefaultContainerRegistry);

var result = imageReplacer.UpdateImages(imagesToUpdate);

result.UpdatedContents.Should().Be(inputYaml);
result.UpdatedImageReferences.Should().BeEmpty();
}

[Test]
public void UpdateImages_WithArgoRollout_ContainersOnly_ReturnsUpdatedYaml()
{
const string inputYaml = @"
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: sample-rollout
spec:
template:
spec:
containers:
- name: nginx
image: nginx:1.19
strategy:
blueGreen:
activeService: active-svc
previewService: preview-svc
";
const string expectedYaml = @"
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: sample-rollout
spec:
template:
spec:
containers:
- name: nginx
image: nginx:1.25
strategy:
blueGreen:
activeService: active-svc
previewService: preview-svc
";
var imageReplacer = new ContainerImageReplacer(inputYaml, DefaultContainerRegistry);

var result = imageReplacer.UpdateImages(imagesToUpdate);

result.UpdatedContents.Should().Be(expectedYaml);
result.UpdatedImageReferences.Should().ContainSingle(r => r == "nginx:1.25");
}

[Test]
public void UpdateImages_WithUnknownCrd_ReportsUnrecognisedKind()
{
// An arbitrary CRD not in the type map — the replacer cannot handle it
const string inputYaml = @"
apiVersion: my-company.io/v1
kind: MyCustomApp
metadata:
name: sample
spec:
template:
spec:
containers:
- name: nginx
image: nginx:1.19
";
var imageReplacer = new ContainerImageReplacer(inputYaml, DefaultContainerRegistry);

var result = imageReplacer.UpdateImages(imagesToUpdate);

result.UpdatedContents.Should().Be(inputYaml);
result.UpdatedImageReferences.Should().BeEmpty();
result.UnrecognisedKinds.Should().ContainSingle(k => k == "my-company.io/v1/MyCustomApp");
}

[Test]
public void UpdateImages_WithKnownK8sTypeNotHandled_DoesNotReportUnrecognisedKind()
{
// V1Service is a known SDK type — it just doesn't have containers, so it should pass silently
const string inputYaml = @"
apiVersion: v1
kind: Service
metadata:
name: my-service
spec:
ports:
- port: 80
";
var imageReplacer = new ContainerImageReplacer(inputYaml, DefaultContainerRegistry);

var result = imageReplacer.UpdateImages(imagesToUpdate);

result.UpdatedContents.Should().Be(inputYaml);
result.UnrecognisedKinds.Should().BeEmpty();
}

[Test]
public void ReplacerWillMatchImageNameInsensitivelyAndReplaceWithLowerCase()
{
Expand Down
47 changes: 40 additions & 7 deletions source/Calamari/ArgoCD/ContainerImageReplacer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Text.RegularExpressions;
using Calamari.ArgoCD.Conventions;
using Calamari.ArgoCD.Models;
using Calamari.Kubernetes;
using k8s;
using k8s.Models;
using YamlDotNet.RepresentationModel;
Expand All @@ -14,6 +15,11 @@ namespace Calamari.ArgoCD
{
public class ContainerImageReplacer : IContainerImageReplacer
{
static readonly Dictionary<string, Type> RolloutTypeMap = new()
{
["argoproj.io/v1alpha1/Rollout"] = typeof(V1alpha1Rollout)
};

readonly string yamlContent;
readonly string defaultRegistry;

Expand All @@ -35,6 +41,7 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection<ContainerImageRef
var updatedDocuments = new List<string>();
var imageReplacements = new HashSet<string>();
var alreadyUpToDateImages = new HashSet<string>();
var unrecognisedKinds = new HashSet<string>();

foreach (var document in documents)
{
Expand All @@ -58,9 +65,13 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection<ContainerImageRef
continue;
}

var apiVersion = rootNode.GetChildNodeIfExists<YamlScalarNode>("apiVersion")?.Value ?? "";
var kind = rootNode.GetChildNodeIfExists<YamlScalarNode>("kind")?.Value ?? "";
var resourceIdentifier = $"{apiVersion}/{kind}";

try
{
var resources = KubernetesYaml.LoadAllFromString(document.RemoveDocumentSeparators()); // we remove trailing --- to avoid issues with deserialization, and we do it with regex so we can account for newline values etc
var resources = KubernetesYaml.LoadAllFromString(document.RemoveDocumentSeparators(), RolloutTypeMap); // we remove trailing --- to avoid issues with deserialization, and we do it with regex so we can account for newline values etc
if (resources == null || resources.Count == 0)
{
updatedDocuments.Add(document);
Expand All @@ -74,28 +85,35 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection<ContainerImageRef
}

var resource = resources[0];
var (updatedDocument, changes, alreadyUpToDate) = UpdateImagesInKubernetesResource(document, resource, imagesToUpdate.Select(i => i.ContainerReference).ToList());
var (updatedDocument, changes, alreadyUpToDate, wasRecognised) = UpdateImagesInKubernetesResource(document, resource, imagesToUpdate.Select(i => i.ContainerReference).ToList());
imageReplacements.UnionWith(changes);
alreadyUpToDateImages.UnionWith(alreadyUpToDate);
if (!wasRecognised)
unrecognisedKinds.Add(resourceIdentifier);
// NOTE: We don't need to check if a change has been made or not, if it hasn't, the final document will remain unchanged.
updatedDocuments.Add(updatedDocument);
}
catch
{
// If deserialization fails, skip
// Deserialization failed — likely an unknown CRD that the SDK cannot parse
unrecognisedKinds.Add(resourceIdentifier);
updatedDocuments.Add(document);
}
}

// Stitch documents back together, trailing --- will remain in places for valid yaml
return new ImageReplacementResult(string.Concat(updatedDocuments), imageReplacements, alreadyUpToDateImages);
return new ImageReplacementResult(string.Concat(updatedDocuments), imageReplacements, alreadyUpToDateImages, unrecognisedKinds);
}

(string, HashSet<string>, HashSet<string>) UpdateImagesInKubernetesResource(string initialDocument, object? resourceObject, List<ContainerImageReference> imagesToUpdate)
// Returns (updatedDocument, imageReplacements, alreadyUpToDate, wasRecognised)
// wasRecognised is false when the resource type is not handled by any switch case, indicating
// it may be an unknown CRD that the user expects to be updated.
(string, HashSet<string>, HashSet<string>, bool) UpdateImagesInKubernetesResource(string initialDocument, object? resourceObject, List<ContainerImageReference> imagesToUpdate)
{
var updatedDocument = initialDocument;
var imageReplacements = new HashSet<string>();
var alreadyUpToDateImages = new HashSet<string>();
var wasRecognised = true;

List<string> replacementResult;
List<string> alreadyUpToDateResult;
Expand Down Expand Up @@ -174,6 +192,15 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection<ContainerImageRef
alreadyUpToDateImages.UnionWith(alreadyUpToDateResult);
break;

case V1alpha1Rollout rollout:
(updatedDocument, replacementResult, alreadyUpToDateResult) = ReplaceImageReferences(updatedDocument, imagesToUpdate, rollout.Spec?.Template?.Spec?.Containers);
imageReplacements.UnionWith(replacementResult);
alreadyUpToDateImages.UnionWith(alreadyUpToDateResult);
(updatedDocument, replacementResult, alreadyUpToDateResult) = ReplaceImageReferences(updatedDocument, imagesToUpdate, rollout.Spec?.Template?.Spec?.InitContainers);
imageReplacements.UnionWith(replacementResult);
alreadyUpToDateImages.UnionWith(alreadyUpToDateResult);
break;

case V1PodTemplate podTemplate:
(updatedDocument, replacementResult, alreadyUpToDateResult) = ReplaceImageReferences(updatedDocument, imagesToUpdate, podTemplate.Template.Spec.Containers);
imageReplacements.UnionWith(replacementResult);
Expand All @@ -182,10 +209,16 @@ public ImageReplacementResult UpdateImages(IReadOnlyCollection<ContainerImageRef
imageReplacements.UnionWith(replacementResult);
alreadyUpToDateImages.UnionWith(alreadyUpToDateResult);
break;
}

default:
// Known SDK types (e.g. V1Service, V1ConfigMap) intentionally have no containers to
// update and fall through here silently. Unknown types (e.g. unregistered CRDs) are
// not IKubernetesObject and should be flagged so the caller can warn the user.
wasRecognised = resourceObject is IKubernetesObject;
break;
}

return (updatedDocument, imageReplacements, alreadyUpToDateImages);
return (updatedDocument, imageReplacements, alreadyUpToDateImages, wasRecognised);
}

(string, List<string>, List<string>) ReplaceImageReferences(string document, List<ContainerImageReference> imagesToUpdate, IList<V1Container>? containers)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ protected FileUpdateResult Update(string rootPath, HashSet<string> filesToUpdate
var allTargetedImages = new HashSet<string>(imageReplacementResult.UpdatedImageReferences);
allTargetedImages.UnionWith(imageReplacementResult.AlreadyUpToDateImages);

foreach (var unrecognisedKind in imageReplacementResult.UnrecognisedKinds)
{
log.WarnFormat("Type '{0}' is not recognised by the Image Update step. Images on this type will not be updated.", unrecognisedKind);
}

if (imageReplacementResult.UpdatedImageReferences.Count > 0)
{
fileSystem.OverwriteFile(file, imageReplacementResult.UpdatedContents);
Expand Down
6 changes: 5 additions & 1 deletion source/Calamari/ArgoCD/Models/ImageReplacementResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Calamari.ArgoCD.Models
{
public class ImageReplacementResult(string updatedContents, HashSet<string> updatedImageReferences, HashSet<string> alreadyUpToDateImages)
public class ImageReplacementResult(string updatedContents, HashSet<string> updatedImageReferences, HashSet<string> alreadyUpToDateImages, HashSet<string>? unrecognisedKinds = null)
{
public string UpdatedContents { get; } = updatedContents;
public HashSet<string> UpdatedImageReferences { get; } = updatedImageReferences;
Expand All @@ -14,6 +14,10 @@ public class ImageReplacementResult(string updatedContents, HashSet<string> upda
// reference the same image name with different tags.
public HashSet<string> AlreadyUpToDateImages { get; } = alreadyUpToDateImages;

// Resource kinds (formatted as "apiVersion/kind") encountered in the YAML that this replacer
// does not know how to process. Each type must be explicitly handled in ContainerImageReplacer.
public HashSet<string> UnrecognisedKinds { get; } = unrecognisedKinds ?? [];

public static ImageReplacementResult CombineResults(params ImageReplacementResult[] results)
{
if (results == null || results.Length == 0)
Expand Down
Loading