Skip to content
Open
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 @@ -1281,6 +1281,114 @@ public void MultiSource_OutOfScopeSource_IsNotTracked()
source1.CommitSha.Should().HaveLength(40, "source 1 was updated and committed");
}

[Test]
public void MultipleApplications_SharingRepoAndBranch_AreClonedOnce_AndCommittedPerApplication()
{
// Arrange: two applications that point at the same repository + branch, each with its own outdated image.
var updater = CreateConvention();
var runningDeployment = CreateRunningDeployment(("nginx", "index.docker.io/nginx:1.27.1"));

var file1 = Path.Combine("app1", "deployment.yaml");
var file2 = Path.Combine("app2", "deployment.yaml");
originRepo.AddFilesToBranch(argoCDBranchName, [
(file1, MakeDeploymentYaml("app1-deployment", "nginx:1.19")),
(file2, MakeDeploymentYaml("app2-deployment", "nginx:1.19")),
]);

customPropertiesLoader.Load<ArgoCDCustomPropertiesDto>()
.Returns(new ArgoCDCustomPropertiesDto(
[new ArgoCDGatewayDto(GatewayId, "Gateway1")],
[
new ArgoCDApplicationDto(GatewayId, "App1", "argocd", "yaml1", "docker.io", "http://my-argo.com"),
new ArgoCDApplicationDto(GatewayId, "App2", "argocd", "yaml2", "docker.io", "http://my-argo.com"),
],
[new GitCredentialDto(OriginUrl, "", "")]));

argoCdApplicationManifestParser.ParseManifest("yaml1").Returns(BuildDirectoryApp("App1", "app1"));
argoCdApplicationManifestParser.ParseManifest("yaml2").Returns(BuildDirectoryApp("App2", "app2"));

var getResults = CaptureReporterResults();

// Act
updater.Install(runningDeployment);

// Assert
using var scope = new AssertionScope();

// The shared repository is cloned exactly once, even though two applications use it.
log.StandardOut.Count(m => m.Contains("Cloning repository")).Should().Be(1, "the shared repository should only be cloned once");

var results = getResults();
results.Should().HaveCount(2);
var app1 = results.Single(r => r.ApplicationName.Value == "argocd/App1");
var app2 = results.Single(r => r.ApplicationName.Value == "argocd/App2");

var app1Sha = app1.TrackedSourceDetails.Single().CommitSha;
var app2Sha = app2.TrackedSourceDetails.Single().CommitSha;
app1Sha.Should().HaveLength(40, "App1 had an outdated image and was committed");
app2Sha.Should().HaveLength(40, "App2 had an outdated image and was committed");
app1Sha.Should().NotBe(app2Sha, "each application is committed separately within the shared clone");

// Both applications' changes reached the remote.
originRepo.ReadFileFromBranch(argoCDBranchName, file1).Should().Contain("nginx:1.27.1");
originRepo.ReadFileFromBranch(argoCDBranchName, file2).Should().Contain("nginx:1.27.1");
}

[Test]
public void MultipleApplications_SameRepoDifferentBranches_AreClonedOnce_AndCheckedOutPerBranch()
{
// Arrange: two applications in the same repository but targeting different branches.
var updater = CreateConvention();
var runningDeployment = CreateRunningDeployment(("nginx", "index.docker.io/nginx:1.27.1"));

var mainBranch = RepositoryHelpers.MainBranchName;
var file = Path.Combine("app", "deployment.yaml");
originRepo.AddFilesToBranch(argoCDBranchName, [(file, MakeDeploymentYaml("dev-deployment", "nginx:1.19"))]);
originRepo.AddFilesToBranch(mainBranch, [(file, MakeDeploymentYaml("main-deployment", "nginx:1.19"))]);

customPropertiesLoader.Load<ArgoCDCustomPropertiesDto>()
.Returns(new ArgoCDCustomPropertiesDto(
[new ArgoCDGatewayDto(GatewayId, "Gateway1")],
[
new ArgoCDApplicationDto(GatewayId, "App1", "argocd", "yaml1", "docker.io", "http://my-argo.com"),
new ArgoCDApplicationDto(GatewayId, "App2", "argocd", "yaml2", "docker.io", "http://my-argo.com"),
],
[new GitCredentialDto(OriginUrl, "", "")]));

argoCdApplicationManifestParser.ParseManifest("yaml1").Returns(BuildDirectoryApp("App1", "app", ArgoCDBranchFriendlyName));
argoCdApplicationManifestParser.ParseManifest("yaml2").Returns(BuildDirectoryApp("App2", "app", mainBranch.ToFriendlyName()));

var getResults = CaptureReporterResults();

// Act
updater.Install(runningDeployment);

// Assert
using var scope = new AssertionScope();

log.StandardOut.Count(m => m.Contains("Cloning repository")).Should().Be(1, "the shared repository should only be cloned once across both branches");
log.MessagesVerboseFormatted.Should().Contain(m => m.Contains($"Checking out '{argoCDBranchName.Value}'"));
log.MessagesVerboseFormatted.Should().Contain(m => m.Contains($"Checking out '{mainBranch.Value}'"));

var results = getResults();
results.Should().HaveCount(2);
results.Should().OnlyContain(r => r.Updated, "both applications had an outdated image on their respective branch");

originRepo.ReadFileFromBranch(argoCDBranchName, file).Should().Contain("nginx:1.27.1");
originRepo.ReadFileFromBranch(mainBranch, file).Should().Contain("nginx:1.27.1");
}

Application BuildDirectoryApp(string name, string path, string targetRevision = ArgoCDBranchFriendlyName)
=> new ArgoCDApplicationBuilder()
.WithName(name).WithNamespace("argocd")
.WithAnnotations(new Dictionary<string, string>
{
[ArgoCDConstants.Annotations.OctopusProjectAnnotationKey(null)] = ProjectSlug,
[ArgoCDConstants.Annotations.OctopusEnvironmentAnnotationKey(null)] = EnvironmentSlug,
})
.WithSource(new ApplicationSource { OriginalRepoUrl = OriginUrl, Path = path, TargetRevision = targetRevision }, SourceTypeConstants.Directory)
.Build();

static string MakeDeploymentYaml(string name, params string[] images)
{
var containerName = (string image) => image.Split('/').Last().Split(':').First();
Expand Down
38 changes: 38 additions & 0 deletions source/Calamari.Tests/ArgoCD/Conventions/FileUpdateResultTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Calamari.ArgoCD.Conventions.UpdateImageTag;
using FluentAssertions;
using NUnit.Framework;
using Octopus.Calamari.Contracts.ArgoCD;

namespace Calamari.Tests.ArgoCD.Conventions
{
[TestFixture]
public class FileUpdateResultTests
{
[Test]
public void Merge_CombinesImagesReplacedPatchedAndRemovedFiles()
{
var first = new FileUpdateResult(["nginx:1.27.1"], [new FileHash("a.yaml", "h1")], [new FileJsonPatch("a.yaml", "patchA")], ["removedA.yaml"]);
var second = new FileUpdateResult(["redis:7.0", "nginx:1.27.1"], [new FileHash("b.yaml", "h2")], [new FileJsonPatch("b.yaml", "patchB")], ["removedB.yaml"]);

var merged = FileUpdateResult.Merge([first, second]);

merged.UpdatedImages.Should().BeEquivalentTo(["nginx:1.27.1", "redis:7.0"], "duplicate images are de-duplicated");
merged.ReplacedFiles.Should().BeEquivalentTo([new FileHash("a.yaml", "h1"), new FileHash("b.yaml", "h2")]);
merged.PatchedFiles.Should().BeEquivalentTo([new FileJsonPatch("a.yaml", "patchA"), new FileJsonPatch("b.yaml", "patchB")]);
merged.FilesRemoved.Should().BeEquivalentTo(["removedA.yaml", "removedB.yaml"]);
merged.HasChanges().Should().BeTrue();
}

[Test]
public void Merge_OfEmptyResults_HasNoChanges()
{
var merged = FileUpdateResult.Merge([FileUpdateResult.EmptyFileUpdateResult, FileUpdateResult.EmptyFileUpdateResult]);

merged.HasChanges().Should().BeFalse();
merged.UpdatedImages.Should().BeEmpty();
merged.ReplacedFiles.Should().BeEmpty();
merged.PatchedFiles.Should().BeEmpty();
merged.FilesRemoved.Should().BeEmpty();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,43 +1,36 @@
using Calamari.ArgoCD.Conventions.UpdateImageTag;
using Calamari.ArgoCD.Domain;
using Calamari.ArgoCD.Git;
using Calamari.ArgoCD.Models;
using Calamari.Common.Plumbing.FileSystem;
using Calamari.Common.Plumbing.Logging;
using Octopus.Calamari.Contracts.ArgoCD;

namespace Calamari.ArgoCD.Conventions.ManifestTemplating;

public class ApplicationSourceUpdater
// Determines whether a source is in scope for this deployment and builds the file updater (which copies the
// templated package files into the source path). Clone/commit/push is handled by the GroupedRepositoryProcessor.
public class ApplicationSourceFactory
{
readonly Application applicationFromYaml;
readonly DeploymentScope deploymentScope;
readonly RepositoryAdapter repositoryAdapter;
readonly ArgoCommitToGitConfig deploymentConfig;
readonly IPackageRelativeFile[] packageFiles;
readonly ArgoCDGatewayDto gateway;
readonly ILog log;
readonly ICalamariFileSystem fileSystem;
readonly ArgoCDOutputVariablesWriter outputVariablesWriter;

public ApplicationSourceUpdater(Application applicationFromYaml,
ArgoCDGatewayDto gateway,
public ApplicationSourceFactory(Application applicationFromYaml,
DeploymentScope deploymentScope,
ArgoCommitToGitConfig deploymentConfig,
IPackageRelativeFile[] packageFiles,
ILog log,
ICalamariFileSystem fileSystem,
ArgoCDOutputVariablesWriter outputVariablesWriter,
RepositoryAdapter repositoryAdapter)
ICalamariFileSystem fileSystem)
{
this.applicationFromYaml = applicationFromYaml;
this.deploymentScope = deploymentScope;
this.deploymentConfig = deploymentConfig;
this.packageFiles = packageFiles;
this.gateway = gateway;
this.log = log;
this.fileSystem = fileSystem;
this.outputVariablesWriter = outputVariablesWriter;
this.repositoryAdapter = repositoryAdapter;
}

public bool IsAppInScope(ApplicationSourceWithMetadata sourceWithMetadata)
Expand All @@ -50,28 +43,11 @@ public bool IsAppInScope(ApplicationSourceWithMetadata sourceWithMetadata)
return deploymentScope.Matches(annotatedScope);
}

public ManifestUpdateResult ProcessSource(ApplicationSourceWithMetadata sourceWithMetadata)
public ISourceUpdater CreateSourceUpdater(ApplicationSourceWithMetadata sourceWithMetadata)
{
var applicationSource = sourceWithMetadata.Source;
log.Info($"Writing files to repository '{applicationSource.OriginalRepoUrl}' for '{applicationFromYaml.Metadata.Name}'");
applicationFromYaml.Metadata.Annotations.TryGetValue(ArgoCDConstants.Annotations.OctopusPathAnnotationKey(applicationSource.Name.ToApplicationSourceName()), out var pathOverrideFromAnnotation);

applicationFromYaml.Metadata.Annotations.TryGetValue(ArgoCDConstants.Annotations.OctopusPathAnnotationKey(applicationSource.Name.ToApplicationSourceName()), out var pathOverrideFromAnnotation);

var sourceUpdater = new CopyTemplatesSourceUpdater(packageFiles, log, fileSystem, deploymentConfig.PurgeOutputDirectory, pathOverrideFromAnnotation);

var sourceUpdateResult = repositoryAdapter.Process(sourceWithMetadata, sourceUpdater);

outputVariablesWriter.WriteSourceUpdateResultOutputWhenPushResultExists(gateway.Name,
NamespacedApplicationName.Create(applicationFromYaml.Metadata.Name, applicationFromYaml.Metadata.Namespace),
sourceWithMetadata.Index,
sourceUpdateResult);

if (sourceUpdateResult.PushResult is not null)
{
return new ManifestUpdateResult(true, sourceUpdateResult.PushResult.CommitSha, sourceUpdateResult.PushResult.CommitTimestamp, sourceUpdateResult.ReplacedFiles);
}

log.Info("No changes were committed");
return new ManifestUpdateResult(false, null, null, sourceUpdateResult.ReplacedFiles);
return new CopyTemplatesSourceUpdater(packageFiles, log, fileSystem, deploymentConfig.PurgeOutputDirectory, pathOverrideFromAnnotation);
}
}
Loading