From 219fb831fd664c9c7e30f00fdcb0bf89e6833974 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Thu, 23 Apr 2026 15:08:19 +1000 Subject: [PATCH 01/39] Add ssh credentials for git --- .../ArgoCD/ArgoCDCustomPropertiesDto.cs | 9 +- ...goCDAppImagesInstallConventionHelmTests.cs | 3 +- ...ateArgoCDAppImagesInstallConventionTest.cs | 3 +- ...licationManifestsInstallConventionTests.cs | 80 +++++++++- .../AuthenticatingRepositoryFactoryTests.cs | 140 ++++++++++++++++++ .../ArgoCD/Git/GitCloneSafeUrlTests.cs | 6 +- .../GitPullRequestClientResolverTests.cs | 12 +- .../GitVendorApiAdapter_PullRequestTests.cs | 2 +- .../ArgoCD/Git/RepositoryFactoryTests.cs | 6 +- .../ArgoCD/Git/RepositoryWrapperTest.cs | 4 +- .../UpdateArgoCDAppImagesInstallConvention.cs | 3 +- ...CDApplicationManifestsInstallConvention.cs | 3 +- .../Git/AuthenticatingRepositoryFactory.cs | 24 ++- source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs | 6 +- source/Calamari/ArgoCD/Git/GitConnection.cs | 30 +++- .../GitVendorPullRequestClientResolver.cs | 10 +- .../PullRequests/StringExtensionMethods.cs | 18 +++ .../AzureDevOpsPullRequestClient.cs | 4 +- .../BitBucket/BitBucketPullRequestClient.cs | 2 +- .../Vendors/GitHub/GitHubPullRequestClient.cs | 2 +- .../Vendors/GitLab/GitLabPullRequestClient.cs | 2 +- .../GitLab/GitLabPullRequestClientFactory.cs | 5 +- .../Calamari/ArgoCD/Git/RepositoryFactory.cs | 16 +- .../Calamari/ArgoCD/Git/RepositoryWrapper.cs | 14 +- source/Calamari/Calamari.csproj | 2 +- 25 files changed, 358 insertions(+), 48 deletions(-) create mode 100644 source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs diff --git a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs index c05d1c106f..11d8da78fe 100644 --- a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs +++ b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs @@ -2,7 +2,11 @@ namespace Octopus.Calamari.Contracts.ArgoCD; -public record ArgoCDCustomPropertiesDto(ArgoCDGatewayDto[] Gateways, ArgoCDApplicationDto[] Applications, GitCredentialDto[] Credentials); +public record ArgoCDCustomPropertiesDto( + ArgoCDGatewayDto[] Gateways, + ArgoCDApplicationDto[] Applications, + GitCredentialDto[] Credentials, + GitCredentialSshKeyDto[] SshCredentials); public record ArgoCDGatewayDto(string Id, string Name); @@ -14,4 +18,7 @@ public record ArgoCDApplicationDto( string DefaultRegistry, string? InstanceWebUiUrl); +// GitUsernamePasswordCredentialDto public record GitCredentialDto(string Url, string Username, string Password); + +public record GitCredentialSshKeyDto(string Url, string Username, string PrivateKey, string PublicKey, string Passphrase); \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs index dd3b7acd2f..a0fa6964ac 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs @@ -98,7 +98,8 @@ public void Init() ], [ new GitCredentialDto(OriginUrl, "", "") - ]); + ], + []); customPropertiesLoader.Load().Returns(argoCdCustomPropertiesDto); argoCdApplicationFromYaml = new Application() diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs index 3b1455b9a7..91e003a604 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs @@ -93,7 +93,8 @@ public void Init() ], [ new GitCredentialDto(OriginUrl, "", "") - ]); + ], + []); customPropertiesLoader.Load().Returns(argoCdCustomPropertiesDto); var argoCdApplicationFromYaml = new ArgoCDApplicationBuilder() diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs index 3c9dbad687..2eba98ffe7 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs @@ -75,7 +75,8 @@ public void Init() ], [ new GitCredentialDto(OriginUrl, "", "") - ]); + ], + []); customPropertiesLoader.Load().Returns(argoCdCustomPropertiesDto); var argoCdApplicationFromYaml = new ArgoCDApplicationBuilder() @@ -645,6 +646,83 @@ public void PurgeDoesNotRemoveFilesThatExistInBothRepoAndTemplates() AssertOutputVariables(); } + [Test] + public void ExecuteCopiesFilesWhenUsingSshCredentials() + { + // Arrange — reconfigure the custom properties to use SSH credentials + // instead of HTTPS, using the raw local path as the URL (mimicking an SCP-style + // URL that bypasses GitCloneSafeUrl) + var argoCdCustomPropertiesDto = new ArgoCDCustomPropertiesDto( + [ + new ArgoCDGatewayDto(GatewayId, "Gateway1") + ], + [ + new ArgoCDApplicationDto(GatewayId, + "App1", + "argocd", + "yaml", + "docker.io", + "http://my-argo.com") + ], + [], + [ + new GitCredentialSshKeyDto(RepoUrl, "git", "private-key", "public-key", "passphrase") + ]); + customPropertiesLoader.Load().Returns(argoCdCustomPropertiesDto); + + // The application source URL must match the SSH credential URL + var argoCdApplicationFromYaml = new ArgoCDApplicationBuilder() + .WithName("App1") + .WithAnnotations(new Dictionary() + { + [ArgoCDConstants.Annotations.OctopusProjectAnnotationKey(null)] = ProjectSlug, + [ArgoCDConstants.Annotations.OctopusEnvironmentAnnotationKey(null)] = EnvironmentSlug, + }) + .WithSource(new ApplicationSource() + { + OriginalRepoUrl = RepoUrl, + Path = "", + TargetRevision = ArgoCDBranchFriendlyName, + }, + SourceTypeConstants.Directory) + .Build(); + + argoCdApplicationManifestParser.ParseManifest(Arg.Any()) + .Returns(argoCdApplicationFromYaml); + + const string firstFilename = "first.yaml"; + CreateFileUnderPackageDirectory(firstFilename); + + var nonSensitiveCalamariVariables = new NonSensitiveCalamariVariables() + { + [KnownVariables.OriginalPackageDirectoryPath] = WorkingDirectory, + [SpecialVariables.Git.InputPath] = "", + [SpecialVariables.Git.CommitMethod] = "DirectCommit", + [SpecialVariables.Git.CommitMessageSummary] = "Octopus did this via SSH", + [ProjectVariables.Slug] = ProjectSlug, + [DeploymentEnvironment.Slug] = EnvironmentSlug, + }; + var allVariables = new CalamariVariables(); + allVariables.Merge(nonSensitiveCalamariVariables); + + var runningDeployment = new RunningDeployment("./arbitraryFile.txt", allVariables); + runningDeployment.CurrentDirectoryProvider = DeploymentWorkingDirectory.StagingDirectory; + runningDeployment.StagingDirectory = WorkingDirectory; + + // Act + var convention = CreateConvention(nonSensitiveCalamariVariables); + convention.Install(runningDeployment); + + // Assert + var resultPath = RepositoryHelpers.CloneOrigin(tempDirectory, OriginPath, argoCDBranchName); + File.Exists(Path.Combine(resultPath, firstFilename)).Should().BeTrue(); + var resultContent = File.ReadAllText(Path.Combine(resultPath, firstFilename)); + resultContent.Should().Be(firstFilename); + + using var resultRepo = new Repository(resultPath); + resultRepo.Head.Tip.Message.TrimEnd().Should().Be("Octopus did this via SSH"); + } + void AssertOutputVariables(bool updated = true, string matchingApplicationTotalSourceCounts = "1") { using var _ = new AssertionScope(); diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs new file mode 100644 index 0000000000..9e3fc9afb7 --- /dev/null +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Calamari.ArgoCD.Git; +using Calamari.ArgoCD.Git.PullRequests; +using Calamari.Common.Plumbing.FileSystem; +using Calamari.Integration.Time; +using Calamari.Testing.Helpers; +using Calamari.Tests.Fixtures.Integration.FileSystem; +using FluentAssertions; +using NUnit.Framework; +using Octopus.Calamari.Contracts.ArgoCD; + +namespace Calamari.Tests.ArgoCD.Git; + +[TestFixture] +public class AuthenticatingRepositoryFactoryTests +{ + readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); + readonly GitBranchName branchName = GitBranchName.CreateFromFriendlyName("devBranch"); + + InMemoryLog log; + string tempDirectory; + string OriginPath => Path.Combine(tempDirectory, "origin"); + RepositoryFactory repositoryFactory; + + [SetUp] + public void Init() + { + log = new InMemoryLog(); + tempDirectory = fileSystem.CreateTemporaryDirectory(); + RepositoryHelpers.CreateBareRepository(OriginPath); + RepositoryHelpers.CreateBranchIn(branchName, OriginPath); + + repositoryFactory = new RepositoryFactory( + log, + fileSystem, + tempDirectory, + new GitVendorPullRequestClientResolver([]), + new SystemClock()); + } + + [TearDown] + public void Cleanup() + { + RepositoryHelpers.DeleteRepositoryDirectory(fileSystem, tempDirectory); + } + + [Test] + public void SshCredentialIsSelectedWhenUrlMatchesSshCredential() + { + var factory = new AuthenticatingRepositoryFactory( + new Dictionary(), + new Dictionary + { + // Use the local path as the SSH credential URL so the clone actually works + [OriginPath] = new GitCredentialSshKeyDto(OriginPath, "git", "private-key", "public-key", "passphrase") + }, + repositoryFactory, + log); + + using var wrapper = factory.CloneRepository(OriginPath, branchName.ToFriendlyName()); + wrapper.Should().NotBeNull(); + } + + [Test] + public void HttpsCredentialIsSelectedWhenUrlMatchesHttpsCredential() + { + var httpsUrl = RepositoryHelpers.ToFileUri(OriginPath); + var factory = new AuthenticatingRepositoryFactory( + new Dictionary + { + [httpsUrl] = new GitCredentialDto(httpsUrl, "", "") + }, + new Dictionary(), + repositoryFactory, + log); + + using var wrapper = factory.CloneRepository(httpsUrl, branchName.ToFriendlyName()); + wrapper.Should().NotBeNull(); + } + + [Test] + public void SshCredentialTakesPriorityOverHttpsWhenBothMatch() + { + var url = OriginPath; + var factory = new AuthenticatingRepositoryFactory( + new Dictionary + { + [url] = new GitCredentialDto(url, "https-user", "https-pass") + }, + new Dictionary + { + [url] = new GitCredentialSshKeyDto(url, "ssh-user", "private-key", "public-key", "passphrase") + }, + repositoryFactory, + log); + + using var wrapper = factory.CloneRepository(url, branchName.ToFriendlyName()); + wrapper.Should().NotBeNull(); + } + + [Test] + public void AnonymousCloneWhenNoCredentialsMatch() + { + var originUrl = RepositoryHelpers.ToFileUri(OriginPath); + var factory = new AuthenticatingRepositoryFactory( + new Dictionary(), + new Dictionary(), + repositoryFactory, + log); + + using var wrapper = factory.CloneRepository(originUrl, branchName.ToFriendlyName()); + wrapper.Should().NotBeNull(); + log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); + } + + [Test] + public void ScpStyleUrlDoesNotMatchHttpsCredential() + { + // An SCP-style URL should not accidentally match an HTTPS credential for the same host + var scpUrl = "git@github.com:org/repo.git"; + var httpsUrl = "https://github.com/org/repo.git"; + + var factory = new AuthenticatingRepositoryFactory( + new Dictionary + { + [httpsUrl] = new GitCredentialDto(httpsUrl, "user", "pass") + }, + new Dictionary(), + repositoryFactory, + log); + + // This will fail to clone (no real repo at this URL) but we can verify it + // falls through to anonymous because the SCP URL doesn't match the HTTPS URL + var act = () => factory.CloneRepository(scpUrl, "main"); + act.Should().Throw(); // clone failure expected + log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); + } +} diff --git a/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs index de94f1156a..2f264149d6 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs @@ -13,7 +13,7 @@ public class GitCloneSafeUrlTests public void FromString_ShouldConvertGitScpAddressToUri(string scpAddress, string expectedUrl) { var result = GitCloneSafeUrl.FromString(scpAddress); - result.AbsoluteUri.Should().Be(expectedUrl); + result.Should().Be(expectedUrl); } [Test] @@ -21,7 +21,7 @@ public void FromString_ShouldReturnValidUriUnmodified() { var uri = "https://github.com/Foo/Bar.git"; var result = GitCloneSafeUrl.FromString(uri); - result.AbsoluteUri.Should().Be(uri); + result.Should().Be(uri); } [Test] @@ -37,6 +37,6 @@ public void ANonProtocoledString_AutomaticallyAddsOci() { var uri = "registry-1.docker.io/bitnamicharts"; var result = GitCloneSafeUrl.FromString(uri); - result.AbsoluteUri.Should().Be($"oci://{uri}"); + result.Should().Be($"oci://{uri}"); } } \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs index 9b07e0f02c..8de52bfa91 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs @@ -54,7 +54,7 @@ GitVendorPullRequestClientResolver CreateResolverWithAllRealFactories() [Test] public async Task GitHubUrl_ResolvesToGitHubClient() { - connection.Url.Returns(new Uri("https://github.com/org/repo")); + connection.Url.Returns("https://github.com/org/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -65,7 +65,7 @@ public async Task GitHubUrl_ResolvesToGitHubClient() [Test] public async Task GitLabCloudUrl_ResolvesToGitLabClient() { - connection.Url.Returns(new Uri("https://gitlab.com/org/repo")); + connection.Url.Returns("https://gitlab.com/org/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -76,7 +76,7 @@ public async Task GitLabCloudUrl_ResolvesToGitLabClient() [Test] public async Task AzureDevOpsUrl_ResolvesToAzureDevOpsClient() { - connection.Url.Returns(new Uri("https://dev.azure.com/org/project/_git/repo")); + connection.Url.Returns("https://dev.azure.com/org/project/_git/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -87,7 +87,7 @@ public async Task AzureDevOpsUrl_ResolvesToAzureDevOpsClient() [Test] public async Task BitBucketUrl_ResolvesToBitBucketClient() { - connection.Url.Returns(new Uri("https://bitbucket.org/org/repo")); + connection.Url.Returns("https://bitbucket.org/org/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -98,7 +98,7 @@ public async Task BitBucketUrl_ResolvesToBitBucketClient() [Test] public async Task UnrecognisedUrl_ReturnsNull() { - connection.Url.Returns(new Uri("https://someunknown.example/org/repo")); + connection.Url.Returns("https://someunknown.example/org/repo"); var resolver = new GitVendorPullRequestClientResolver(new IGitVendorPullRequestClientFactory[] { new NeverMatchesFactory() @@ -112,7 +112,7 @@ public async Task UnrecognisedUrl_ReturnsNull() [Test] public async Task SelfHostedUrl_WithMatchingSelfHostedFactory_ReturnsExpectedClient() { - connection.Url.Returns(new Uri("https://mygitlab.company.com/org/repo")); + connection.Url.Returns("https://mygitlab.company.com/org/repo"); var expectedClient = Substitute.For(); var factory = Substitute.For(); factory.CanHandleAsCloudHosted(Arg.Any()).Returns(false); diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs index 8509b9da82..213155930b 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs @@ -119,7 +119,7 @@ async Task TestPullRequest(string repositoryUrl, string defaultBranch, string cl repository.Network.Push(newBranch, new PushOptions() { CredentialsProvider = credentialsHandler }); var conn = Substitute.For(); - conn.Url.Returns(new Uri(repositoryUrl)); + conn.Url.Returns(repositoryUrl); conn.Username.Returns(cloneUsername); conn.Password.Returns(clonePassword); try diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index 7ce6b99ed9..5352891d82 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -51,7 +51,7 @@ public void ThrowsExceptionIfUrlDoesNotExist() { var connection = new GitConnection("username", "password", - new Uri("file://doesNotExist"), + "file://doesNotExist", branchName); Action action = () => repositoryFactory.CloneRepository("name", connection); @@ -66,7 +66,7 @@ public void CanCloneAnExistingRepositoryWithExplicitBranchNameAndAssociatedFiles var originalContent = "This is the file content"; CreateCommitOnOrigin(branchName, filename, originalContent); - var connection = new GitConnection(null, null, new Uri(OriginPath), branchName); + var connection = new GitConnection(null, null, OriginPath, branchName); var clonedRepository = repositoryFactory.CloneRepository("CanCloneAnExistingRepository", connection); clonedRepository.Should().NotBeNull(); @@ -83,7 +83,7 @@ public void CanCloneAnExistingRepositoryAtHEADAndAssociatedFiles() var originalContent = "This is the file content"; CreateCommitOnOrigin(RepositoryHelpers.MainBranchName, filename, originalContent); - var connection = new GitConnection(null, null, new Uri(OriginPath), new GitHead()); + var connection = new GitConnection(null, null, OriginPath, new GitHead()); var clonedRepository = repositoryFactory.CloneRepository("CanCloneAnExistingRepository", connection); clonedRepository.Should().NotBeNull(); diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs index 264e9b5fae..d33da02f08 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs @@ -54,7 +54,7 @@ public void Init() gitVendorAgnosticPullRequestClientFactory.TryResolve(Arg.Any(), Arg.Any(), Arg.Any()).Returns(gitVendorPullRequestClient); var repositoryFactory = new RepositoryFactory(log, fileSystem, tempDirectory, gitVendorAgnosticPullRequestClientFactory, new SystemClock()); - gitConnection = new GitConnection(null, null, new Uri(OriginPath), branchName); + gitConnection = new GitConnection(null, null, OriginPath, branchName); repository = repositoryFactory.CloneRepository(repositoryPath, gitConnection); } @@ -177,7 +177,7 @@ public void CloningAReferenceOtherThanABranchFails() bareOrigin.AddFilesToBranch(branchName, ("file.yaml", "")); bareOrigin.ApplyTag("1.0.0", bareOrigin.Head.Tip.Sha); - gitConnection = new GitConnection(null, null, new Uri(OriginPath), GitReference.CreateFromString("1.0.0")); + gitConnection = new GitConnection(null, null, OriginPath, GitReference.CreateFromString("1.0.0")); var repositoryFactory = new RepositoryFactory(log, fileSystem, tempDirectory, gitVendorAgnosticPullRequestClientFactory, new SystemClock()); var act = () => repositoryFactory.CloneRepository($"{repositoryPath}/sut", gitConnection); diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs index f34dd68157..ea7c419fe9 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs @@ -61,7 +61,8 @@ public void Install(RunningDeployment deployment) var argoProperties = customPropertiesLoader.Load(); var gitCredentials = argoProperties.Credentials.ToDictionary(c => c.Url); - var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, repositoryFactory, log); + var sshCredentials = argoProperties.SshCredentials.ToDictionary(c => c.Url); + var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, sshCredentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); log.LogApplicationCounts(deploymentScope, argoProperties.Applications); diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs index 9e40be4577..0749b0bb77 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs @@ -70,7 +70,8 @@ public void Install(RunningDeployment deployment) var argoProperties = customPropertiesLoader.Load(); var gitCredentials = argoProperties.Credentials.ToDictionary(c => c.Url); - var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, repositoryFactory, log); + var sshCredentials = argoProperties.SshCredentials.ToDictionary(c => c.Url); + var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, sshCredentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); log.LogApplicationCounts(deploymentScope, argoProperties.Applications); diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index a5d7f3053f..8bbf074a07 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -7,19 +7,37 @@ namespace Calamari.ArgoCD.Git; public class AuthenticatingRepositoryFactory { readonly Dictionary gitCredentials; + readonly Dictionary sshCredentials; readonly RepositoryFactory repositoryFactory; readonly ILog log; - - public AuthenticatingRepositoryFactory(Dictionary gitCredentials, RepositoryFactory repositoryFactory, ILog log) + public AuthenticatingRepositoryFactory( + Dictionary gitCredentials, + Dictionary sshCredentials, + RepositoryFactory repositoryFactory, + ILog log) { this.gitCredentials = gitCredentials; + this.sshCredentials = sshCredentials; this.repositoryFactory = repositoryFactory; this.log = log; } - + public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevision) { + var sshCredential = sshCredentials.GetValueOrDefault(requestedUrl); + if (sshCredential is not null) + { + var sshConnection = new SshGitConnection( + sshCredential.Username, + requestedUrl, + GitReference.CreateFromString(targetRevision), + sshCredential.PrivateKey, + sshCredential.PublicKey, + sshCredential.Passphrase); + return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), sshConnection); + } + var gitCredential = gitCredentials.GetValueOrDefault(requestedUrl); if (gitCredential == null) { diff --git a/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs b/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs index 65f2552dd0..205d0de1a7 100644 --- a/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs +++ b/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs @@ -29,7 +29,7 @@ public static class GitCloneSafeUrl /// This is invoked during yaml deserialisation, and may be applied to repoURLs which will never actually be cloned /// during step execution (eg sources which have not been scoped to the step). /// - public static Uri FromString(string uri) + public static string FromString(string uri) { if (!uri.StartsWith(StandardSshScpPrefix)) { @@ -38,7 +38,7 @@ public static Uri FromString(string uri) { uri = $"oci://{uri}"; } - return new Uri(uri); + return new Uri(uri).AbsoluteUri; } var scpAddress = uri.Substring(StandardSshScpPrefix.Length); @@ -55,6 +55,6 @@ public static Uri FromString(string uri) Host = host, Path = path }; - return uriBuilder.Uri; + return uriBuilder.Uri.AbsoluteUri; } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/GitConnection.cs b/source/Calamari/ArgoCD/Git/GitConnection.cs index d2dacd5df6..656ddab42c 100644 --- a/source/Calamari/ArgoCD/Git/GitConnection.cs +++ b/source/Calamari/ArgoCD/Git/GitConnection.cs @@ -1,5 +1,4 @@ #nullable enable -using System; namespace Calamari.ArgoCD.Git { @@ -7,9 +6,9 @@ public interface IRepositoryConnection { public string? Username { get; } public string? Password { get; } - public Uri Url { get; } + public string Url { get; } } - + public interface IGitConnection : IRepositoryConnection { public GitReference GitReference { get; } @@ -17,7 +16,7 @@ public interface IGitConnection : IRepositoryConnection public class GitConnection : IGitConnection { - public GitConnection(string? username, string? password, Uri url, GitReference gitReference) + public GitConnection(string? username, string? password, string url, GitReference gitReference) { Username = username; Password = password; @@ -27,7 +26,28 @@ public GitConnection(string? username, string? password, Uri url, GitReference g public string? Username { get; } public string? Password { get; } - public Uri Url { get; } + public string Url { get; } + public GitReference GitReference { get; } + } + + public class SshGitConnection : IGitConnection + { + public SshGitConnection(string? username, string url, GitReference gitReference, string privateKey, string publicKey, string passphrase) + { + Username = username; + Url = url; + GitReference = gitReference; + PrivateKey = privateKey; + PublicKey = publicKey; + Passphrase = passphrase; + } + + public string? Username { get; } + public string? Password => null; + public string Url { get; } public GitReference GitReference { get; } + public string PrivateKey { get; } + public string PublicKey { get; } + public string Passphrase { get; } } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs index bcbda328f1..9d707208b5 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs @@ -25,15 +25,21 @@ public GitVendorPullRequestClientResolver(IEnumerable TryResolve(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { + if (!Uri.TryCreate(repositoryConnection.Url, UriKind.Absolute, out var repositoryUri)) + { + log.Verbose($"Git vendor: Unknown (URL is not a valid URI: '{repositoryConnection.Url}')"); + return null; + } + //first try getting a handling factory by checking if it can be handled as a cloud hosted repo - var handlingFactory = clientFactories.SingleOrDefault(f => f.CanHandleAsCloudHosted(repositoryConnection.Url)); + var handlingFactory = clientFactories.SingleOrDefault(f => f.CanHandleAsCloudHosted(repositoryUri)); //if we still don't have a handling factory, try the self-hosted checks. if (handlingFactory is null) { foreach (var clientFactory in clientFactories) { - if (!await clientFactory.CanHandleAsSelfHosted(repositoryConnection.Url, cancellationToken)) + if (!await clientFactory.CanHandleAsSelfHosted(repositoryUri, cancellationToken)) { continue; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/StringExtensionMethods.cs b/source/Calamari/ArgoCD/Git/PullRequests/StringExtensionMethods.cs index d75f75be32..53e64e6fff 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/StringExtensionMethods.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/StringExtensionMethods.cs @@ -13,6 +13,24 @@ public static string StripGitSuffix(this string url) return url; } + /// + /// Parses a repository URL string into a . Pull request clients require + /// HTTPS URLs for REST API calls — SCP-style SSH URLs (e.g. git@host:path) are not valid URIs. + /// The guards against this by returning null + /// for non-URI URLs, but this method provides a clear error if one slips through. + /// + public static Uri ParseAsHttpsUri(this string repositoryUrl) + { + if (!Uri.TryCreate(repositoryUrl, UriKind.Absolute, out var uri)) + { + throw new InvalidOperationException( + $"Pull request operations require an HTTPS repository URL, but got: '{repositoryUrl}'. " + + "SCP-style SSH URLs (e.g. git@github.com:org/repo.git) are not supported for pull request creation."); + } + + return uri; + } + // This extension method is here until we can drop netfx and put it into the interface public static string[] ExtractPropertiesFromUrlPath(this Uri repositoryUri) { diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs index 57d09eb0dc..7c17d4ebcc 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs @@ -32,7 +32,7 @@ public async Task CreatePullRequest(string pullRequestTitle, Convert.ToBase64String(Encoding.ASCII.GetBytes($"{repositoryConnection.Username}:{repositoryConnection.Password}"))); - var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url); + var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url.ParseAsHttpsUri()); var apiUrl = $"https://{CloudHost}/{organizationName}/{projectName}/_apis/git/repositories/{repositoryName}/pullrequests?api-version=7.1"; var pullRequest = new @@ -63,7 +63,7 @@ public async Task CreatePullRequest(string pullRequestTitle, public string GenerateCommitUrl(string commit) { - var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url); + var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url.ParseAsHttpsUri()); return $"https://{CloudHost}/{organizationName}/{projectName}/_git/{repositoryName}/commit/{commit}"; } } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs index 622797cdb4..247f89342d 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs @@ -21,7 +21,7 @@ public BitBucketPullRequestClient(IRepositoryConnection repositoryConnection, Ur this.repositoryConnection = repositoryConnection; this.baseUrl = baseUrl; - var parts = repositoryConnection.Url.ExtractPropertiesFromUrlPath(); + var parts = repositoryConnection.Url.ParseAsHttpsUri().ExtractPropertiesFromUrlPath(); workspace = parts[0]; repositorySlug = parts[1]; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs index abad36002b..0e771ae83e 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs @@ -19,7 +19,7 @@ public GitHubPullRequestClient(IGitHubClient client, IRepositoryConnection repos this.client = client; this.baseUrl = baseUrl; - var parts = repositoryConnection.Url.ExtractPropertiesFromUrlPath(); + var parts = repositoryConnection.Url.ParseAsHttpsUri().ExtractPropertiesFromUrlPath(); repoOwner = parts[0]; repoName = parts[1]; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs index 6168387ad0..4eef98107c 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs @@ -18,7 +18,7 @@ public GitLabPullRequestClient(GitLabClient gitLabClient, IRepositoryConnection this.gitLabClient = gitLabClient; this.baseUrl = baseUrl; - var parts = repositoryConnection.Url.ExtractPropertiesFromUrlPath(); + var parts = repositoryConnection.Url.ParseAsHttpsUri().ExtractPropertiesFromUrlPath(); projectPath = $"{parts[^2]}/{parts[^1]}"; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs index 151629be52..6ca2b9bda2 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs @@ -26,9 +26,10 @@ public async Task Create(IRepositoryConnection repo { await Task.CompletedTask; //if we aren't cloud hosted, we must be self-hosted - var host = CanHandleAsCloudHosted(repositoryConnection.Url) + var repositoryUri = repositoryConnection.Url.ParseAsHttpsUri(); + var host = CanHandleAsCloudHosted(repositoryUri) ? CloudHost - : SelfHostedGitLabInspector.GetSelfHostedBaseRepositoryUrl(repositoryConnection.Url); + : SelfHostedGitLabInspector.GetSelfHostedBaseRepositoryUrl(repositoryUri); var client = new GitLabClient(host, repositoryConnection.Password); return new GitLabPullRequestClient(client, repositoryConnection, new Uri(host)); diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index 189a69ecaf..acd141d33b 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -64,7 +64,19 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che BranchName = (gitConnection.GitReference as GitBranchName)?.ToFriendlyName() }; - if (gitConnection.Username != null && gitConnection.Password != null) + if (gitConnection is SshGitConnection ssh) + { + options.FetchOptions.CredentialsProvider = (url, usernameFromUrl, types) => new SshUserKeyMemoryCredentials + { + Username = ssh.Username, + PublicKey = ssh.PublicKey, + PrivateKey = ssh.PrivateKey, + Passphrase = ssh.Passphrase + }; + // TODO(eddy): Implement proper host key verification + options.FetchOptions.CertificateCheck = (cert, valid, host) => true; + } + else if (gitConnection.Username != null && gitConnection.Password != null) { options.FetchOptions.CredentialsProvider = (url, usernameFromUrl, types) => new UsernamePasswordCredentials { @@ -79,7 +91,7 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che { try { - repoPath = Repository.Clone(gitConnection.Url.AbsoluteUri, checkoutPath, options); + repoPath = Repository.Clone(gitConnection.Url, checkoutPath, options); timedOp.Complete(); } catch (Exception e) diff --git a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs index 09673c7cb3..498a96729c 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs @@ -38,7 +38,9 @@ public class RepositoryWrapper( public string WorkingDirectory => repository.Info.WorkingDirectory; - Credentials RepositoryCredentials => new UsernamePasswordCredentials { Username = connection.Username, Password = connection.Password }; + Credentials RepositoryCredentials => connection is SshGitConnection ssh + ? new SshUserKeyMemoryCredentials { Username = ssh.Username, PublicKey = ssh.PublicKey, PrivateKey = ssh.PrivateKey, Passphrase = ssh.Passphrase } + : new UsernamePasswordCredentials { Username = connection.Username, Password = connection.Password }; // returns true if changes were made to the repository public bool CommitChanges(string summary, string description) @@ -160,7 +162,7 @@ public async Task PushChanges(bool requiresPullRequest, commit.Sha, commit.ShortSha(), commit.Author.When, - connection.Url.AbsoluteUri, + connection.Url, title, uri, number); @@ -217,7 +219,9 @@ public void PushChanges(GitBranchName branchName) var pushOptions = new PushOptions { CredentialsProvider = (url, usernameFromUrl, types) => RepositoryCredentials, - OnPushStatusError = errors => errorsDetected = errors + OnPushStatusError = errors => errorsDetected = errors, + // TODO(eddy): Implement proper host key verification for SSH connections + CertificateCheck = connection is SshGitConnection ? (cert, valid, host) => true : null }; repository.Network.Push(repository.Head, pushOptions); @@ -233,7 +237,9 @@ void FetchAndRebase(GitBranchName branchName) var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToList(); var fetchOptions = new FetchOptions { - CredentialsProvider = (url, usernameFromUrl, types) => RepositoryCredentials + CredentialsProvider = (url, usernameFromUrl, types) => RepositoryCredentials, + // TODO(eddy): Implement proper host key verification for SSH connections + CertificateCheck = connection is SshGitConnection ? (cert, valid, host) => true : null }; try diff --git a/source/Calamari/Calamari.csproj b/source/Calamari/Calamari.csproj index 47df31f8fa..6b575866eb 100644 --- a/source/Calamari/Calamari.csproj +++ b/source/Calamari/Calamari.csproj @@ -49,7 +49,7 @@ - + From 9e80fee30821cdbf1f982b4409d900da4395e459 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Thu, 23 Apr 2026 15:17:08 +1000 Subject: [PATCH 02/39] Nullable for new field --- source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs | 3 ++- .../Conventions/UpdateArgoCDAppImagesInstallConvention.cs | 3 ++- .../UpdateArgoCDApplicationManifestsInstallConvention.cs | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs index 11d8da78fe..30ac0909d1 100644 --- a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs +++ b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs @@ -6,7 +6,8 @@ public record ArgoCDCustomPropertiesDto( ArgoCDGatewayDto[] Gateways, ArgoCDApplicationDto[] Applications, GitCredentialDto[] Credentials, - GitCredentialSshKeyDto[] SshCredentials); + // Nullable for backwards compatibility + GitCredentialSshKeyDto[]? SshCredentials); public record ArgoCDGatewayDto(string Id, string Name); diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs index ea7c419fe9..e5e7ef0a86 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; using System.Linq; using Calamari.ArgoCD.Conventions.UpdateImageTag; using Calamari.ArgoCD.Git; @@ -61,7 +62,7 @@ public void Install(RunningDeployment deployment) var argoProperties = customPropertiesLoader.Load(); var gitCredentials = argoProperties.Credentials.ToDictionary(c => c.Url); - var sshCredentials = argoProperties.SshCredentials.ToDictionary(c => c.Url); + var sshCredentials = argoProperties.SshCredentials?.ToDictionary(c => c.Url) ?? new Dictionary(); var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, sshCredentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs index 0749b0bb77..54d495950a 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; using System.IO; using System.Linq; using Calamari.ArgoCD.Conventions.ManifestTemplating; @@ -70,7 +71,7 @@ public void Install(RunningDeployment deployment) var argoProperties = customPropertiesLoader.Load(); var gitCredentials = argoProperties.Credentials.ToDictionary(c => c.Url); - var sshCredentials = argoProperties.SshCredentials.ToDictionary(c => c.Url); + var sshCredentials = argoProperties.SshCredentials?.ToDictionary(c => c.Url) ?? new Dictionary(); var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, sshCredentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); From 5dcdc689f82feebca4371d7850cf71d90bb6298c Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Thu, 23 Apr 2026 15:28:18 +1000 Subject: [PATCH 03/39] Source libgit2sharp from feedz.io --- NuGet.Config | 1 + 1 file changed, 1 insertion(+) diff --git a/NuGet.Config b/NuGet.Config index aac9575c70..7c3c4ebc53 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -22,6 +22,7 @@ + From b27f13dde94e5d470c3c32450e2b8a22b648ec04 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Thu, 23 Apr 2026 15:36:56 +1000 Subject: [PATCH 04/39] And native binaries --- NuGet.Config | 1 + 1 file changed, 1 insertion(+) diff --git a/NuGet.Config b/NuGet.Config index 7c3c4ebc53..01f1ce7bca 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -23,6 +23,7 @@ + From b254337f8fb0874c6e15c8ba731a7c5daea8d924 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 24 Apr 2026 10:16:35 +1000 Subject: [PATCH 05/39] Test cleanup --- .../AuthenticatingRepositoryFactoryTests.cs | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs index 9e3fc9afb7..8c47118ffe 100644 --- a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -13,16 +13,15 @@ namespace Calamari.Tests.ArgoCD.Git; -[TestFixture] -public class AuthenticatingRepositoryFactoryTests +public abstract class AuthenticatingRepositoryFactoryTestBase { - readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); - readonly GitBranchName branchName = GitBranchName.CreateFromFriendlyName("devBranch"); + protected readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); + protected readonly GitBranchName branchName = GitBranchName.CreateFromFriendlyName("devBranch"); - InMemoryLog log; - string tempDirectory; - string OriginPath => Path.Combine(tempDirectory, "origin"); - RepositoryFactory repositoryFactory; + protected InMemoryLog log; + protected string tempDirectory; + protected string OriginPath => Path.Combine(tempDirectory, "origin"); + protected RepositoryFactory repositoryFactory; [SetUp] public void Init() @@ -46,6 +45,9 @@ public void Cleanup() RepositoryHelpers.DeleteRepositoryDirectory(fileSystem, tempDirectory); } +[TestFixture] +public class SshUrlTests : AuthenticatingRepositoryFactoryTestBase +{ [Test] public void SshCredentialIsSelectedWhenUrlMatchesSshCredential() { @@ -64,39 +66,43 @@ public void SshCredentialIsSelectedWhenUrlMatchesSshCredential() } [Test] - public void HttpsCredentialIsSelectedWhenUrlMatchesHttpsCredential() + public void SshCredentialTakesPriorityOverHttpsWhenBothMatch() { - var httpsUrl = RepositoryHelpers.ToFileUri(OriginPath); + var url = OriginPath; var factory = new AuthenticatingRepositoryFactory( new Dictionary { - [httpsUrl] = new GitCredentialDto(httpsUrl, "", "") + [url] = new GitCredentialDto(url, "https-user", "https-pass") + }, + new Dictionary + { + [url] = new GitCredentialSshKeyDto(url, "ssh-user", "private-key", "public-key", "passphrase") }, - new Dictionary(), repositoryFactory, log); - using var wrapper = factory.CloneRepository(httpsUrl, branchName.ToFriendlyName()); + using var wrapper = factory.CloneRepository(url, branchName.ToFriendlyName()); wrapper.Should().NotBeNull(); } +} +[TestFixture] +public class HttpsUrlTests : AuthenticatingRepositoryFactoryTestBase +{ [Test] - public void SshCredentialTakesPriorityOverHttpsWhenBothMatch() + public void HttpsCredentialIsSelectedWhenUrlMatchesHttpsCredential() { - var url = OriginPath; + var httpsUrl = RepositoryHelpers.ToFileUri(OriginPath); var factory = new AuthenticatingRepositoryFactory( new Dictionary { - [url] = new GitCredentialDto(url, "https-user", "https-pass") - }, - new Dictionary - { - [url] = new GitCredentialSshKeyDto(url, "ssh-user", "private-key", "public-key", "passphrase") + [httpsUrl] = new GitCredentialDto(httpsUrl, "", "") }, + new Dictionary(), repositoryFactory, log); - using var wrapper = factory.CloneRepository(url, branchName.ToFriendlyName()); + using var wrapper = factory.CloneRepository(httpsUrl, branchName.ToFriendlyName()); wrapper.Should().NotBeNull(); } @@ -114,7 +120,11 @@ public void AnonymousCloneWhenNoCredentialsMatch() wrapper.Should().NotBeNull(); log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); } +} +[TestFixture] +public class ScpStyleUrlTests : AuthenticatingRepositoryFactoryTestBase +{ [Test] public void ScpStyleUrlDoesNotMatchHttpsCredential() { @@ -138,3 +148,5 @@ public void ScpStyleUrlDoesNotMatchHttpsCredential() log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); } } + +} From a4ff1ddd36d3ef64fcc82444ce121ff06058e895 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 24 Apr 2026 11:00:34 +1000 Subject: [PATCH 06/39] test: basic auth headers are sent for git requests --- .../Git/GitHttpSmartSubTransportTests.cs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs diff --git a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs new file mode 100644 index 0000000000..33ff2a2e9d --- /dev/null +++ b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using Calamari.ArgoCD; +using Calamari.ArgoCD.Git; +using Calamari.ArgoCD.Git.PullRequests; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.FileSystem; +using Calamari.Integration.Time; +using Calamari.Testing.Helpers; +using Calamari.Tests.Fixtures.Integration.FileSystem; +using FluentAssertions; +using NUnit.Framework; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Calamari.Tests.ArgoCD.Git; + +[TestFixture] +public class GitHttpSmartSubTransportTests +{ + static GitHttpSmartSubTransportTests() + { + // Ensure the custom HTTP smart sub-transport is registered with libgit2. + // ArgoCDModule's static constructor handles this; RunClassConstructor is idempotent. + RuntimeHelpers.RunClassConstructor(typeof(ArgoCDModule).TypeHandle); + } + + readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); + + InMemoryLog log; + string tempDirectory; + WireMockServer server; + + [SetUp] + public void Init() + { + log = new InMemoryLog(); + tempDirectory = fileSystem.CreateTemporaryDirectory(); + server = WireMockServer.Start(); + + // Return 200 for any request. The body is not valid git smart HTTP, + // so the clone will fail after the request is sent — but WireMock + // will have recorded the request headers we need to inspect. + server.Given(Request.Create().UsingAnyMethod()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("not-a-git-response")); + } + + [TearDown] + public void Cleanup() + { + server?.Stop(); + server?.Dispose(); + RepositoryHelpers.DeleteRepositoryDirectory(fileSystem, tempDirectory); + } + + [Test] + public void BasicAuthHeaderIsSentOnFirstRequest() + { + var username = "testuser"; + var password = "testpassword"; + var expectedAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + + var repoUrl = new Uri($"{server.Url}/fake-repo.git"); + var connection = new GitConnection(username, password, repoUrl, GitBranchName.CreateFromFriendlyName("main")); + var repositoryFactory = new RepositoryFactory( + log, + fileSystem, + tempDirectory, + new GitVendorPullRequestClientResolver([]), + new SystemClock()); + + // The clone will fail because WireMock doesn't speak git protocol, + // but the HTTP request will have been sent and recorded. + var act = () => repositoryFactory.CloneRepository("test-repo", connection); + act.Should().Throw(); + + var requests = server.LogEntries.ToList(); + requests.Should().NotBeEmpty("at least one HTTP request should have been made"); + + var firstRequest = requests.First(); + firstRequest.RequestMessage.Headers.Should().ContainKey("Authorization"); + + var authHeader = firstRequest.RequestMessage.Headers?["Authorization"].First(); + authHeader.Should().Be($"Basic {expectedAuth}", + "the Basic auth header should be sent proactively on the first request, not after a 401 challenge"); + } + + [Test] + public void NoAuthHeaderIsSentWhenCredentialsAreNotProvided() + { + var repoUrl = new Uri($"{server.Url}/fake-repo.git"); + var connection = new GitConnection(null, null, repoUrl, GitBranchName.CreateFromFriendlyName("main")); + var repositoryFactory = new RepositoryFactory( + log, + fileSystem, + tempDirectory, + new GitVendorPullRequestClientResolver([]), + new SystemClock()); + + var act = () => repositoryFactory.CloneRepository("test-repo", connection); + act.Should().Throw(); + + var requests = server.LogEntries.ToList(); + requests.Should().NotBeEmpty("at least one HTTP request should have been made"); + + var firstRequest = requests.First(); + firstRequest.RequestMessage.Headers.Should().NotContainKey("Authorization", + "no auth header should be sent when credentials are not provided"); + } +} From 9b72271ed083f4ec3fe32e80230282500e21997b Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 24 Apr 2026 11:32:02 +1000 Subject: [PATCH 07/39] Lazily register git transport --- .../Git/GitHttpSmartSubTransportTests.cs | 5 +--- source/Calamari/ArgoCD/ArgoCDModule.cs | 15 ----------- .../Git/LibGit2SharpTransportRegistration.cs | 25 +++++++++++++++++++ .../Calamari/ArgoCD/Git/RepositoryFactory.cs | 2 ++ 4 files changed, 28 insertions(+), 19 deletions(-) create mode 100644 source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs diff --git a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs index 33ff2a2e9d..f982bada7f 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs @@ -1,8 +1,6 @@ using System; using System.Linq; -using System.Runtime.CompilerServices; using System.Text; -using Calamari.ArgoCD; using Calamari.ArgoCD.Git; using Calamari.ArgoCD.Git.PullRequests; using Calamari.Common.Commands; @@ -24,8 +22,7 @@ public class GitHttpSmartSubTransportTests static GitHttpSmartSubTransportTests() { // Ensure the custom HTTP smart sub-transport is registered with libgit2. - // ArgoCDModule's static constructor handles this; RunClassConstructor is idempotent. - RuntimeHelpers.RunClassConstructor(typeof(ArgoCDModule).TypeHandle); + LibGit2SharpTransportRegistration.EnsureRegistered(); } readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari/ArgoCD/ArgoCDModule.cs b/source/Calamari/ArgoCD/ArgoCDModule.cs index ee55c1afa3..9ebab224e0 100644 --- a/source/Calamari/ArgoCD/ArgoCDModule.cs +++ b/source/Calamari/ArgoCD/ArgoCDModule.cs @@ -3,7 +3,6 @@ using Calamari.ArgoCD.Git; using Calamari.ArgoCD.Git.PullRequests; using Calamari.ArgoCD.Git.PullRequests.Vendors.GitLab; -using LibGit2Sharp; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -11,20 +10,6 @@ namespace Calamari.ArgoCD { public class ArgoCDModule : Module { - static ArgoCDModule() - { - // Note this cannot be set in the RepositoryFactory as it causes tests to fail, due to the following issue. - - // LibGit2Sharp custom sub-transports are registered by calling a static registration - // method on GlobalSettings. Additionally, if you try and register a multiple transports - // with the same scheme, it throws an exception. It's not ideal, but it's what we've got - // to work with. - // - // Using the type constructor to make sure that these methods are only called once. - GlobalSettings.RegisterSmartSubtransport("http"); - GlobalSettings.RegisterSmartSubtransport("https"); - } - protected override void Load(ContainerBuilder builder) { builder.RegisterType().AsSelf().InstancePerLifetimeScope(); diff --git a/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs b/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs new file mode 100644 index 0000000000..4f265b625f --- /dev/null +++ b/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs @@ -0,0 +1,25 @@ +using System; +using LibGit2Sharp; + +namespace Calamari.ArgoCD.Git; + +/// +/// Lazily registers custom smart sub-transports for libgit2sharp so that the native +/// library is only loaded when git operations are actually needed, rather than during +/// startup. +/// The only reason not to do it during startup is that we have a new dependency on +/// OpenSSL3 that older (now unsupported) OS versions may not fulfill. Instead of +/// breaking everyone if they are running older systems, we will only break them +/// if they use git functionality. +/// +static class LibGit2SharpTransportRegistration +{ + static readonly Lazy Registered = new(() => + { + GlobalSettings.RegisterSmartSubtransport("http"); + GlobalSettings.RegisterSmartSubtransport("https"); + return true; + }); + + public static void EnsureRegistered() => _ = Registered.Value; +} \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index 189a69ecaf..0ada687dd6 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -35,6 +35,8 @@ public RepositoryFactory(ILog log, ICalamariFileSystem fileSystem, string reposi this.gitVendorPullRequestClientResolver = gitVendorPullRequestClientResolver; this.clock = clock; + LibGit2SharpTransportRegistration.EnsureRegistered(); + // Calamari runs as a single-purpose process per deployment step and always receives // explicit credentials. Clear the search paths for all global config levels so libgit2 // cannot load ~/.gitconfig or /etc/gitconfig and pick up a credential helper (e.g. From eaff4ffac73f70536b14c59dafeba898913a2e33 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 24 Apr 2026 11:46:55 +1000 Subject: [PATCH 08/39] Fix test --- .../ArgoCD/Git/GitHttpSmartSubTransportTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs index f982bada7f..b01434c357 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs @@ -60,7 +60,7 @@ public void BasicAuthHeaderIsSentOnFirstRequest() var password = "testpassword"; var expectedAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); - var repoUrl = new Uri($"{server.Url}/fake-repo.git"); + var repoUrl = $"{server.Url}/fake-repo.git"; var connection = new GitConnection(username, password, repoUrl, GitBranchName.CreateFromFriendlyName("main")); var repositoryFactory = new RepositoryFactory( log, @@ -88,7 +88,7 @@ public void BasicAuthHeaderIsSentOnFirstRequest() [Test] public void NoAuthHeaderIsSentWhenCredentialsAreNotProvided() { - var repoUrl = new Uri($"{server.Url}/fake-repo.git"); + var repoUrl = $"{server.Url}/fake-repo.git"; var connection = new GitConnection(null, null, repoUrl, GitBranchName.CreateFromFriendlyName("main")); var repositoryFactory = new RepositoryFactory( log, From cc856e0e974a8811abbe525ea74d612ad63a1e33 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Mon, 27 Apr 2026 11:47:34 +1000 Subject: [PATCH 09/39] Rename and testing --- build/Build.LocalPackages.cs | 21 +- .../ArgoCD/ArgoCDCustomPropertiesDto.cs | 2 +- .../AuthenticatingRepositoryFactoryTests.cs | 190 ++++++++++-------- .../Git/GitHttpSmartSubTransportTests.cs | 4 +- .../GitPullRequestClientResolverTests.cs | 6 +- .../GitVendorApiAdapter_PullRequestTests.cs | 4 +- .../ArgoCD/Git/RepositoryFactoryTests.cs | 6 +- .../ArgoCD/Git/RepositoryWrapperTest.cs | 6 +- .../Git/AuthenticatingRepositoryFactory.cs | 46 +++-- ...GitConnection.cs => HttpsGitConnection.cs} | 10 +- .../GitVendorPullRequestClientResolver.cs | 6 +- .../IGitVendorPullRequestClientFactory.cs | 2 +- .../AzureDevOpsPullRequestClient.cs | 4 +- .../AzureDevOpsPullRequestClientFactory.cs | 2 +- .../BitBucket/BitBucketPullRequestClient.cs | 4 +- .../BitBucketPullRequestClientFactory.cs | 2 +- .../GitHub/GitHubPullRequestClientFactory.cs | 2 +- .../GitLab/GitLabPullRequestClientFactory.cs | 2 +- .../Calamari/ArgoCD/Git/RepositoryFactory.cs | 24 ++- .../Calamari/ArgoCD/Git/RepositoryWrapper.cs | 11 +- 20 files changed, 197 insertions(+), 157 deletions(-) rename source/Calamari/ArgoCD/Git/{GitConnection.cs => HttpsGitConnection.cs} (81%) diff --git a/build/Build.LocalPackages.cs b/build/Build.LocalPackages.cs index 05d48ee24b..dcf0d199af 100644 --- a/build/Build.LocalPackages.cs +++ b/build/Build.LocalPackages.cs @@ -16,6 +16,7 @@ public partial class Build d.Requires(() => IsLocalBuild) .DependsOn(PublishCalamariProjects) .DependsOn(PackCalamariConsolidatedNugetPackage) + .DependsOn(PackContractsProject) .Executes(() => { Directory.CreateDirectory(LocalPackagesDirectory); @@ -34,27 +35,39 @@ public partial class Build .Executes(() => { var serverProjectFile = KnownPaths.RootDirectory / ".." / "OctopusDeploy" / "source" / "Octopus.Server" / "Octopus.Server.csproj"; + var coreProjectFile = KnownPaths.RootDirectory / ".." / "OctopusDeploy" / "source" / "Octopus.Core" / "Octopus.Core.csproj"; var serverNugetConfigFile = KnownPaths.RootDirectory / ".." / "OctopusDeploy" / "NuGet.Config"; - var projectFileExists = File.Exists(serverProjectFile); + var serverProjectFileExists = File.Exists(serverProjectFile); + var coreProjectFileExists = File.Exists(serverProjectFile); var nugetFileExists = File.Exists(serverNugetConfigFile); - if (projectFileExists && nugetFileExists) + if (serverProjectFileExists && coreProjectFileExists && nugetFileExists) { Log.Information("Setting Calamari version in Octopus Server " + "project {ServerProjectFile} to {NugetVersion}", serverProjectFile, NugetVersion.Value); SetOctopusServerCalamariVersion(serverProjectFile); + SetOctopusServerCalamariVersion(coreProjectFile); AddLocalPackagesSource(serverNugetConfigFile); } else { - if (!projectFileExists) + if (!serverProjectFileExists) { Log.Warning("Could not set Calamari version in Octopus Server project " + "{ServerProjectFile} to {NugetVersion} as could not find " + "project file", serverProjectFile, NugetVersion.Value); } - else if (!nugetFileExists) + + if (!coreProjectFileExists) + { + Log.Warning("Could not set Calamari version in Octopus Server project " + + "{ServerProjectFile} to {NugetVersion} as could not find " + + "project file", + serverProjectFile, NugetVersion.Value); + } + + if (!nugetFileExists) { Log.Warning("Could not set Calamari version in Octopus Server project " + "{ServerProjectFile} to {NugetVersion} as could not find " diff --git a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs index 30ac0909d1..6d8033fb57 100644 --- a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs +++ b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs @@ -22,4 +22,4 @@ public record ArgoCDApplicationDto( // GitUsernamePasswordCredentialDto public record GitCredentialDto(string Url, string Username, string Password); -public record GitCredentialSshKeyDto(string Url, string Username, string PrivateKey, string PublicKey, string Passphrase); \ No newline at end of file +public record GitCredentialSshKeyDto(string Url, string Username, string PrivateKey, string PublicKey, string? Passphrase); \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs index 8c47118ffe..4c1027e9ff 100644 --- a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -8,6 +8,7 @@ using Calamari.Testing.Helpers; using Calamari.Tests.Fixtures.Integration.FileSystem; using FluentAssertions; +using NSubstitute; using NUnit.Framework; using Octopus.Calamari.Contracts.ArgoCD; @@ -45,108 +46,123 @@ public void Cleanup() RepositoryHelpers.DeleteRepositoryDirectory(fileSystem, tempDirectory); } -[TestFixture] -public class SshUrlTests : AuthenticatingRepositoryFactoryTestBase -{ - [Test] - public void SshCredentialIsSelectedWhenUrlMatchesSshCredential() + [TestFixture] + public class HttpsUrlTests : AuthenticatingRepositoryFactoryTestBase { - var factory = new AuthenticatingRepositoryFactory( - new Dictionary(), - new Dictionary - { - // Use the local path as the SSH credential URL so the clone actually works - [OriginPath] = new GitCredentialSshKeyDto(OriginPath, "git", "private-key", "public-key", "passphrase") - }, - repositoryFactory, - log); - - using var wrapper = factory.CloneRepository(OriginPath, branchName.ToFriendlyName()); - wrapper.Should().NotBeNull(); + [Test] + public void HttpsCredentialIsSelectedWhenUrlMatchesHttpsCredential() + { + var httpsUrl = RepositoryHelpers.ToFileUri(OriginPath); + var factory = new AuthenticatingRepositoryFactory( + new Dictionary + { + [httpsUrl] = new GitCredentialDto(httpsUrl, "", "") + }, + new Dictionary(), + repositoryFactory, + log); + + using var wrapper = factory.CloneRepository(httpsUrl, branchName.ToFriendlyName()); + wrapper.Should().NotBeNull(); + } + + [Test] + public void AnonymousCloneWhenNoCredentialsMatch() + { + var originUrl = RepositoryHelpers.ToFileUri(OriginPath); + var factory = new AuthenticatingRepositoryFactory( + new Dictionary(), + new Dictionary(), + repositoryFactory, + log); + + using var wrapper = factory.CloneRepository(originUrl, branchName.ToFriendlyName()); + wrapper.Should().NotBeNull(); + log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); + } } - [Test] - public void SshCredentialTakesPriorityOverHttpsWhenBothMatch() + [TestFixture] + public class SshUrlTests : AuthenticatingRepositoryFactoryTestBase { - var url = OriginPath; - var factory = new AuthenticatingRepositoryFactory( - new Dictionary - { - [url] = new GitCredentialDto(url, "https-user", "https-pass") - }, - new Dictionary - { - [url] = new GitCredentialSshKeyDto(url, "ssh-user", "private-key", "public-key", "passphrase") - }, - repositoryFactory, - log); - - using var wrapper = factory.CloneRepository(url, branchName.ToFriendlyName()); - wrapper.Should().NotBeNull(); + [Test] + public void SshCredentialIsSelectedWhenUrlMatchesSshCredential() + { + var factory = new AuthenticatingRepositoryFactory( + new Dictionary(), + new Dictionary + { + // Use the local path as the SSH credential URL so the clone actually works + [OriginPath] = new GitCredentialSshKeyDto(OriginPath, "git", "private-key", "public-key", "passphrase") + }, + repositoryFactory, + log); + + using var wrapper = factory.CloneRepository(OriginPath, branchName.ToFriendlyName()); + wrapper.Should().NotBeNull(); + } + + [Test] + public void HttpsCredentialTakesPriorityOverSshWhenBothMatchAnSshUrl() + { + AssertHttpsCredentialTakesPriorityOverSsh("ssh://git@github.com/org/repo.git"); + } } -} -[TestFixture] -public class HttpsUrlTests : AuthenticatingRepositoryFactoryTestBase -{ - [Test] - public void HttpsCredentialIsSelectedWhenUrlMatchesHttpsCredential() + [TestFixture] + public class ScpStyleUrlTests : AuthenticatingRepositoryFactoryTestBase { - var httpsUrl = RepositoryHelpers.ToFileUri(OriginPath); - var factory = new AuthenticatingRepositoryFactory( - new Dictionary - { - [httpsUrl] = new GitCredentialDto(httpsUrl, "", "") - }, - new Dictionary(), - repositoryFactory, - log); - - using var wrapper = factory.CloneRepository(httpsUrl, branchName.ToFriendlyName()); - wrapper.Should().NotBeNull(); + [Test] + public void ScpStyleUrlDoesNotMatchHttpsCredential() + { + // An SCP-style URL should not accidentally match an HTTPS credential for the same host + var scpUrl = "git@github.com:org/repo.git"; + var httpsUrl = "https://github.com/org/repo.git"; + + var factory = new AuthenticatingRepositoryFactory( + new Dictionary + { + [httpsUrl] = new GitCredentialDto(httpsUrl, "user", "pass") + }, + new Dictionary(), + repositoryFactory, + log); + + // This will fail to clone (no real repo at this URL) but we can verify it + // falls through to anonymous because the SCP URL doesn't match the HTTPS URL + var act = () => factory.CloneRepository(scpUrl, "main"); + act.Should().Throw(); // clone failure expected + log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); + } + + [Test] + public void HttpsCredentialTakesPriorityOverSshWhenBothMatchAnScpUrl() + { + AssertHttpsCredentialTakesPriorityOverSsh("git@github.com:org/repo.git"); + } } - [Test] - public void AnonymousCloneWhenNoCredentialsMatch() + protected void AssertHttpsCredentialTakesPriorityOverSsh(string url) { - var originUrl = RepositoryHelpers.ToFileUri(OriginPath); - var factory = new AuthenticatingRepositoryFactory( - new Dictionary(), - new Dictionary(), - repositoryFactory, - log); - - using var wrapper = factory.CloneRepository(originUrl, branchName.ToFriendlyName()); - wrapper.Should().NotBeNull(); - log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); - } -} - -[TestFixture] -public class ScpStyleUrlTests : AuthenticatingRepositoryFactoryTestBase -{ - [Test] - public void ScpStyleUrlDoesNotMatchHttpsCredential() - { - // An SCP-style URL should not accidentally match an HTTPS credential for the same host - var scpUrl = "git@github.com:org/repo.git"; - var httpsUrl = "https://github.com/org/repo.git"; + var mockRepoFactory = Substitute.For(); var factory = new AuthenticatingRepositoryFactory( new Dictionary { - [httpsUrl] = new GitCredentialDto(httpsUrl, "user", "pass") + [url] = new GitCredentialDto(url, "https-user", "https-pass") }, - new Dictionary(), - repositoryFactory, + new Dictionary + { + [url] = new GitCredentialSshKeyDto(url, "ssh-user", "private-key", "public-key", "passphrase") + }, + mockRepoFactory, log); - // This will fail to clone (no real repo at this URL) but we can verify it - // falls through to anonymous because the SCP URL doesn't match the HTTPS URL - var act = () => factory.CloneRepository(scpUrl, "main"); - act.Should().Throw(); // clone failure expected - log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); - } -} + factory.CloneRepository(url, "main"); -} + mockRepoFactory.Received() + .CloneRepository( + Arg.Any(), + Arg.Is(c => c is HttpsGitConnection)); + } +} \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs index 9da7736a92..ccf8c7a1c7 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs @@ -55,7 +55,7 @@ public void BasicAuthHeaderIsSentOnFirstRequest() var expectedAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); var repoUrl = $"{server.Url}/fake-repo.git"; - var connection = new GitConnection(username, password, repoUrl, GitBranchName.CreateFromFriendlyName("main")); + var connection = new HttpsGitConnection(username, password, repoUrl, GitBranchName.CreateFromFriendlyName("main")); var repositoryFactory = new RepositoryFactory( log, fileSystem, @@ -83,7 +83,7 @@ public void BasicAuthHeaderIsSentOnFirstRequest() public void NoAuthHeaderIsSentWhenCredentialsAreNotProvided() { var repoUrl = $"{server.Url}/fake-repo.git"; - var connection = new GitConnection(null, null, repoUrl, GitBranchName.CreateFromFriendlyName("main")); + var connection = new HttpsGitConnection(null, null, repoUrl, GitBranchName.CreateFromFriendlyName("main")); var repositoryFactory = new RepositoryFactory( log, fileSystem, diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs index 8de52bfa91..caf35a7107 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs @@ -20,14 +20,14 @@ namespace Calamari.Tests.ArgoCD.Git.PullRequests; public class GitPullRequestClientResolverTests { ILog log; - IRepositoryConnection connection; + HttpsGitConnection connection; MemoryCache cache; [SetUp] public void SetUp() { log = Substitute.For(); - connection = Substitute.For(); + connection = Substitute.For(); connection.Username.Returns("test-user"); connection.Password.Returns("test-token"); cache = new MemoryCache(new MemoryCacheOptions()); @@ -131,6 +131,6 @@ class NeverMatchesFactory : IGitVendorPullRequestClientFactory public string Name => "NeverMatches"; public bool CanHandleAsCloudHosted(Uri repositoryUri) => false; public Task CanHandleAsSelfHosted(Uri repositoryUri, CancellationToken cancellationToken) => Task.FromResult(false); - public Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) => throw new NotImplementedException(); } } diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs index 213155930b..0a1663029f 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs @@ -97,7 +97,7 @@ await TestPullRequest(repositoryUrl, }); } - async Task TestPullRequest(string repositoryUrl, string defaultBranch, string cloneUsername, string clonePassword, Func createVendorApiAdapter) + async Task TestPullRequest(string repositoryUrl, string defaultBranch, string cloneUsername, string clonePassword, Func createVendorApiAdapter) { using var temporaryFolder = TemporaryDirectory.Create(); @@ -118,7 +118,7 @@ async Task TestPullRequest(string repositoryUrl, string defaultBranch, string cl repository.Branches.Update(newBranch, branch => branch.Remote = remote.Name, branch => branch.UpstreamBranch = newBranch.CanonicalName); repository.Network.Push(newBranch, new PushOptions() { CredentialsProvider = credentialsHandler }); - var conn = Substitute.For(); + var conn = Substitute.For(); conn.Url.Returns(repositoryUrl); conn.Username.Returns(cloneUsername); conn.Password.Returns(clonePassword); diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index 5352891d82..8d5a752fbd 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -49,7 +49,7 @@ public void Cleanup() [Test] public void ThrowsExceptionIfUrlDoesNotExist() { - var connection = new GitConnection("username", + var connection = new HttpsGitConnection("username", "password", "file://doesNotExist", branchName); @@ -66,7 +66,7 @@ public void CanCloneAnExistingRepositoryWithExplicitBranchNameAndAssociatedFiles var originalContent = "This is the file content"; CreateCommitOnOrigin(branchName, filename, originalContent); - var connection = new GitConnection(null, null, OriginPath, branchName); + var connection = new HttpsGitConnection(null, null, OriginPath, branchName); var clonedRepository = repositoryFactory.CloneRepository("CanCloneAnExistingRepository", connection); clonedRepository.Should().NotBeNull(); @@ -83,7 +83,7 @@ public void CanCloneAnExistingRepositoryAtHEADAndAssociatedFiles() var originalContent = "This is the file content"; CreateCommitOnOrigin(RepositoryHelpers.MainBranchName, filename, originalContent); - var connection = new GitConnection(null, null, OriginPath, new GitHead()); + var connection = new HttpsGitConnection(null, null, OriginPath, new GitHead()); var clonedRepository = repositoryFactory.CloneRepository("CanCloneAnExistingRepository", connection); clonedRepository.Should().NotBeNull(); diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs index d33da02f08..3f1c2ad4fa 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs @@ -51,10 +51,10 @@ public void Init() Arg.Any(), Arg.Any()) .Returns(new PullRequest("title", 1, "url")); - gitVendorAgnosticPullRequestClientFactory.TryResolve(Arg.Any(), Arg.Any(), Arg.Any()).Returns(gitVendorPullRequestClient); + gitVendorAgnosticPullRequestClientFactory.TryResolve(Arg.Any(), Arg.Any(), Arg.Any()).Returns(gitVendorPullRequestClient); var repositoryFactory = new RepositoryFactory(log, fileSystem, tempDirectory, gitVendorAgnosticPullRequestClientFactory, new SystemClock()); - gitConnection = new GitConnection(null, null, OriginPath, branchName); + gitConnection = new HttpsGitConnection(null, null, OriginPath, branchName); repository = repositoryFactory.CloneRepository(repositoryPath, gitConnection); } @@ -177,7 +177,7 @@ public void CloningAReferenceOtherThanABranchFails() bareOrigin.AddFilesToBranch(branchName, ("file.yaml", "")); bareOrigin.ApplyTag("1.0.0", bareOrigin.Head.Tip.Sha); - gitConnection = new GitConnection(null, null, OriginPath, GitReference.CreateFromString("1.0.0")); + gitConnection = new HttpsGitConnection(null, null, OriginPath, GitReference.CreateFromString("1.0.0")); var repositoryFactory = new RepositoryFactory(log, fileSystem, tempDirectory, gitVendorAgnosticPullRequestClientFactory, new SystemClock()); var act = () => repositoryFactory.CloneRepository($"{repositoryPath}/sut", gitConnection); diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index 8bbf074a07..10ddbea4c9 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -6,45 +6,47 @@ namespace Calamari.ArgoCD.Git; public class AuthenticatingRepositoryFactory { - readonly Dictionary gitCredentials; - readonly Dictionary sshCredentials; - readonly RepositoryFactory repositoryFactory; + readonly Dictionary httpsGitCredentials; + readonly Dictionary sshGitCredentials; + readonly IRepositoryFactory repositoryFactory; readonly ILog log; public AuthenticatingRepositoryFactory( - Dictionary gitCredentials, - Dictionary sshCredentials, - RepositoryFactory repositoryFactory, + Dictionary httpsGitCredentials, + Dictionary sshGitCredentials, + IRepositoryFactory repositoryFactory, ILog log) { - this.gitCredentials = gitCredentials; - this.sshCredentials = sshCredentials; + this.httpsGitCredentials = httpsGitCredentials; + this.sshGitCredentials = sshGitCredentials; this.repositoryFactory = repositoryFactory; this.log = log; } public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevision) { - var sshCredential = sshCredentials.GetValueOrDefault(requestedUrl); - if (sshCredential is not null) + var httpsGitCredential = httpsGitCredentials.GetValueOrDefault(requestedUrl); + if (httpsGitCredential is not null) + { + var gitConnection = new HttpsGitConnection(httpsGitCredential.Username, httpsGitCredential.Password, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); + return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection); + } + + var sshGitCredential = sshGitCredentials.GetValueOrDefault(requestedUrl); + if (sshGitCredential is not null) { var sshConnection = new SshGitConnection( - sshCredential.Username, + sshGitCredential.Username, requestedUrl, GitReference.CreateFromString(targetRevision), - sshCredential.PrivateKey, - sshCredential.PublicKey, - sshCredential.Passphrase); + sshGitCredential.PrivateKey, + sshGitCredential.PublicKey, + sshGitCredential.Passphrase); return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), sshConnection); } - var gitCredential = gitCredentials.GetValueOrDefault(requestedUrl); - if (gitCredential == null) - { - log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); - } - - var gitConnection = new GitConnection(gitCredential?.Username, gitCredential?.Password, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); - return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection); + log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); + var anonGitConnection = new HttpsGitConnection(null, null, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); + return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), anonGitConnection); } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/GitConnection.cs b/source/Calamari/ArgoCD/Git/HttpsGitConnection.cs similarity index 81% rename from source/Calamari/ArgoCD/Git/GitConnection.cs rename to source/Calamari/ArgoCD/Git/HttpsGitConnection.cs index 656ddab42c..bfa6b7de78 100644 --- a/source/Calamari/ArgoCD/Git/GitConnection.cs +++ b/source/Calamari/ArgoCD/Git/HttpsGitConnection.cs @@ -4,8 +4,6 @@ namespace Calamari.ArgoCD.Git { public interface IRepositoryConnection { - public string? Username { get; } - public string? Password { get; } public string Url { get; } } @@ -14,9 +12,9 @@ public interface IGitConnection : IRepositoryConnection public GitReference GitReference { get; } } - public class GitConnection : IGitConnection + public class HttpsGitConnection : IGitConnection { - public GitConnection(string? username, string? password, string url, GitReference gitReference) + public HttpsGitConnection(string? username, string? password, string url, GitReference gitReference) { Username = username; Password = password; @@ -32,7 +30,7 @@ public GitConnection(string? username, string? password, string url, GitReferenc public class SshGitConnection : IGitConnection { - public SshGitConnection(string? username, string url, GitReference gitReference, string privateKey, string publicKey, string passphrase) + public SshGitConnection(string? username, string url, GitReference gitReference, string privateKey, string publicKey, string? passphrase) { Username = username; Url = url; @@ -48,6 +46,6 @@ public SshGitConnection(string? username, string url, GitReference gitReference, public GitReference GitReference { get; } public string PrivateKey { get; } public string PublicKey { get; } - public string Passphrase { get; } + public string? Passphrase { get; } } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs index 9d707208b5..788a41e56e 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs @@ -9,7 +9,7 @@ namespace Calamari.ArgoCD.Git.PullRequests { public interface IGitVendorPullRequestClientResolver { - Task TryResolve(IRepositoryConnection repositoryConnection, ILog log, + Task TryResolve(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken); } @@ -22,8 +22,8 @@ public GitVendorPullRequestClientResolver(IEnumerable TryResolve(IRepositoryConnection repositoryConnection, ILog log, - CancellationToken cancellationToken) + public async Task TryResolve(HttpsGitConnection repositoryConnection, ILog log, + CancellationToken cancellationToken) { if (!Uri.TryCreate(repositoryConnection.Url, UriKind.Absolute, out var repositoryUri)) { diff --git a/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs index 2a61c4e559..7e41a88f26 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs @@ -26,6 +26,6 @@ async Task CanHandleAsSelfHosted(Uri repositoryUri, CancellationToken canc return false; } - Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken); + Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken); } } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs index 7c17d4ebcc..127031e4dc 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs @@ -13,9 +13,9 @@ public class AzureDevOpsPullRequestClient : IGitVendorPullRequestClient { const string CloudHost = "dev.azure.com"; - readonly IRepositoryConnection repositoryConnection; + readonly HttpsGitConnection repositoryConnection; - public AzureDevOpsPullRequestClient(IRepositoryConnection repositoryConnection) + public AzureDevOpsPullRequestClient(HttpsGitConnection repositoryConnection) { this.repositoryConnection = repositoryConnection; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs index f6aa2594e2..6862b078b0 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs @@ -11,7 +11,7 @@ public class AzureDevOpsPullRequestClientFactory : IGitVendorPullRequestClientFa public bool CanHandleAsCloudHosted(Uri repositoryUri) => AzureDevOpsRepositoryUriParser.IsAzureDevOpsRepository(repositoryUri); - public async Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken) + public async Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { await Task.CompletedTask; return new AzureDevOpsPullRequestClient(repositoryConnection); diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs index 247f89342d..e19dfdd2ba 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs @@ -11,12 +11,12 @@ namespace Calamari.ArgoCD.Git.PullRequests.Vendors.BitBucket { public class BitBucketPullRequestClient : IGitVendorPullRequestClient { - readonly IRepositoryConnection repositoryConnection; + readonly HttpsGitConnection repositoryConnection; readonly Uri baseUrl; readonly string workspace; readonly string repositorySlug; - public BitBucketPullRequestClient(IRepositoryConnection repositoryConnection, Uri baseUrl) + public BitBucketPullRequestClient(HttpsGitConnection repositoryConnection, Uri baseUrl) { this.repositoryConnection = repositoryConnection; this.baseUrl = baseUrl; diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs index d4e4670ad3..6eef02dbf0 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs @@ -15,7 +15,7 @@ public bool CanHandleAsCloudHosted(Uri repositoryUri) return repositoryUri.Host.Equals(baseUrl.Host, StringComparison.OrdinalIgnoreCase); } - public async Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken) + public async Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { await Task.CompletedTask; return new BitBucketPullRequestClient(repositoryConnection, baseUrl); diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs index bb476b9072..dc8f1cb554 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs @@ -16,7 +16,7 @@ public class GitHubPullRequestClientFactory: IGitVendorPullRequestClientFactory public bool CanHandleAsCloudHosted(Uri repositoryUri) => GitHubRepositoryUriParser.IsGitHub(repositoryUri); - public async Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken) + public async Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { await Task.CompletedTask; diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs index 6ca2b9bda2..3361a12702 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs @@ -21,7 +21,7 @@ public async Task CanHandleAsSelfHosted(Uri repositoryUri, CancellationTok return await selfHostedGitLabInspector.IsSelfHostedGitLabInstance(repositoryUri, cancellationToken); } - public async Task Create(IRepositoryConnection repositoryConnection, ILog taskLog, + public async Task Create(HttpsGitConnection repositoryConnection, ILog taskLog, CancellationToken cancellationToken) { await Task.CompletedTask; diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index 7b8a235270..d58d2a03ed 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -78,12 +78,12 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che // TODO(eddy): Implement proper host key verification options.FetchOptions.CertificateCheck = (cert, valid, host) => true; } - else if (gitConnection.Username != null && gitConnection.Password != null) + else if (gitConnection is HttpsGitConnection { Username: not null, Password: not null } https) { - options.FetchOptions.CredentialsProvider = (url, usernameFromUrl, types) => new UsernamePasswordCredentials + options.FetchOptions.CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials { - Username = gitConnection.Username!, - Password = gitConnection.Password! + Username = https.Username, + Password = https.Password }; } @@ -112,16 +112,16 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che //this is required to handle the issue around "HEAD" var branchToCheckout = repo.GetBranchName(gitConnection.GitReference); var remoteBranch = repo.Branches.First(f => f.IsRemote && f.UpstreamBranchCanonicalName == branchToCheckout.Value); - + log.VerboseFormat("Checking out '{0}' @ {1}", branchToCheckout, remoteBranch.Tip.Sha.Substring(0, 10)); - + //A local branch is required such that libgit2sharp can create "tracking" data // libgit2sharp does not support pushing from a detached head if (repo.Branches[branchToCheckout.Value] == null) { repo.CreateBranch(branchToCheckout.Value, remoteBranch.Tip); } - + LibGit2Sharp.Commands.Checkout(repo, branchToCheckout.ToFriendlyName()); } catch (LibGit2SharpException e) @@ -130,7 +130,15 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che } //TODO(tmm): Make this function (and all callers async). - var gitVendorApiAdapter = gitVendorPullRequestClientResolver.TryResolve(gitConnection, log, CancellationToken.None).Result; + var gitVendorApiAdapter = gitConnection is HttpsGitConnection httpsGitConnection + ? gitVendorPullRequestClientResolver.TryResolve(httpsGitConnection, log, CancellationToken.None).Result + : null; + + if (gitConnection is SshGitConnection) + { + log.Verbose("Git is using SSH authentication, Git vendor functionality will not be available"); + } + return new RepositoryWrapper(repo, fileSystem, checkoutPath, diff --git a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs index 498a96729c..6212681b32 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs @@ -33,14 +33,17 @@ public class RepositoryWrapper( readonly IGitVendorPullRequestClient? vendorApiAdapter = vendorApiAdapter; readonly IClock clock = clock; // ReSharper restore ReplaceWithPrimaryConstructorParameter - + readonly Identity repositoryIdentity = new("Octopus", "octopus@octopus.com"); public string WorkingDirectory => repository.Info.WorkingDirectory; - Credentials RepositoryCredentials => connection is SshGitConnection ssh - ? new SshUserKeyMemoryCredentials { Username = ssh.Username, PublicKey = ssh.PublicKey, PrivateKey = ssh.PrivateKey, Passphrase = ssh.Passphrase } - : new UsernamePasswordCredentials { Username = connection.Username, Password = connection.Password }; + Credentials RepositoryCredentials => connection switch + { + SshGitConnection ssh => new SshUserKeyMemoryCredentials { Username = ssh.Username, PublicKey = ssh.PublicKey, PrivateKey = ssh.PrivateKey, Passphrase = ssh.Passphrase }, + HttpsGitConnection https => new UsernamePasswordCredentials { Username = https.Username, Password = https.Password }, + _ => null + }; // returns true if changes were made to the repository public bool CommitChanges(string summary, string description) From d92b5edcd873eeac1b07f0aa69b71138a1dd3a88 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Tue, 28 Apr 2026 10:02:24 +1000 Subject: [PATCH 10/39] Add test category for requiring OpenSSL 3 --- build/Build.CalamariTesting.cs | 26 +++++++++++++++++++ .../Calamari.Testing/Helpers/TestCategory.cs | 4 ++- ...goCDAppImagesInstallConventionHelmTests.cs | 1 + ...ateArgoCDAppImagesInstallConventionTest.cs | 1 + ...licationManifestsInstallConventionTests.cs | 1 + .../AuthenticatingRepositoryFactoryTests.cs | 1 + .../Git/GitHttpSmartSubTransportTests.cs | 1 + .../GitVendorApiAdapter_PullRequestTests.cs | 2 ++ .../ArgoCD/Git/RepositoryFactoryTests.cs | 1 + .../ArgoCD/Git/RepositoryWrapperTest.cs | 1 + 10 files changed, 38 insertions(+), 1 deletion(-) diff --git a/build/Build.CalamariTesting.cs b/build/Build.CalamariTesting.cs index 1549543b8f..4db99a587b 100644 --- a/build/Build.CalamariTesting.cs +++ b/build/Build.CalamariTesting.cs @@ -31,6 +31,19 @@ partial class Build .Execute(); }); + [PublicAPI] + Target LinuxSpecificTestingWithoutOpenSsl3 => + target => target + .Executes(async () => + { + var dotnetPath = await LocateOrInstallDotNetSdk(); + + CreateTestRun("Binaries/Calamari.Tests.dll") + .WithDotNetPath(dotnetPath) + .WithFilter("TestCategory != Windows & TestCategory != PlatformAgnostic & TestCategory != RunOnceOnWindowsAndLinux & TestCategory != RequiresOpenSsl3") + .Execute(); + }); + [PublicAPI] Target OncePerWindowsOrLinuxTesting => target => target @@ -44,6 +57,19 @@ partial class Build .Execute(); }); + [PublicAPI] + Target OncePerWindowsOrLinuxTestingWithoutOpenSsl3 => + target => target + .Executes(async () => + { + var dotnetPath = await LocateOrInstallDotNetSdk(); + + CreateTestRun("Binaries/Calamari.Tests.dll") + .WithDotNetPath(dotnetPath) + .WithFilter("((TestCategory != Windows & TestCategory != PlatformAgnostic) | TestCategory = RunOnceOnWindowsAndLinux) & TestCategory != RequiresOpenSsl3") + .Execute(); + }); + [PublicAPI] Target OncePerWindowsTesting => target => target diff --git a/source/Calamari.Testing/Helpers/TestCategory.cs b/source/Calamari.Testing/Helpers/TestCategory.cs index a4a67dbcb9..c04db10ae7 100644 --- a/source/Calamari.Testing/Helpers/TestCategory.cs +++ b/source/Calamari.Testing/Helpers/TestCategory.cs @@ -21,7 +21,9 @@ public static class CompatibleOS } public const string PlatformAgnostic = "PlatformAgnostic"; - + public const string RunOnceOnWindowsAndLinux = "RunOnceOnWindowsAndLinux"; + + public const string RequiresOpenSsl3 = "RequiresOpenSsl3"; } } \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs index 2feb643c82..065fa59967 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs @@ -29,6 +29,7 @@ namespace Calamari.Tests.ArgoCD.Commands.Conventions { + [Category(TestCategory.RequiresOpenSsl3)] public class UpdateArgoCDAppImagesInstallConventionHelmTests { const string ProjectSlug = "TheProject"; diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs index 91e003a604..6af0ee334c 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs @@ -29,6 +29,7 @@ namespace Calamari.Tests.ArgoCD.Commands.Conventions { [TestFixture] + [Category(TestCategory.RequiresOpenSsl3)] public class UpdateArgoCDAppImagesInstallConventionTests { const string ProjectSlug = "TheProject"; diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs index 2eba98ffe7..30542aa2d7 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs @@ -28,6 +28,7 @@ namespace Calamari.Tests.ArgoCD.Commands.Conventions { [TestFixture] + [Category(TestCategory.RequiresOpenSsl3)] public class UpdateArgoCDApplicationManifestsInstallConventionTests { const string ProjectSlug = "TheProject"; diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs index 4c1027e9ff..c226ec76a2 100644 --- a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -14,6 +14,7 @@ namespace Calamari.Tests.ArgoCD.Git; +[Category(TestCategory.RequiresOpenSsl3)] public abstract class AuthenticatingRepositoryFactoryTestBase { protected readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs index ccf8c7a1c7..f2a8f34efe 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs @@ -17,6 +17,7 @@ namespace Calamari.Tests.ArgoCD.Git; [TestFixture] +[Category(TestCategory.RequiresOpenSsl3)] public class GitHttpSmartSubTransportTests { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs index 0a1663029f..c42da28ca6 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs @@ -9,6 +9,7 @@ using Calamari.ArgoCD.Git.PullRequests.Vendors.GitHub; using Calamari.ArgoCD.Git.PullRequests.Vendors.GitLab; using Calamari.Common.Plumbing.FileSystem; +using Calamari.Testing.Helpers; using FluentAssertions; using LibGit2Sharp; using LibGit2Sharp.Handlers; @@ -22,6 +23,7 @@ namespace Calamari.Tests.ArgoCD.Git.GitVendorApiAdapters { [TestFixture] + [Category(TestCategory.RequiresOpenSsl3)] public class GitHubPullRequestClientTests { [Test] diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index 8d5a752fbd..783c4db9a9 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -16,6 +16,7 @@ namespace Calamari.Tests.ArgoCD.Git { [TestFixture] + [Category(TestCategory.RequiresOpenSsl3)] public class RepositoryFactoryTests { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs index 3f1c2ad4fa..acc3fed0ac 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs @@ -19,6 +19,7 @@ namespace Calamari.Tests.ArgoCD.Git { [TestFixture] + [Category(TestCategory.RequiresOpenSsl3)] public class RepositoryWrapperTest { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); From 2cd2cced5872d45462c92906f0b8e8d7c298cf4d Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Tue, 28 Apr 2026 10:12:14 +1000 Subject: [PATCH 11/39] Better error message --- .../Git/LibGit2SharpTransportRegistration.cs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs b/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs index 4f265b625f..d24b16af9b 100644 --- a/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs +++ b/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs @@ -1,4 +1,5 @@ using System; +using Calamari.Common.Commands; using LibGit2Sharp; namespace Calamari.ArgoCD.Git; @@ -14,12 +15,32 @@ namespace Calamari.ArgoCD.Git; /// static class LibGit2SharpTransportRegistration { - static readonly Lazy Registered = new(() => + static readonly Lazy Registered = new(Register); + + public static void EnsureRegistered() => _ = Registered.Value; + + static bool Register() + { + try { GlobalSettings.RegisterSmartSubtransport("http"); GlobalSettings.RegisterSmartSubtransport("https"); - return true; - }); + } + catch (TypeInitializationException ex) when (ex.InnerException is DllNotFoundException dllEx) + { + var message = $""" + Failed to load the native libgit2 library required for Git operations. - public static void EnsureRegistered() => _ = Registered.Value; + On Linux, libgit2 requires OpenSSL 3 (libcrypto.so.3) to be installed on the worker. + Please install it according to your distributions guidance or update to a supported OS. + + Original exception: + {dllEx.Message} + """; + + throw new CommandException(message, ex); + } + + return true; + } } \ No newline at end of file From 7c8b7afffefeabec2c17e6fe87f9d9aac94763e6 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Tue, 28 Apr 2026 10:59:44 +1000 Subject: [PATCH 12/39] Ensure registration in tests --- .../Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs | 4 ++-- source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs index c42da28ca6..b372fab3ca 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs @@ -101,10 +101,10 @@ await TestPullRequest(repositoryUrl, async Task TestPullRequest(string repositoryUrl, string defaultBranch, string cloneUsername, string clonePassword, Func createVendorApiAdapter) { - using var temporaryFolder = TemporaryDirectory.Create(); - + CredentialsHandler credentialsHandler = (url, usernameFromUrl, types) => new UsernamePasswordCredentials { Username = cloneUsername, Password = clonePassword}; + LibGit2SharpTransportRegistration.EnsureRegistered(); var repositoryPath = Repository.Clone(repositoryUrl, temporaryFolder.DirectoryPath, new CloneOptions() { FetchOptions = diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs index 7c086fa233..8c8390b7a3 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs @@ -10,6 +10,8 @@ public static class RepositoryHelpers { public static Repository CreateBareRepository(string repositoryPath) { + LibGit2SharpTransportRegistration.EnsureRegistered(); + Directory.CreateDirectory(repositoryPath); Repository.Init(repositoryPath, isBare: true); return new Repository(repositoryPath); @@ -44,6 +46,8 @@ public static void CreateBranchIn(GitBranchName branchName, string originPath) public static string CloneOrigin(string tempDirectory, string originPath, GitBranchName branchName) { + LibGit2SharpTransportRegistration.EnsureRegistered(); + var subPath = Guid.NewGuid().ToString(); var resultPath = Path.Combine(tempDirectory, subPath); Repository.Clone(originPath, resultPath); From 778cbea4de72555bc4533f4407305dbb06dd16b0 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Tue, 28 Apr 2026 13:09:35 +1000 Subject: [PATCH 13/39] Needed an interface --- .../GitPullRequestClientResolverTests.cs | 6 +++--- .../Calamari/ArgoCD/Git/HttpsGitConnection.cs | 20 +++++++++++++++---- .../GitVendorPullRequestClientResolver.cs | 4 ++-- .../IGitVendorPullRequestClientFactory.cs | 2 +- .../AzureDevOpsPullRequestClient.cs | 4 ++-- .../AzureDevOpsPullRequestClientFactory.cs | 2 +- .../BitBucket/BitBucketPullRequestClient.cs | 4 ++-- .../BitBucketPullRequestClientFactory.cs | 2 +- .../GitHub/GitHubPullRequestClientFactory.cs | 2 +- .../GitLab/GitLabPullRequestClientFactory.cs | 2 +- 10 files changed, 30 insertions(+), 18 deletions(-) diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs index caf35a7107..73ca89625f 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs @@ -20,14 +20,14 @@ namespace Calamari.Tests.ArgoCD.Git.PullRequests; public class GitPullRequestClientResolverTests { ILog log; - HttpsGitConnection connection; + IHttpsGitConnection connection; MemoryCache cache; [SetUp] public void SetUp() { log = Substitute.For(); - connection = Substitute.For(); + connection = Substitute.For(); connection.Username.Returns("test-user"); connection.Password.Returns("test-token"); cache = new MemoryCache(new MemoryCacheOptions()); @@ -131,6 +131,6 @@ class NeverMatchesFactory : IGitVendorPullRequestClientFactory public string Name => "NeverMatches"; public bool CanHandleAsCloudHosted(Uri repositoryUri) => false; public Task CanHandleAsSelfHosted(Uri repositoryUri, CancellationToken cancellationToken) => Task.FromResult(false); - public Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) => throw new NotImplementedException(); } } diff --git a/source/Calamari/ArgoCD/Git/HttpsGitConnection.cs b/source/Calamari/ArgoCD/Git/HttpsGitConnection.cs index bfa6b7de78..c9bfa89679 100644 --- a/source/Calamari/ArgoCD/Git/HttpsGitConnection.cs +++ b/source/Calamari/ArgoCD/Git/HttpsGitConnection.cs @@ -4,15 +4,21 @@ namespace Calamari.ArgoCD.Git { public interface IRepositoryConnection { - public string Url { get; } + public string Url { get; } } public interface IGitConnection : IRepositoryConnection { - public GitReference GitReference { get; } + public GitReference GitReference { get; } + } + + public interface IHttpsGitConnection : IGitConnection + { + string? Username { get; } + string? Password { get; } } - public class HttpsGitConnection : IGitConnection + public class HttpsGitConnection : IHttpsGitConnection { public HttpsGitConnection(string? username, string? password, string url, GitReference gitReference) { @@ -30,7 +36,13 @@ public HttpsGitConnection(string? username, string? password, string url, GitRef public class SshGitConnection : IGitConnection { - public SshGitConnection(string? username, string url, GitReference gitReference, string privateKey, string publicKey, string? passphrase) + public SshGitConnection( + string? username, + string url, + GitReference gitReference, + string privateKey, + string publicKey, + string? passphrase) { Username = username; Url = url; diff --git a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs index 788a41e56e..863214d6cf 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs @@ -9,7 +9,7 @@ namespace Calamari.ArgoCD.Git.PullRequests { public interface IGitVendorPullRequestClientResolver { - Task TryResolve(HttpsGitConnection repositoryConnection, ILog log, + Task TryResolve(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken); } @@ -22,7 +22,7 @@ public GitVendorPullRequestClientResolver(IEnumerable TryResolve(HttpsGitConnection repositoryConnection, ILog log, + public async Task TryResolve(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { if (!Uri.TryCreate(repositoryConnection.Url, UriKind.Absolute, out var repositoryUri)) diff --git a/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs index 7e41a88f26..e6e81d10c0 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs @@ -26,6 +26,6 @@ async Task CanHandleAsSelfHosted(Uri repositoryUri, CancellationToken canc return false; } - Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken); + Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken); } } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs index 127031e4dc..ab3a239fd7 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs @@ -13,9 +13,9 @@ public class AzureDevOpsPullRequestClient : IGitVendorPullRequestClient { const string CloudHost = "dev.azure.com"; - readonly HttpsGitConnection repositoryConnection; + readonly IHttpsGitConnection repositoryConnection; - public AzureDevOpsPullRequestClient(HttpsGitConnection repositoryConnection) + public AzureDevOpsPullRequestClient(IHttpsGitConnection repositoryConnection) { this.repositoryConnection = repositoryConnection; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs index 6862b078b0..3d330c1e2a 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs @@ -11,7 +11,7 @@ public class AzureDevOpsPullRequestClientFactory : IGitVendorPullRequestClientFa public bool CanHandleAsCloudHosted(Uri repositoryUri) => AzureDevOpsRepositoryUriParser.IsAzureDevOpsRepository(repositoryUri); - public async Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) + public async Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { await Task.CompletedTask; return new AzureDevOpsPullRequestClient(repositoryConnection); diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs index e19dfdd2ba..399f56d1bf 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs @@ -11,12 +11,12 @@ namespace Calamari.ArgoCD.Git.PullRequests.Vendors.BitBucket { public class BitBucketPullRequestClient : IGitVendorPullRequestClient { - readonly HttpsGitConnection repositoryConnection; + readonly IHttpsGitConnection repositoryConnection; readonly Uri baseUrl; readonly string workspace; readonly string repositorySlug; - public BitBucketPullRequestClient(HttpsGitConnection repositoryConnection, Uri baseUrl) + public BitBucketPullRequestClient(IHttpsGitConnection repositoryConnection, Uri baseUrl) { this.repositoryConnection = repositoryConnection; this.baseUrl = baseUrl; diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs index 6eef02dbf0..7ebe86876d 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs @@ -15,7 +15,7 @@ public bool CanHandleAsCloudHosted(Uri repositoryUri) return repositoryUri.Host.Equals(baseUrl.Host, StringComparison.OrdinalIgnoreCase); } - public async Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) + public async Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { await Task.CompletedTask; return new BitBucketPullRequestClient(repositoryConnection, baseUrl); diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs index dc8f1cb554..1c1fb6cd42 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs @@ -16,7 +16,7 @@ public class GitHubPullRequestClientFactory: IGitVendorPullRequestClientFactory public bool CanHandleAsCloudHosted(Uri repositoryUri) => GitHubRepositoryUriParser.IsGitHub(repositoryUri); - public async Task Create(HttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) + public async Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { await Task.CompletedTask; diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs index 3361a12702..24a343dc97 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs @@ -21,7 +21,7 @@ public async Task CanHandleAsSelfHosted(Uri repositoryUri, CancellationTok return await selfHostedGitLabInspector.IsSelfHostedGitLabInstance(repositoryUri, cancellationToken); } - public async Task Create(HttpsGitConnection repositoryConnection, ILog taskLog, + public async Task Create(IHttpsGitConnection repositoryConnection, ILog taskLog, CancellationToken cancellationToken) { await Task.CompletedTask; From 68b8a2dc7e94d8e81737b275681d04293e416653 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 29 Apr 2026 15:20:29 +1000 Subject: [PATCH 14/39] Self review --- .../ArgoCD/ArgoCDCustomPropertiesDto.cs | 2 +- .../ArgoCD/Git/RepositoryFactoryTests.cs | 29 +++++++++++++++++++ .../ArgoCD/Git/RepositoryHelpers.cs | 2 ++ .../Git/AuthenticatingRepositoryFactory.cs | 2 ++ .../Vendors/GitHub/GitHubPullRequestClient.cs | 3 +- .../Vendors/GitLab/GitLabPullRequestClient.cs | 2 +- .../Calamari/ArgoCD/Git/RepositoryFactory.cs | 3 +- .../Calamari/ArgoCD/Git/RepositoryWrapper.cs | 6 ++-- .../Git/SshHostKeyVerificationBypass.cs | 7 +++++ 9 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 source/Calamari/ArgoCD/Git/SshHostKeyVerificationBypass.cs diff --git a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs index 6d8033fb57..ae2e65bbce 100644 --- a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs +++ b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs @@ -19,7 +19,7 @@ public record ArgoCDApplicationDto( string DefaultRegistry, string? InstanceWebUiUrl); -// GitUsernamePasswordCredentialDto +// GitUsernamePasswordCredentialDto - could rename, but not worth altering the API public record GitCredentialDto(string Url, string Username, string Password); public record GitCredentialSshKeyDto(string Url, string Username, string PrivateKey, string PublicKey, string? Passphrase); \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index 783c4db9a9..cf09112db8 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -5,6 +5,7 @@ using Calamari.ArgoCD.Git.PullRequests; using Calamari.Common.Commands; using Calamari.Common.Plumbing.FileSystem; +using Calamari.Common.Plumbing.Logging; using Calamari.Integration.Time; using Calamari.Testing.Helpers; using Calamari.Tests.Fixtures.Integration.FileSystem; @@ -94,6 +95,34 @@ public void CanCloneAnExistingRepositoryAtHEADAndAssociatedFiles() fileContent.Should().Be(originalContent); } + [Test] + public void CloningSshGitConnectionDoesNotResolveAPullRequestClientAndLogsVerboseMessage() + { + // Arrange + var filename = "sshTest.txt"; + var content = "ssh test content"; + CreateCommitOnOrigin(branchName, filename, content); + + var mockResolver = Substitute.For(); + var factoryWithMockedResolver = new RepositoryFactory(log, fileSystem, tempDirectory, mockResolver, new SystemClock()); + + var sshConnection = new SshGitConnection( + username: "git", + url: OriginPath, + gitReference: branchName, + privateKey: "private-key", + publicKey: "public-key", + passphrase: "passphrase"); + + // Act + factoryWithMockedResolver.CloneRepository("Clone_WithSshConnection", sshConnection); + + mockResolver.DidNotReceive().TryResolve(Arg.Any(), Arg.Any(), Arg.Any()); + + log.MessagesVerboseFormatted + .Should().Contain(s => s.Contains("SSH authentication") && s.Contains("Git vendor functionality will not be available")); + } + void CreateCommitOnOrigin(GitBranchName branchName, string fileName, string content) { var message = $"Commit: Message"; diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs index 8c8390b7a3..47449183f8 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs @@ -21,6 +21,8 @@ public static Repository CreateBareRepository(string repositoryPath) public static void CreateBranchIn(GitBranchName branchName, string originPath) { + LibGit2SharpTransportRegistration.EnsureRegistered(); + var signature = new Signature("Your Name", "your.email@example.com", DateTimeOffset.Now); var repository = new Repository(originPath); diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index 10ddbea4c9..861b6c0b3c 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -46,6 +46,8 @@ public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevis } log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); + // SCP-style URLs (git@github.com:org/repo.git) are rewritten to HTTPS by GitCloneSafeUrl. + // Anonymous HTTPS clone may fail with 401/404, which is confusing for SSH-only repos. var anonGitConnection = new HttpsGitConnection(null, null, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), anonGitConnection); } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs index 0e771ae83e..39095ffde1 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs @@ -3,7 +3,6 @@ using System.Threading; using System.Threading.Tasks; using Octokit; -using PullRequest = Calamari.ArgoCD.Git.PullRequests.PullRequest; namespace Calamari.ArgoCD.Git.PullRequests.Vendors.GitHub { @@ -14,7 +13,7 @@ public class GitHubPullRequestClient: IGitVendorPullRequestClient readonly string repoOwner; readonly string repoName; - public GitHubPullRequestClient(IGitHubClient client, IRepositoryConnection repositoryConnection, Uri baseUrl) + public GitHubPullRequestClient(IGitHubClient client, IHttpsGitConnection repositoryConnection, Uri baseUrl) { this.client = client; this.baseUrl = baseUrl; diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs index 4eef98107c..7d02696c99 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs @@ -13,7 +13,7 @@ public class GitLabPullRequestClient : IGitVendorPullRequestClient readonly Uri baseUrl; readonly string projectPath; - public GitLabPullRequestClient(GitLabClient gitLabClient, IRepositoryConnection repositoryConnection, Uri baseUrl) + public GitLabPullRequestClient(GitLabClient gitLabClient, IHttpsGitConnection repositoryConnection, Uri baseUrl) { this.gitLabClient = gitLabClient; this.baseUrl = baseUrl; diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index d58d2a03ed..d92d09bb57 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -75,8 +75,7 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che PrivateKey = ssh.PrivateKey, Passphrase = ssh.Passphrase }; - // TODO(eddy): Implement proper host key verification - options.FetchOptions.CertificateCheck = (cert, valid, host) => true; + options.FetchOptions.CertificateCheck = SshHostKeyVerificationBypass.AcceptAll; } else if (gitConnection is HttpsGitConnection { Username: not null, Password: not null } https) { diff --git a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs index 4fc7e19f30..ba8ef09eb8 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs @@ -203,8 +203,7 @@ public void PushChanges(GitBranchName branchName) { CredentialsProvider = (url, usernameFromUrl, types) => RepositoryCredentials, OnPushStatusError = errors => errorsDetected = errors, - // TODO(eddy): Implement proper host key verification for SSH connections - CertificateCheck = connection is SshGitConnection ? (cert, valid, host) => true : null + CertificateCheck = connection is SshGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null }; repository.Network.Push(repository.Head, pushOptions); @@ -221,8 +220,7 @@ void FetchAndRebase(GitBranchName branchName) var fetchOptions = new FetchOptions { CredentialsProvider = (url, usernameFromUrl, types) => RepositoryCredentials, - // TODO(eddy): Implement proper host key verification for SSH connections - CertificateCheck = connection is SshGitConnection ? (cert, valid, host) => true : null + CertificateCheck = connection is SshGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null }; try diff --git a/source/Calamari/ArgoCD/Git/SshHostKeyVerificationBypass.cs b/source/Calamari/ArgoCD/Git/SshHostKeyVerificationBypass.cs new file mode 100644 index 0000000000..0e2bd6bf28 --- /dev/null +++ b/source/Calamari/ArgoCD/Git/SshHostKeyVerificationBypass.cs @@ -0,0 +1,7 @@ +namespace Calamari.ArgoCD.Git; + +internal static class SshHostKeyVerificationBypass +{ + // TODO(eddy): Implement proper host key verification + public static readonly LibGit2Sharp.Handlers.CertificateCheckHandler AcceptAll = (cert, valid, host) => true; +} From 22afdadfebad3755fb80b1c6748c352d9ca5669f Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 6 May 2026 08:39:53 +1000 Subject: [PATCH 15/39] Clean up DTOs + libgit models --- .../Variables/CustomPropertiesLoader.cs | 27 ++- .../ArgoCD/ArgoCDCustomPropertiesDto.cs | 16 +- ...goCDAppImagesInstallConventionHelmTests.cs | 3 +- ...ateArgoCDAppImagesInstallConventionTest.cs | 3 +- ...licationManifestsInstallConventionTests.cs | 6 +- ...CDCustomPropertiesDtoSerializationTests.cs | 186 ++++++++++++++++++ .../AuthenticatingRepositoryFactoryTests.cs | 40 ++-- .../ArgoCD/Git/RepositoryFactoryTests.cs | 5 +- .../Commands/UpdateArgoCDAppImagesCommand.cs | 3 +- .../UpdateArgoCDAppManifestsCommand.cs | 3 +- .../UpdateArgoCDAppImagesInstallConvention.cs | 7 +- ...CDApplicationManifestsInstallConvention.cs | 8 +- .../Git/AuthenticatingRepositoryFactory.cs | 42 ++-- ...HttpsGitConnection.cs => GitConnection.cs} | 9 +- .../Git/IGitCredentialDtoJsonConverter.cs | 40 ++++ .../Calamari/ArgoCD/Git/RepositoryFactory.cs | 4 +- .../Calamari/ArgoCD/Git/RepositoryWrapper.cs | 2 +- source/Calamari/Calamari.csproj | 2 +- 18 files changed, 310 insertions(+), 96 deletions(-) create mode 100644 source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs rename source/Calamari/ArgoCD/Git/{HttpsGitConnection.cs => GitConnection.cs} (82%) create mode 100644 source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs diff --git a/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs b/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs index 9c489a234c..2e4762de93 100644 --- a/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs +++ b/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs @@ -1,13 +1,8 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Security.Cryptography; using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Commands; -using Calamari.Common.Plumbing.Deployment.PackageRetention; using Calamari.Common.Plumbing.Extensions; using Calamari.Common.Plumbing.FileSystem; -using Calamari.Common.Plumbing.Logging; using Newtonsoft.Json; namespace Calamari.Common.Plumbing.Variables @@ -22,13 +17,23 @@ public class CustomPropertiesLoader : ICustomPropertiesLoader readonly ICalamariFileSystem fileSystem; readonly string customPropertiesFile; readonly string password; + readonly JsonSerializerSettings serializerSettings; - - public CustomPropertiesLoader(ICalamariFileSystem fileSystem, string customPropertiesFile, string password) + public CustomPropertiesLoader(ICalamariFileSystem fileSystem, string customPropertiesFile, string password, params JsonConverter[] converters) { this.fileSystem = fileSystem; this.customPropertiesFile = customPropertiesFile; this.password = password; + + serializerSettings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.None, + DateParseHandling = DateParseHandling.None, + }; + foreach (var converter in converters) + { + serializerSettings.Converters.Add(converter); + } } public T Load() @@ -37,7 +42,7 @@ public T Load() try { - return JsonConvert.DeserializeObject(json, SerializerSettings); + return JsonConvert.DeserializeObject(json, serializerSettings); } catch (JsonReaderException) { @@ -45,12 +50,6 @@ public T Load() } } - static readonly JsonSerializerSettings SerializerSettings = new JsonSerializerSettings - { - TypeNameHandling = TypeNameHandling.None, - DateParseHandling = DateParseHandling.None, - }; - static string Decrypt(byte[] encryptedJson, string encryptionPassword) { try diff --git a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs index ae2e65bbce..c92f804344 100644 --- a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs +++ b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs @@ -5,9 +5,8 @@ namespace Octopus.Calamari.Contracts.ArgoCD; public record ArgoCDCustomPropertiesDto( ArgoCDGatewayDto[] Gateways, ArgoCDApplicationDto[] Applications, - GitCredentialDto[] Credentials, - // Nullable for backwards compatibility - GitCredentialSshKeyDto[]? SshCredentials); + IGitCredentialDto[] Credentials +); public record ArgoCDGatewayDto(string Id, string Name); @@ -19,7 +18,12 @@ public record ArgoCDApplicationDto( string DefaultRegistry, string? InstanceWebUiUrl); -// GitUsernamePasswordCredentialDto - could rename, but not worth altering the API -public record GitCredentialDto(string Url, string Username, string Password); +public interface IGitCredentialDto +{ + string Url { get; } +} -public record GitCredentialSshKeyDto(string Url, string Username, string PrivateKey, string PublicKey, string? Passphrase); \ No newline at end of file +// UsernamePasswordGitCredentialDto - could rename, but not worth altering the API +public record GitCredentialDto(string Url, string Username, string Password) : IGitCredentialDto; + +public record SshKeyGitCredentialDto(string Url, string Username, string PrivateKey) : IGitCredentialDto; \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs index 065fa59967..0a54f4c6af 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs @@ -99,8 +99,7 @@ public void Init() ], [ new GitCredentialDto(OriginUrl, "", "") - ], - []); + ]); customPropertiesLoader.Load().Returns(argoCdCustomPropertiesDto); argoCdApplicationFromYaml = new Application() diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs index 6af0ee334c..1ae4028229 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs @@ -94,8 +94,7 @@ public void Init() ], [ new GitCredentialDto(OriginUrl, "", "") - ], - []); + ]); customPropertiesLoader.Load().Returns(argoCdCustomPropertiesDto); var argoCdApplicationFromYaml = new ArgoCDApplicationBuilder() diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs index 30542aa2d7..72c726db26 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs @@ -76,8 +76,7 @@ public void Init() ], [ new GitCredentialDto(OriginUrl, "", "") - ], - []); + ]); customPropertiesLoader.Load().Returns(argoCdCustomPropertiesDto); var argoCdApplicationFromYaml = new ArgoCDApplicationBuilder() @@ -665,9 +664,8 @@ public void ExecuteCopiesFilesWhenUsingSshCredentials() "docker.io", "http://my-argo.com") ], - [], [ - new GitCredentialSshKeyDto(RepoUrl, "git", "private-key", "public-key", "passphrase") + new SshKeyGitCredentialDto(RepoUrl, "git", "private-key") ]); customPropertiesLoader.Load().Returns(argoCdCustomPropertiesDto); diff --git a/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs new file mode 100644 index 0000000000..5eab0c08ba --- /dev/null +++ b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs @@ -0,0 +1,186 @@ +#nullable enable +using Calamari.ArgoCD.Git; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Octopus.Calamari.Contracts.ArgoCD; + +namespace Calamari.Tests.ArgoCD.Contracts; + +/// +/// Deserialisation tests for with a focus on +/// the polymorphic array. The converter discriminates on +/// the Kind field emitted by Octopus Server (the concrete type name); a missing +/// Kind defaults to for backwards compatibility. +/// +[TestFixture] +public class ArgoCDCustomPropertiesDtoSerializationTests +{ + static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.None, + DateParseHandling = DateParseHandling.None, + Converters = { new IGitCredentialDtoJsonConverter() } + }; + + static T DeserializeRaw(string json) + => JsonConvert.DeserializeObject(json, Settings)!; + + [Test] + public void KindGitCredentialDto_DeserializesAsHttpsCredential() + { + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [ + { + "Kind": "GitCredentialDto", + "Url": "https://github.com/org/repo.git", + "Username": "user", + "Password": "pass" + } + ] + } + """; + + var result = DeserializeRaw(json); + + result.Credentials.Should().HaveCount(1); + var credential = result.Credentials[0].Should().BeOfType().Subject; + credential.Url.Should().Be("https://github.com/org/repo.git"); + credential.Username.Should().Be("user"); + credential.Password.Should().Be("pass"); + } + + [Test] + public void KindSshKeyGitCredentialDto_DeserializesAsSshCredential() + { + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [ + { + "Kind": "SshKeyGitCredentialDto", + "Url": "git@github.com:org/repo.git", + "Username": "git", + "PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----", + "Passphrase": "my-passphrase" + } + ] + } + """; + + var result = DeserializeRaw(json); + + result.Credentials.Should().HaveCount(1); + var credential = result.Credentials[0].Should().BeOfType().Subject; + credential.Url.Should().Be("git@github.com:org/repo.git"); + credential.Username.Should().Be("git"); + credential.PrivateKey.Should().Be("-----BEGIN OPENSSH PRIVATE KEY-----"); + } + + [Test] + public void MixedArray_BothTypesPreserved() + { + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [ + { + "Kind": "GitCredentialDto", + "Url": "https://github.com/org/repo.git", + "Username": "user", + "Password": "pass" + }, + { + "Kind": "SshKeyGitCredentialDto", + "Url": "git@github.com:org/other.git", + "Username": "git", + "PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----", + "Passphrase": null + } + ] + } + """; + + var result = DeserializeRaw(json); + + result.Credentials.Should().HaveCount(2); + result.Credentials[0].Should().BeOfType(); + result.Credentials[1].Should().BeOfType(); + } + + [Test] + public void MissingKind_DefaultsToHttpsCredential() + { + // Backwards compatibility: older server versions don't emit the Kind field. + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [ + { + "Url": "https://github.com/org/repo.git", + "Username": "user", + "Password": "pass" + } + ] + } + """; + + var result = DeserializeRaw(json); + + result.Credentials.Should().HaveCount(1); + result.Credentials[0].Should().BeOfType(); + } + + [Test] + public void UnknownKind_Throws() + { + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [ + { "Kind": "MysteryCredentialDto", "Url": "x" } + ] + } + """; + + var act = () => DeserializeRaw(json); + + act.Should().Throw() + .WithMessage("*MysteryCredentialDto*"); + } + + [Test] + public void EmptyCredentialsArray_Deserializes() + { + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [] + } + """; + + var result = DeserializeRaw(json); + + result.Credentials.Should().BeEmpty(); + } + + [Test] + public void MissingCredentialsField_DeserializesAsNull() + { + const string json = """ + { + "Gateways": [{"Id": "gw1", "Name": "Gateway 1"}], + "Applications": [{"GatewayId": "gw1", "Name": "App1", "KubernetesNamespace": "argocd", + "Manifest": "manifest.yaml", "DefaultRegistry": "docker.io", + "InstanceWebUiUrl": null}] + } + """; + + var result = DeserializeRaw(json); + + result.Should().NotBeNull(); + result.Credentials.Should().BeNull(); + } +} diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs index c226ec76a2..02d0d3fdf6 100644 --- a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Calamari.ArgoCD.Git; using Calamari.ArgoCD.Git.PullRequests; using Calamari.Common.Plumbing.FileSystem; @@ -55,11 +56,10 @@ public void HttpsCredentialIsSelectedWhenUrlMatchesHttpsCredential() { var httpsUrl = RepositoryHelpers.ToFileUri(OriginPath); var factory = new AuthenticatingRepositoryFactory( - new Dictionary + new Dictionary { [httpsUrl] = new GitCredentialDto(httpsUrl, "", "") }, - new Dictionary(), repositoryFactory, log); @@ -72,8 +72,7 @@ public void AnonymousCloneWhenNoCredentialsMatch() { var originUrl = RepositoryHelpers.ToFileUri(OriginPath); var factory = new AuthenticatingRepositoryFactory( - new Dictionary(), - new Dictionary(), + new Dictionary(), repositoryFactory, log); @@ -90,11 +89,10 @@ public class SshUrlTests : AuthenticatingRepositoryFactoryTestBase public void SshCredentialIsSelectedWhenUrlMatchesSshCredential() { var factory = new AuthenticatingRepositoryFactory( - new Dictionary(), - new Dictionary + new Dictionary { // Use the local path as the SSH credential URL so the clone actually works - [OriginPath] = new GitCredentialSshKeyDto(OriginPath, "git", "private-key", "public-key", "passphrase") + [OriginPath] = new SshKeyGitCredentialDto(OriginPath, "git", "private-key") }, repositoryFactory, log); @@ -121,11 +119,10 @@ public void ScpStyleUrlDoesNotMatchHttpsCredential() var httpsUrl = "https://github.com/org/repo.git"; var factory = new AuthenticatingRepositoryFactory( - new Dictionary + new Dictionary { [httpsUrl] = new GitCredentialDto(httpsUrl, "user", "pass") }, - new Dictionary(), repositoryFactory, log); @@ -147,17 +144,20 @@ protected void AssertHttpsCredentialTakesPriorityOverSsh(string url) { var mockRepoFactory = Substitute.For(); - var factory = new AuthenticatingRepositoryFactory( - new Dictionary - { - [url] = new GitCredentialDto(url, "https-user", "https-pass") - }, - new Dictionary - { - [url] = new GitCredentialSshKeyDto(url, "ssh-user", "private-key", "public-key", "passphrase") - }, - mockRepoFactory, - log); + // If there are HTTPS and SSH credentials for the same URL, HTTPS wins so API functionality works. + IGitCredentialDto[] rawCredentials = + [ + new GitCredentialDto(url, "https-user", "https-pass"), + new SshKeyGitCredentialDto(url, "ssh-user", "private-key") + ]; + var credentialDictionary = rawCredentials + .GroupBy(c => c.Url) + .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); + + // The HTTPS credential must have been selected by the GroupBy rule. + credentialDictionary[url].Should().BeOfType("HTTPS credentials take priority over SSH for the same URL"); + + var factory = new AuthenticatingRepositoryFactory(credentialDictionary, mockRepoFactory, log); factory.CloneRepository(url, "main"); diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index cf09112db8..708425aad5 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -110,10 +110,9 @@ public void CloningSshGitConnectionDoesNotResolveAPullRequestClientAndLogsVerbos username: "git", url: OriginPath, gitReference: branchName, - privateKey: "private-key", - publicKey: "public-key", - passphrase: "passphrase"); + privateKey: "private-key"); + // libgit2 skips credential callbacks for local file paths, so this test validates only pull-request-client resolution and verbose logging — not SSH credential validity. // Act factoryWithMockedResolver.CloneRepository("Clone_WithSshConnection", sshConnection); diff --git a/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppImagesCommand.cs b/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppImagesCommand.cs index 4c39556754..6eca24253b 100644 --- a/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppImagesCommand.cs +++ b/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppImagesCommand.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.IO; using Calamari.ArgoCD.Conventions; +using Calamari.ArgoCD.Git; using Calamari.ArgoCD.Git.PullRequests; using Calamari.Commands.Support; using Calamari.Common.Commands; @@ -56,7 +57,7 @@ public override int Execute(string[] commandLineArguments) new UpdateArgoCDAppImagesInstallConvention(log, fileSystem, configFactory, - new CustomPropertiesLoader(fileSystem, customPropertiesFile, customPropertiesPassword), + new CustomPropertiesLoader(fileSystem, customPropertiesFile, customPropertiesPassword, new IGitCredentialDtoJsonConverter()), new ArgoCdApplicationManifestParser(), gitVendorPullRequestClientResolver, clock, diff --git a/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppManifestsCommand.cs b/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppManifestsCommand.cs index 4a2a92ecff..efd146d6de 100644 --- a/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppManifestsCommand.cs +++ b/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppManifestsCommand.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using Calamari.ArgoCD.Conventions; +using Calamari.ArgoCD.Git; using Calamari.ArgoCD.Git.PullRequests; using Calamari.Commands; using Calamari.Commands.Support; @@ -94,7 +95,7 @@ public override int Execute(string[] commandLineArguments) PackageDirectoryName, log, configFactory, - new CustomPropertiesLoader(fileSystem, customPropertiesFile, customPropertiesPassword), + new CustomPropertiesLoader(fileSystem, customPropertiesFile, customPropertiesPassword, new IGitCredentialDtoJsonConverter()), new ArgoCdApplicationManifestParser(), argoCDManifestsFileMatcher, gitVendorPullRequestClientResolver, diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs index e5e7ef0a86..d57c53e389 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs @@ -61,9 +61,10 @@ public void Install(RunningDeployment deployment) clock); var argoProperties = customPropertiesLoader.Load(); - var gitCredentials = argoProperties.Credentials.ToDictionary(c => c.Url); - var sshCredentials = argoProperties.SshCredentials?.ToDictionary(c => c.Url) ?? new Dictionary(); - var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, sshCredentials, repositoryFactory, log); + var gitCredentials = argoProperties.Credentials + .GroupBy(c => c.Url) + .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); + var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); log.LogApplicationCounts(deploymentScope, argoProperties.Applications); diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs index 54d495950a..cbecb94f8a 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs @@ -1,6 +1,5 @@ #nullable enable using System; -using System.Collections.Generic; using System.IO; using System.Linq; using Calamari.ArgoCD.Conventions.ManifestTemplating; @@ -70,9 +69,10 @@ public void Install(RunningDeployment deployment) var argoProperties = customPropertiesLoader.Load(); - var gitCredentials = argoProperties.Credentials.ToDictionary(c => c.Url); - var sshCredentials = argoProperties.SshCredentials?.ToDictionary(c => c.Url) ?? new Dictionary(); - var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, sshCredentials, repositoryFactory, log); + var gitCredentials = argoProperties.Credentials + .GroupBy(c => c.Url) + .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); + var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); log.LogApplicationCounts(deploymentScope, argoProperties.Applications); diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index 861b6c0b3c..5466139584 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -6,43 +6,39 @@ namespace Calamari.ArgoCD.Git; public class AuthenticatingRepositoryFactory { - readonly Dictionary httpsGitCredentials; - readonly Dictionary sshGitCredentials; + readonly Dictionary gitCredentials; readonly IRepositoryFactory repositoryFactory; readonly ILog log; public AuthenticatingRepositoryFactory( - Dictionary httpsGitCredentials, - Dictionary sshGitCredentials, + Dictionary gitCredentials, IRepositoryFactory repositoryFactory, ILog log) { - this.httpsGitCredentials = httpsGitCredentials; - this.sshGitCredentials = sshGitCredentials; + this.gitCredentials = gitCredentials; this.repositoryFactory = repositoryFactory; this.log = log; } public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevision) { - var httpsGitCredential = httpsGitCredentials.GetValueOrDefault(requestedUrl); - if (httpsGitCredential is not null) + var gitCredential = gitCredentials.GetValueOrDefault(requestedUrl); + switch (gitCredential) { - var gitConnection = new HttpsGitConnection(httpsGitCredential.Username, httpsGitCredential.Password, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); - return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection); - } - - var sshGitCredential = sshGitCredentials.GetValueOrDefault(requestedUrl); - if (sshGitCredential is not null) - { - var sshConnection = new SshGitConnection( - sshGitCredential.Username, - requestedUrl, - GitReference.CreateFromString(targetRevision), - sshGitCredential.PrivateKey, - sshGitCredential.PublicKey, - sshGitCredential.Passphrase); - return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), sshConnection); + case GitCredentialDto passwordCredential: + { + var gitConnection = new HttpsGitConnection(passwordCredential.Username, passwordCredential.Password, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); + return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection); + } + case SshKeyGitCredentialDto sshCredential: + { + var sshConnection = new SshGitConnection( + sshCredential.Username, + requestedUrl, + GitReference.CreateFromString(targetRevision), + sshCredential.PrivateKey); + return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), sshConnection); + } } log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); diff --git a/source/Calamari/ArgoCD/Git/HttpsGitConnection.cs b/source/Calamari/ArgoCD/Git/GitConnection.cs similarity index 82% rename from source/Calamari/ArgoCD/Git/HttpsGitConnection.cs rename to source/Calamari/ArgoCD/Git/GitConnection.cs index c9bfa89679..07c05ed26c 100644 --- a/source/Calamari/ArgoCD/Git/HttpsGitConnection.cs +++ b/source/Calamari/ArgoCD/Git/GitConnection.cs @@ -40,24 +40,17 @@ public SshGitConnection( string? username, string url, GitReference gitReference, - string privateKey, - string publicKey, - string? passphrase) + string privateKey) { Username = username; Url = url; GitReference = gitReference; PrivateKey = privateKey; - PublicKey = publicKey; - Passphrase = passphrase; } public string? Username { get; } - public string? Password => null; public string Url { get; } public GitReference GitReference { get; } public string PrivateKey { get; } - public string PublicKey { get; } - public string? Passphrase { get; } } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs b/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs new file mode 100644 index 0000000000..3e49167d4a --- /dev/null +++ b/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs @@ -0,0 +1,40 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Octopus.Calamari.Contracts.ArgoCD; + +namespace Calamari.ArgoCD.Git; + +/// +/// Discriminates an on the Kind field emitted by Octopus +/// Server (matching the concrete type name). A missing Kind defaults to +/// for backwards compatibility with server versions that pre-date +/// the field. +/// +public class IGitCredentialDtoJsonConverter : JsonConverter +{ + static readonly JsonSerializer ConcreteSerializer = new(); + + public override bool CanWrite => false; + + public override void WriteJson(JsonWriter writer, IGitCredentialDto? value, JsonSerializer serializer) + => throw new NotSupportedException(); + + public override IGitCredentialDto ReadJson( + JsonReader reader, + Type objectType, + IGitCredentialDto? existingValue, + bool hasExistingValue, + JsonSerializer serializer) + { + var obj = JObject.Load(reader); + var kind = obj["Kind"]?.Value(); + + return kind switch + { + null or nameof(GitCredentialDto) => obj.ToObject(ConcreteSerializer)!, + nameof(SshKeyGitCredentialDto) => obj.ToObject(ConcreteSerializer)!, + _ => throw new JsonSerializationException($"Unrecognised credential Kind '{kind}'.") + }; + } +} diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index d92d09bb57..cbd9103104 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -68,12 +68,10 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che if (gitConnection is SshGitConnection ssh) { - options.FetchOptions.CredentialsProvider = (url, usernameFromUrl, types) => new SshUserKeyMemoryCredentials + options.FetchOptions.CredentialsProvider = (url, usernameFromUrl, types) => new SshKeyMemoryCredentials { Username = ssh.Username, - PublicKey = ssh.PublicKey, PrivateKey = ssh.PrivateKey, - Passphrase = ssh.Passphrase }; options.FetchOptions.CertificateCheck = SshHostKeyVerificationBypass.AcceptAll; } diff --git a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs index ba8ef09eb8..c4edab8ec8 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs @@ -40,7 +40,7 @@ public class RepositoryWrapper( Credentials RepositoryCredentials => connection switch { - SshGitConnection ssh => new SshUserKeyMemoryCredentials { Username = ssh.Username, PublicKey = ssh.PublicKey, PrivateKey = ssh.PrivateKey, Passphrase = ssh.Passphrase }, + SshGitConnection ssh => new SshKeyMemoryCredentials { Username = ssh.Username, PrivateKey = ssh.PrivateKey }, HttpsGitConnection https => new UsernamePasswordCredentials { Username = https.Username, Password = https.Password }, _ => null }; diff --git a/source/Calamari/Calamari.csproj b/source/Calamari/Calamari.csproj index 6b575866eb..5c7a43fa02 100644 --- a/source/Calamari/Calamari.csproj +++ b/source/Calamari/Calamari.csproj @@ -49,7 +49,7 @@ - + From 8cf57a0c63b36f566185b29f5fa222fe004dba90 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 6 May 2026 12:03:27 +1000 Subject: [PATCH 16/39] Add type discriminator --- .../ArgoCD/ArgoCDCustomPropertiesDto.cs | 11 ++++- ...CDCustomPropertiesDtoSerializationTests.cs | 42 ++++++------------- .../Git/IGitCredentialDtoJsonConverter.cs | 10 ++--- 3 files changed, 26 insertions(+), 37 deletions(-) diff --git a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs index c92f804344..f68161821c 100644 --- a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs +++ b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs @@ -20,10 +20,17 @@ public record ArgoCDApplicationDto( public interface IGitCredentialDto { + string Type { get; } string Url { get; } } // UsernamePasswordGitCredentialDto - could rename, but not worth altering the API -public record GitCredentialDto(string Url, string Username, string Password) : IGitCredentialDto; +public record GitCredentialDto(string Url, string Username, string Password) : IGitCredentialDto +{ + public string Type => nameof(GitCredentialDto); +} -public record SshKeyGitCredentialDto(string Url, string Username, string PrivateKey) : IGitCredentialDto; \ No newline at end of file +public record SshKeyGitCredentialDto(string Url, string Username, string PrivateKey) : IGitCredentialDto +{ + public string Type => nameof(SshKeyGitCredentialDto); +} \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs index 5eab0c08ba..d3e9ca40f0 100644 --- a/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs +++ b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs @@ -10,8 +10,8 @@ namespace Calamari.Tests.ArgoCD.Contracts; /// /// Deserialisation tests for with a focus on /// the polymorphic array. The converter discriminates on -/// the Kind field emitted by Octopus Server (the concrete type name); a missing -/// Kind defaults to for backwards compatibility. +/// the Type field emitted by Octopus Server (the concrete type name); a missing +/// Type defaults to for backwards compatibility. /// [TestFixture] public class ArgoCDCustomPropertiesDtoSerializationTests @@ -27,14 +27,14 @@ static T DeserializeRaw(string json) => JsonConvert.DeserializeObject(json, Settings)!; [Test] - public void KindGitCredentialDto_DeserializesAsHttpsCredential() + public void TypeGitCredentialDto_DeserializesAsHttpsCredential() { const string json = """ { "Gateways": [], "Applications": [], "Credentials": [ { - "Kind": "GitCredentialDto", + "Type": "GitCredentialDto", "Url": "https://github.com/org/repo.git", "Username": "user", "Password": "pass" @@ -53,14 +53,14 @@ public void KindGitCredentialDto_DeserializesAsHttpsCredential() } [Test] - public void KindSshKeyGitCredentialDto_DeserializesAsSshCredential() + public void TypeSshKeyGitCredentialDto_DeserializesAsSshCredential() { const string json = """ { "Gateways": [], "Applications": [], "Credentials": [ { - "Kind": "SshKeyGitCredentialDto", + "Type": "SshKeyGitCredentialDto", "Url": "git@github.com:org/repo.git", "Username": "git", "PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----", @@ -87,13 +87,13 @@ public void MixedArray_BothTypesPreserved() "Gateways": [], "Applications": [], "Credentials": [ { - "Kind": "GitCredentialDto", + "Type": "GitCredentialDto", "Url": "https://github.com/org/repo.git", "Username": "user", "Password": "pass" }, { - "Kind": "SshKeyGitCredentialDto", + "Type": "SshKeyGitCredentialDto", "Url": "git@github.com:org/other.git", "Username": "git", "PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----", @@ -111,9 +111,9 @@ public void MixedArray_BothTypesPreserved() } [Test] - public void MissingKind_DefaultsToHttpsCredential() + public void MissingType_DefaultsToHttpsCredential() { - // Backwards compatibility: older server versions don't emit the Kind field. + // Backwards compatibility: older server versions don't emit the Type field. const string json = """ { "Gateways": [], "Applications": [], @@ -134,13 +134,13 @@ public void MissingKind_DefaultsToHttpsCredential() } [Test] - public void UnknownKind_Throws() + public void UnknownType_Throws() { const string json = """ { "Gateways": [], "Applications": [], "Credentials": [ - { "Kind": "MysteryCredentialDto", "Url": "x" } + { "Type": "MysteryCredentialDto", "Url": "x" } ] } """; @@ -165,22 +165,4 @@ public void EmptyCredentialsArray_Deserializes() result.Credentials.Should().BeEmpty(); } - - [Test] - public void MissingCredentialsField_DeserializesAsNull() - { - const string json = """ - { - "Gateways": [{"Id": "gw1", "Name": "Gateway 1"}], - "Applications": [{"GatewayId": "gw1", "Name": "App1", "KubernetesNamespace": "argocd", - "Manifest": "manifest.yaml", "DefaultRegistry": "docker.io", - "InstanceWebUiUrl": null}] - } - """; - - var result = DeserializeRaw(json); - - result.Should().NotBeNull(); - result.Credentials.Should().BeNull(); - } } diff --git a/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs b/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs index 3e49167d4a..c5becdd71f 100644 --- a/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs +++ b/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs @@ -6,8 +6,8 @@ namespace Calamari.ArgoCD.Git; /// -/// Discriminates an on the Kind field emitted by Octopus -/// Server (matching the concrete type name). A missing Kind defaults to +/// Discriminates an on the Type field emitted by Octopus +/// Server (matching the concrete type name). A missing Type defaults to /// for backwards compatibility with server versions that pre-date /// the field. /// @@ -28,13 +28,13 @@ public override IGitCredentialDto ReadJson( JsonSerializer serializer) { var obj = JObject.Load(reader); - var kind = obj["Kind"]?.Value(); + var type = obj["Type"]?.Value(); - return kind switch + return type switch { null or nameof(GitCredentialDto) => obj.ToObject(ConcreteSerializer)!, nameof(SshKeyGitCredentialDto) => obj.ToObject(ConcreteSerializer)!, - _ => throw new JsonSerializationException($"Unrecognised credential Kind '{kind}'.") + _ => throw new JsonSerializationException($"Unrecognised credential Type '{type}'.") }; } } From 193b3b056693b368531cfb0b107368f42fef702a Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Thu, 7 May 2026 15:44:41 +1000 Subject: [PATCH 17/39] openssl1.1 handling --- source/Calamari.Testing/Helpers/TestCategory.cs | 2 -- .../UpdateArgoCDAppImagesInstallConventionHelmTests.cs | 1 - .../UpdateArgoCDAppImagesInstallConventionTest.cs | 1 - ...pdateArgoCDApplicationManifestsInstallConventionTests.cs | 1 - .../ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs | 1 - .../ArgoCD/Git/GitHttpSmartSubTransportTests.cs | 1 - .../PullRequests/GitVendorApiAdapter_PullRequestTests.cs | 1 - source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs | 1 - source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs | 1 - .../ArgoCD/Git/LibGit2SharpTransportRegistration.cs | 6 +++--- source/Calamari/Calamari.csproj | 2 +- 11 files changed, 4 insertions(+), 14 deletions(-) diff --git a/source/Calamari.Testing/Helpers/TestCategory.cs b/source/Calamari.Testing/Helpers/TestCategory.cs index c04db10ae7..6874ef4f3f 100644 --- a/source/Calamari.Testing/Helpers/TestCategory.cs +++ b/source/Calamari.Testing/Helpers/TestCategory.cs @@ -23,7 +23,5 @@ public static class CompatibleOS public const string PlatformAgnostic = "PlatformAgnostic"; public const string RunOnceOnWindowsAndLinux = "RunOnceOnWindowsAndLinux"; - - public const string RequiresOpenSsl3 = "RequiresOpenSsl3"; } } \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs index 0a54f4c6af..7ae6e890db 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs @@ -29,7 +29,6 @@ namespace Calamari.Tests.ArgoCD.Commands.Conventions { - [Category(TestCategory.RequiresOpenSsl3)] public class UpdateArgoCDAppImagesInstallConventionHelmTests { const string ProjectSlug = "TheProject"; diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs index 1ae4028229..3b1455b9a7 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs @@ -29,7 +29,6 @@ namespace Calamari.Tests.ArgoCD.Commands.Conventions { [TestFixture] - [Category(TestCategory.RequiresOpenSsl3)] public class UpdateArgoCDAppImagesInstallConventionTests { const string ProjectSlug = "TheProject"; diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs index 72c726db26..0091c02709 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs @@ -28,7 +28,6 @@ namespace Calamari.Tests.ArgoCD.Commands.Conventions { [TestFixture] - [Category(TestCategory.RequiresOpenSsl3)] public class UpdateArgoCDApplicationManifestsInstallConventionTests { const string ProjectSlug = "TheProject"; diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs index 02d0d3fdf6..9bd6674763 100644 --- a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -15,7 +15,6 @@ namespace Calamari.Tests.ArgoCD.Git; -[Category(TestCategory.RequiresOpenSsl3)] public abstract class AuthenticatingRepositoryFactoryTestBase { protected readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs index f2a8f34efe..ccf8c7a1c7 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs @@ -17,7 +17,6 @@ namespace Calamari.Tests.ArgoCD.Git; [TestFixture] -[Category(TestCategory.RequiresOpenSsl3)] public class GitHttpSmartSubTransportTests { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs index b372fab3ca..f2d722d409 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs @@ -23,7 +23,6 @@ namespace Calamari.Tests.ArgoCD.Git.GitVendorApiAdapters { [TestFixture] - [Category(TestCategory.RequiresOpenSsl3)] public class GitHubPullRequestClientTests { [Test] diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index 708425aad5..9aaced2755 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -17,7 +17,6 @@ namespace Calamari.Tests.ArgoCD.Git { [TestFixture] - [Category(TestCategory.RequiresOpenSsl3)] public class RepositoryFactoryTests { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs index c1991234da..ac047c3448 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs @@ -19,7 +19,6 @@ namespace Calamari.Tests.ArgoCD.Git { [TestFixture] - [Category(TestCategory.RequiresOpenSsl3)] public class RepositoryWrapperTest { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs b/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs index d24b16af9b..f61a81ed9a 100644 --- a/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs +++ b/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs @@ -31,8 +31,8 @@ static bool Register() var message = $""" Failed to load the native libgit2 library required for Git operations. - On Linux, libgit2 requires OpenSSL 3 (libcrypto.so.3) to be installed on the worker. - Please install it according to your distributions guidance or update to a supported OS. + On Linux, libgit2 requires either OpenSSL 3 (libcrypto.so.3) or OpenSSL 1.1 (libcrypto.so.1.1) to be installed on the worker. + Please install one of them according to your distributions guidance or update to a supported OS. Original exception: {dllEx.Message} @@ -43,4 +43,4 @@ Please install it according to your distributions guidance or update to a suppor return true; } -} \ No newline at end of file +} diff --git a/source/Calamari/Calamari.csproj b/source/Calamari/Calamari.csproj index 5c7a43fa02..4b44ff90be 100644 --- a/source/Calamari/Calamari.csproj +++ b/source/Calamari/Calamari.csproj @@ -49,7 +49,7 @@ - + From 4dd5cbec4fe4a8f8afbd06d31cc7d560d387cd3c Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 8 May 2026 11:21:24 +1000 Subject: [PATCH 18/39] Update libgit2sharp --- source/Calamari/Calamari.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari/Calamari.csproj b/source/Calamari/Calamari.csproj index 4b44ff90be..2c971fc20d 100644 --- a/source/Calamari/Calamari.csproj +++ b/source/Calamari/Calamari.csproj @@ -49,7 +49,7 @@ - + From cb5907c9b9e03eca8565e9a0ceebb4ffa8e35e4d Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 8 May 2026 15:00:25 +1000 Subject: [PATCH 19/39] Restore openssl test categories --- build/Build.CalamariTesting.cs | 20 ++++++------------- .../Calamari.Testing/Helpers/TestCategory.cs | 2 ++ ...goCDAppImagesInstallConventionHelmTests.cs | 1 + ...ateArgoCDAppImagesInstallConventionTest.cs | 1 + ...licationManifestsInstallConventionTests.cs | 1 + .../AuthenticatingRepositoryFactoryTests.cs | 1 + .../Git/GitHttpSmartSubTransportTests.cs | 1 + .../GitVendorApiAdapter_PullRequestTests.cs | 1 + .../ArgoCD/Git/RepositoryFactoryTests.cs | 1 + .../ArgoCD/Git/RepositoryWrapperTest.cs | 1 + 10 files changed, 16 insertions(+), 14 deletions(-) diff --git a/build/Build.CalamariTesting.cs b/build/Build.CalamariTesting.cs index 4db99a587b..d349d276d8 100644 --- a/build/Build.CalamariTesting.cs +++ b/build/Build.CalamariTesting.cs @@ -31,21 +31,13 @@ partial class Build .Execute(); }); + // Temporary so I can merge this PR without breaking Teamcity builds [PublicAPI] Target LinuxSpecificTestingWithoutOpenSsl3 => - target => target - .Executes(async () => - { - var dotnetPath = await LocateOrInstallDotNetSdk(); - - CreateTestRun("Binaries/Calamari.Tests.dll") - .WithDotNetPath(dotnetPath) - .WithFilter("TestCategory != Windows & TestCategory != PlatformAgnostic & TestCategory != RunOnceOnWindowsAndLinux & TestCategory != RequiresOpenSsl3") - .Execute(); - }); + target => target.DependsOn(LinuxSpecificTestingWithoutOpenSsl3); [PublicAPI] - Target OncePerWindowsOrLinuxTesting => + Target LinuxSpecificTestingWithoutWithoutOpenSsl11OrOpenSsl3 => target => target .Executes(async () => { @@ -53,12 +45,12 @@ partial class Build CreateTestRun("Binaries/Calamari.Tests.dll") .WithDotNetPath(dotnetPath) - .WithFilter("(TestCategory != Windows & TestCategory != PlatformAgnostic) | TestCategory = RunOnceOnWindowsAndLinux") + .WithFilter("TestCategory != Windows & TestCategory != PlatformAgnostic & TestCategory != RunOnceOnWindowsAndLinux & TestCategory != RequiresOpenSsl1_1OrOpenSsl3") .Execute(); }); [PublicAPI] - Target OncePerWindowsOrLinuxTestingWithoutOpenSsl3 => + Target OncePerWindowsOrLinuxTesting => target => target .Executes(async () => { @@ -66,7 +58,7 @@ partial class Build CreateTestRun("Binaries/Calamari.Tests.dll") .WithDotNetPath(dotnetPath) - .WithFilter("((TestCategory != Windows & TestCategory != PlatformAgnostic) | TestCategory = RunOnceOnWindowsAndLinux) & TestCategory != RequiresOpenSsl3") + .WithFilter("(TestCategory != Windows & TestCategory != PlatformAgnostic) | TestCategory = RunOnceOnWindowsAndLinux") .Execute(); }); diff --git a/source/Calamari.Testing/Helpers/TestCategory.cs b/source/Calamari.Testing/Helpers/TestCategory.cs index 6874ef4f3f..7a24d51fe1 100644 --- a/source/Calamari.Testing/Helpers/TestCategory.cs +++ b/source/Calamari.Testing/Helpers/TestCategory.cs @@ -23,5 +23,7 @@ public static class CompatibleOS public const string PlatformAgnostic = "PlatformAgnostic"; public const string RunOnceOnWindowsAndLinux = "RunOnceOnWindowsAndLinux"; + + public const string RequiresOpenSsl1_1OrOpenSsl3 = "RequiresOpenSsl1_1OrOpenSsl3"; } } \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs index 7ae6e890db..615cdc2245 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionHelmTests.cs @@ -29,6 +29,7 @@ namespace Calamari.Tests.ArgoCD.Commands.Conventions { + [Category(TestCategory.RequiresOpenSsl1_1OrOpenSsl3)] public class UpdateArgoCDAppImagesInstallConventionHelmTests { const string ProjectSlug = "TheProject"; diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs index 3b1455b9a7..40bcc3480e 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDAppImagesInstallConventionTest.cs @@ -29,6 +29,7 @@ namespace Calamari.Tests.ArgoCD.Commands.Conventions { [TestFixture] + [Category(TestCategory.RequiresOpenSsl1_1OrOpenSsl3)] public class UpdateArgoCDAppImagesInstallConventionTests { const string ProjectSlug = "TheProject"; diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs index 0091c02709..6752018471 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs @@ -28,6 +28,7 @@ namespace Calamari.Tests.ArgoCD.Commands.Conventions { [TestFixture] + [Category(TestCategory.RequiresOpenSsl1_1OrOpenSsl3)] public class UpdateArgoCDApplicationManifestsInstallConventionTests { const string ProjectSlug = "TheProject"; diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs index 9bd6674763..990c037140 100644 --- a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -15,6 +15,7 @@ namespace Calamari.Tests.ArgoCD.Git; +[Category(TestCategory.RequiresOpenSsl1_1OrOpenSsl3)] public abstract class AuthenticatingRepositoryFactoryTestBase { protected readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs index ccf8c7a1c7..83e8c7b77f 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs @@ -17,6 +17,7 @@ namespace Calamari.Tests.ArgoCD.Git; [TestFixture] +[Category(TestCategory.RequiresOpenSsl1_1OrOpenSsl3)] public class GitHttpSmartSubTransportTests { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs index f2d722d409..1a170168c1 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs @@ -23,6 +23,7 @@ namespace Calamari.Tests.ArgoCD.Git.GitVendorApiAdapters { [TestFixture] + [Category(TestCategory.RequiresOpenSsl1_1OrOpenSsl3)] public class GitHubPullRequestClientTests { [Test] diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index 9aaced2755..8bf13b9128 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -17,6 +17,7 @@ namespace Calamari.Tests.ArgoCD.Git { [TestFixture] + [Category(TestCategory.RequiresOpenSsl1_1OrOpenSsl3)] public class RepositoryFactoryTests { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs index ac047c3448..192e53953e 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs @@ -19,6 +19,7 @@ namespace Calamari.Tests.ArgoCD.Git { [TestFixture] + [Category(TestCategory.RequiresOpenSsl1_1OrOpenSsl3)] public class RepositoryWrapperTest { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); From df74be6dbf2d8df7fedecf56c9fe5ee4a9016487 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 8 May 2026 15:20:36 +1000 Subject: [PATCH 20/39] Fix self ref --- build/Build.CalamariTesting.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Build.CalamariTesting.cs b/build/Build.CalamariTesting.cs index d349d276d8..c34b2adb47 100644 --- a/build/Build.CalamariTesting.cs +++ b/build/Build.CalamariTesting.cs @@ -34,7 +34,7 @@ partial class Build // Temporary so I can merge this PR without breaking Teamcity builds [PublicAPI] Target LinuxSpecificTestingWithoutOpenSsl3 => - target => target.DependsOn(LinuxSpecificTestingWithoutOpenSsl3); + target => target.DependsOn(LinuxSpecificTestingWithoutWithoutOpenSsl11OrOpenSsl3); [PublicAPI] Target LinuxSpecificTestingWithoutWithoutOpenSsl11OrOpenSsl3 => From 1918b750eec00f0de8a720b6650c336771fc74ce Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 8 May 2026 15:38:59 +1000 Subject: [PATCH 21/39] Merge conflict --- source/Calamari/CommitToGit/CommitToGitConfigFactory.cs | 4 ++-- source/Calamari/CommitToGit/CommitToGitRepositorySettings.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs index 41d8da5f1b..999e148e20 100644 --- a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs +++ b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs @@ -32,10 +32,10 @@ public CommitToGitRepositorySettings CreateRepositoryConfig(RunningDeployment de var commitParameters = new GitCommitParameters(summary, description, requiresPullRequest); return new CommitToGitRepositorySettings( - new GitConnection( + new HttpsGitConnection( variables.Get(SpecialVariables.Action.Git.Username), variables.Get(SpecialVariables.Action.Git.Password), - new Uri(uriAsString), + uriAsString, GitReference.CreateFromString(gitReferenceAsString)), commitParameters, variables.Get(SpecialVariables.Action.Git.DestinationPath)); diff --git a/source/Calamari/CommitToGit/CommitToGitRepositorySettings.cs b/source/Calamari/CommitToGit/CommitToGitRepositorySettings.cs index ae5d237cd5..5fd27ac192 100644 --- a/source/Calamari/CommitToGit/CommitToGitRepositorySettings.cs +++ b/source/Calamari/CommitToGit/CommitToGitRepositorySettings.cs @@ -6,14 +6,14 @@ namespace Calamari.CommitToGit { public class CommitToGitRepositorySettings { - public CommitToGitRepositorySettings(GitConnection gitConnection, GitCommitParameters commitParameters, string destinationPath) + public CommitToGitRepositorySettings(IGitConnection gitConnection, GitCommitParameters commitParameters, string destinationPath) { GitConnection = gitConnection; DestinationPath = destinationPath; CommitParameters = commitParameters; } - public GitConnection GitConnection { get; } + public IGitConnection GitConnection { get; } public string DestinationPath { get; } public GitCommitParameters CommitParameters { get; } From 0a0e91b278cbf8cd03c28d79ee18a4bbbd66094c Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 8 May 2026 16:38:12 +1000 Subject: [PATCH 22/39] Convert GitCredentialDto to IGitCredentialDto --- .../Variables/CustomPropertiesLoader.cs | 27 +++-- .../ArgoCD/ArgoCDCustomPropertiesDto.cs | 18 ++- ...CDCustomPropertiesDtoSerializationTests.cs | 110 ++++++++++++++++++ .../AuthenticatingRepositoryFactoryTests.cs | 108 +++++++++++++++++ .../ArgoCD/Git/GitCloneSafeUrlTests.cs | 6 +- .../Git/GitHttpSmartSubTransportTests.cs | 8 +- .../GitPullRequestClientResolverTests.cs | 18 +-- .../GitVendorApiAdapter_PullRequestTests.cs | 9 +- .../ArgoCD/Git/RepositoryFactoryTests.cs | 8 +- .../ArgoCD/Git/RepositoryWrapperTest.cs | 10 +- .../Commands/UpdateArgoCDAppImagesCommand.cs | 3 +- .../UpdateArgoCDAppManifestsCommand.cs | 3 +- .../UpdateArgoCDAppImagesInstallConvention.cs | 5 +- ...CDApplicationManifestsInstallConvention.cs | 4 +- .../Git/AuthenticatingRepositoryFactory.cs | 22 ++-- source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs | 6 +- source/Calamari/ArgoCD/Git/GitConnection.cs | 21 ++-- .../Git/IGitCredentialDtoJsonConverter.cs | 39 +++++++ .../GitVendorPullRequestClientResolver.cs | 16 ++- .../IGitVendorPullRequestClientFactory.cs | 2 +- .../PullRequests/StringExtensionMethods.cs | 18 +++ .../AzureDevOpsPullRequestClient.cs | 8 +- .../AzureDevOpsPullRequestClientFactory.cs | 2 +- .../BitBucket/BitBucketPullRequestClient.cs | 6 +- .../BitBucketPullRequestClientFactory.cs | 2 +- .../Vendors/GitHub/GitHubPullRequestClient.cs | 5 +- .../GitHub/GitHubPullRequestClientFactory.cs | 2 +- .../Vendors/GitLab/GitLabPullRequestClient.cs | 4 +- .../GitLab/GitLabPullRequestClientFactory.cs | 7 +- .../Calamari/ArgoCD/Git/RepositoryFactory.cs | 21 ++-- .../Calamari/ArgoCD/Git/RepositoryWrapper.cs | 10 +- .../CommitToGit/CommitToGitConfigFactory.cs | 4 +- .../CommitToGitRepositorySettings.cs | 4 +- 33 files changed, 425 insertions(+), 111 deletions(-) create mode 100644 source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs create mode 100644 source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs create mode 100644 source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs diff --git a/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs b/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs index 9c489a234c..2e4762de93 100644 --- a/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs +++ b/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs @@ -1,13 +1,8 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Security.Cryptography; using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Commands; -using Calamari.Common.Plumbing.Deployment.PackageRetention; using Calamari.Common.Plumbing.Extensions; using Calamari.Common.Plumbing.FileSystem; -using Calamari.Common.Plumbing.Logging; using Newtonsoft.Json; namespace Calamari.Common.Plumbing.Variables @@ -22,13 +17,23 @@ public class CustomPropertiesLoader : ICustomPropertiesLoader readonly ICalamariFileSystem fileSystem; readonly string customPropertiesFile; readonly string password; + readonly JsonSerializerSettings serializerSettings; - - public CustomPropertiesLoader(ICalamariFileSystem fileSystem, string customPropertiesFile, string password) + public CustomPropertiesLoader(ICalamariFileSystem fileSystem, string customPropertiesFile, string password, params JsonConverter[] converters) { this.fileSystem = fileSystem; this.customPropertiesFile = customPropertiesFile; this.password = password; + + serializerSettings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.None, + DateParseHandling = DateParseHandling.None, + }; + foreach (var converter in converters) + { + serializerSettings.Converters.Add(converter); + } } public T Load() @@ -37,7 +42,7 @@ public T Load() try { - return JsonConvert.DeserializeObject(json, SerializerSettings); + return JsonConvert.DeserializeObject(json, serializerSettings); } catch (JsonReaderException) { @@ -45,12 +50,6 @@ public T Load() } } - static readonly JsonSerializerSettings SerializerSettings = new JsonSerializerSettings - { - TypeNameHandling = TypeNameHandling.None, - DateParseHandling = DateParseHandling.None, - }; - static string Decrypt(byte[] encryptedJson, string encryptionPassword) { try diff --git a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs index c05d1c106f..47256f8628 100644 --- a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs +++ b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs @@ -2,7 +2,11 @@ namespace Octopus.Calamari.Contracts.ArgoCD; -public record ArgoCDCustomPropertiesDto(ArgoCDGatewayDto[] Gateways, ArgoCDApplicationDto[] Applications, GitCredentialDto[] Credentials); +public record ArgoCDCustomPropertiesDto( + ArgoCDGatewayDto[] Gateways, + ArgoCDApplicationDto[] Applications, + IGitCredentialDto[] Credentials +); public record ArgoCDGatewayDto(string Id, string Name); @@ -14,4 +18,14 @@ public record ArgoCDApplicationDto( string DefaultRegistry, string? InstanceWebUiUrl); -public record GitCredentialDto(string Url, string Username, string Password); +public interface IGitCredentialDto +{ + string Type { get; } + string Url { get; } +} + +// UsernamePasswordGitCredentialDto - could rename, but not worth altering the API +public record GitCredentialDto(string Url, string Username, string Password) : IGitCredentialDto +{ + public string Type => nameof(GitCredentialDto); +} \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs new file mode 100644 index 0000000000..d85bc22361 --- /dev/null +++ b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs @@ -0,0 +1,110 @@ +#nullable enable +using Calamari.ArgoCD.Git; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Octopus.Calamari.Contracts.ArgoCD; + +namespace Calamari.Tests.ArgoCD.Contracts; + +/// +/// Deserialisation tests for with a focus on +/// the polymorphic array. The converter discriminates on +/// the Type field emitted by Octopus Server (the concrete type name); a missing +/// Type defaults to for backwards compatibility. +/// +[TestFixture] +public class ArgoCDCustomPropertiesDtoSerializationTests +{ + static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.None, + DateParseHandling = DateParseHandling.None, + Converters = { new IGitCredentialDtoJsonConverter() } + }; + + static T DeserializeRaw(string json) + => JsonConvert.DeserializeObject(json, Settings)!; + + [Test] + public void TypeGitCredentialDto_DeserializesAsHttpsCredential() + { + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [ + { + "Type": "GitCredentialDto", + "Url": "https://github.com/org/repo.git", + "Username": "user", + "Password": "pass" + } + ] + } + """; + + var result = DeserializeRaw(json); + + result.Credentials.Should().HaveCount(1); + var credential = result.Credentials[0].Should().BeOfType().Subject; + credential.Url.Should().Be("https://github.com/org/repo.git"); + credential.Username.Should().Be("user"); + credential.Password.Should().Be("pass"); + } + + [Test] + public void MissingType_DefaultsToHttpsCredential() + { + // Backwards compatibility: older server versions don't emit the Type field. + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [ + { + "Url": "https://github.com/org/repo.git", + "Username": "user", + "Password": "pass" + } + ] + } + """; + + var result = DeserializeRaw(json); + + result.Credentials.Should().HaveCount(1); + result.Credentials[0].Should().BeOfType(); + } + + [Test] + public void UnknownType_Throws() + { + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [ + { "Type": "MysteryCredentialDto", "Url": "x" } + ] + } + """; + + var act = () => DeserializeRaw(json); + + act.Should().Throw() + .WithMessage("*MysteryCredentialDto*"); + } + + [Test] + public void EmptyCredentialsArray_Deserializes() + { + const string json = """ + { + "Gateways": [], "Applications": [], + "Credentials": [] + } + """; + + var result = DeserializeRaw(json); + + result.Credentials.Should().BeEmpty(); + } +} diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs new file mode 100644 index 0000000000..450d97a344 --- /dev/null +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Calamari.ArgoCD.Git; +using Calamari.ArgoCD.Git.PullRequests; +using Calamari.Common.Plumbing.FileSystem; +using Calamari.Integration.Time; +using Calamari.Testing.Helpers; +using Calamari.Tests.Fixtures.Integration.FileSystem; +using FluentAssertions; +using NUnit.Framework; +using Octopus.Calamari.Contracts.ArgoCD; + +namespace Calamari.Tests.ArgoCD.Git; + +[Category(TestCategory.RequiresOpenSsl3)] +public abstract class AuthenticatingRepositoryFactoryTestBase +{ + protected readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); + protected readonly GitBranchName branchName = GitBranchName.CreateFromFriendlyName("devBranch"); + + protected InMemoryLog log; + protected string tempDirectory; + protected string OriginPath => Path.Combine(tempDirectory, "origin"); + protected RepositoryFactory repositoryFactory; + + [SetUp] + public void Init() + { + log = new InMemoryLog(); + tempDirectory = fileSystem.CreateTemporaryDirectory(); + RepositoryHelpers.CreateBareRepository(OriginPath); + RepositoryHelpers.CreateBranchIn(branchName, OriginPath); + + repositoryFactory = new RepositoryFactory( + log, + fileSystem, + tempDirectory, + new GitVendorPullRequestClientResolver([]), + new SystemClock()); + } + + [TearDown] + public void Cleanup() + { + RepositoryHelpers.DeleteRepositoryDirectory(fileSystem, tempDirectory); + } + + [TestFixture] + public class HttpsUrlTests : AuthenticatingRepositoryFactoryTestBase + { + [Test] + public void HttpsCredentialIsSelectedWhenUrlMatchesHttpsCredential() + { + var httpsUrl = RepositoryHelpers.ToFileUri(OriginPath); + var factory = new AuthenticatingRepositoryFactory( + new Dictionary + { + [httpsUrl] = new GitCredentialDto(httpsUrl, "", "") + }, + repositoryFactory, + log); + + using var wrapper = factory.CloneRepository(httpsUrl, branchName.ToFriendlyName()); + wrapper.Should().NotBeNull(); + } + + [Test] + public void AnonymousCloneWhenNoCredentialsMatch() + { + var originUrl = RepositoryHelpers.ToFileUri(OriginPath); + var factory = new AuthenticatingRepositoryFactory( + new Dictionary(), + repositoryFactory, + log); + + using var wrapper = factory.CloneRepository(originUrl, branchName.ToFriendlyName()); + wrapper.Should().NotBeNull(); + log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); + } + } + + [TestFixture] + public class ScpStyleUrlTests : AuthenticatingRepositoryFactoryTestBase + { + [Test] + public void ScpStyleUrlDoesNotMatchHttpsCredential() + { + // An SCP-style URL should not accidentally match an HTTPS credential for the same host + var scpUrl = "git@github.com:org/repo.git"; + var httpsUrl = "https://github.com/org/repo.git"; + + var factory = new AuthenticatingRepositoryFactory( + new Dictionary + { + [httpsUrl] = new GitCredentialDto(httpsUrl, "user", "pass") + }, + repositoryFactory, + log); + + // This will fail to clone (no real repo at this URL) but we can verify it + // falls through to anonymous because the SCP URL doesn't match the HTTPS URL + var act = () => factory.CloneRepository(scpUrl, "main"); + act.Should().Throw(); // clone failure expected + log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); + } + } +} diff --git a/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs index de94f1156a..2f264149d6 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs @@ -13,7 +13,7 @@ public class GitCloneSafeUrlTests public void FromString_ShouldConvertGitScpAddressToUri(string scpAddress, string expectedUrl) { var result = GitCloneSafeUrl.FromString(scpAddress); - result.AbsoluteUri.Should().Be(expectedUrl); + result.Should().Be(expectedUrl); } [Test] @@ -21,7 +21,7 @@ public void FromString_ShouldReturnValidUriUnmodified() { var uri = "https://github.com/Foo/Bar.git"; var result = GitCloneSafeUrl.FromString(uri); - result.AbsoluteUri.Should().Be(uri); + result.Should().Be(uri); } [Test] @@ -37,6 +37,6 @@ public void ANonProtocoledString_AutomaticallyAddsOci() { var uri = "registry-1.docker.io/bitnamicharts"; var result = GitCloneSafeUrl.FromString(uri); - result.AbsoluteUri.Should().Be($"oci://{uri}"); + result.Should().Be($"oci://{uri}"); } } \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs index 6d26b541e5..f2a8f34efe 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitHttpSmartSubTransportTests.cs @@ -55,8 +55,8 @@ public void BasicAuthHeaderIsSentOnFirstRequest() var password = "testpassword"; var expectedAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); - var repoUrl = new Uri($"{server.Url}/fake-repo.git"); - var connection = new GitConnection(username, password, repoUrl, GitBranchName.CreateFromFriendlyName("main")); + var repoUrl = $"{server.Url}/fake-repo.git"; + var connection = new HttpsGitConnection(username, password, repoUrl, GitBranchName.CreateFromFriendlyName("main")); var repositoryFactory = new RepositoryFactory( log, fileSystem, @@ -83,8 +83,8 @@ public void BasicAuthHeaderIsSentOnFirstRequest() [Test] public void NoAuthHeaderIsSentWhenCredentialsAreNotProvided() { - var repoUrl = new Uri($"{server.Url}/fake-repo.git"); - var connection = new GitConnection(null, null, repoUrl, GitBranchName.CreateFromFriendlyName("main")); + var repoUrl = $"{server.Url}/fake-repo.git"; + var connection = new HttpsGitConnection(null, null, repoUrl, GitBranchName.CreateFromFriendlyName("main")); var repositoryFactory = new RepositoryFactory( log, fileSystem, diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs index 9b07e0f02c..73ca89625f 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs @@ -20,14 +20,14 @@ namespace Calamari.Tests.ArgoCD.Git.PullRequests; public class GitPullRequestClientResolverTests { ILog log; - IRepositoryConnection connection; + IHttpsGitConnection connection; MemoryCache cache; [SetUp] public void SetUp() { log = Substitute.For(); - connection = Substitute.For(); + connection = Substitute.For(); connection.Username.Returns("test-user"); connection.Password.Returns("test-token"); cache = new MemoryCache(new MemoryCacheOptions()); @@ -54,7 +54,7 @@ GitVendorPullRequestClientResolver CreateResolverWithAllRealFactories() [Test] public async Task GitHubUrl_ResolvesToGitHubClient() { - connection.Url.Returns(new Uri("https://github.com/org/repo")); + connection.Url.Returns("https://github.com/org/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -65,7 +65,7 @@ public async Task GitHubUrl_ResolvesToGitHubClient() [Test] public async Task GitLabCloudUrl_ResolvesToGitLabClient() { - connection.Url.Returns(new Uri("https://gitlab.com/org/repo")); + connection.Url.Returns("https://gitlab.com/org/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -76,7 +76,7 @@ public async Task GitLabCloudUrl_ResolvesToGitLabClient() [Test] public async Task AzureDevOpsUrl_ResolvesToAzureDevOpsClient() { - connection.Url.Returns(new Uri("https://dev.azure.com/org/project/_git/repo")); + connection.Url.Returns("https://dev.azure.com/org/project/_git/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -87,7 +87,7 @@ public async Task AzureDevOpsUrl_ResolvesToAzureDevOpsClient() [Test] public async Task BitBucketUrl_ResolvesToBitBucketClient() { - connection.Url.Returns(new Uri("https://bitbucket.org/org/repo")); + connection.Url.Returns("https://bitbucket.org/org/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -98,7 +98,7 @@ public async Task BitBucketUrl_ResolvesToBitBucketClient() [Test] public async Task UnrecognisedUrl_ReturnsNull() { - connection.Url.Returns(new Uri("https://someunknown.example/org/repo")); + connection.Url.Returns("https://someunknown.example/org/repo"); var resolver = new GitVendorPullRequestClientResolver(new IGitVendorPullRequestClientFactory[] { new NeverMatchesFactory() @@ -112,7 +112,7 @@ public async Task UnrecognisedUrl_ReturnsNull() [Test] public async Task SelfHostedUrl_WithMatchingSelfHostedFactory_ReturnsExpectedClient() { - connection.Url.Returns(new Uri("https://mygitlab.company.com/org/repo")); + connection.Url.Returns("https://mygitlab.company.com/org/repo"); var expectedClient = Substitute.For(); var factory = Substitute.For(); factory.CanHandleAsCloudHosted(Arg.Any()).Returns(false); @@ -131,6 +131,6 @@ class NeverMatchesFactory : IGitVendorPullRequestClientFactory public string Name => "NeverMatches"; public bool CanHandleAsCloudHosted(Uri repositoryUri) => false; public Task CanHandleAsSelfHosted(Uri repositoryUri, CancellationToken cancellationToken) => Task.FromResult(false); - public Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) => throw new NotImplementedException(); } } diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs index a9a61ad01b..7399cabdd5 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitVendorApiAdapter_PullRequestTests.cs @@ -99,11 +99,10 @@ await TestPullRequest(repositoryUrl, }); } - async Task TestPullRequest(string repositoryUrl, string defaultBranch, string cloneUsername, string clonePassword, Func createVendorApiAdapter) + async Task TestPullRequest(string repositoryUrl, string defaultBranch, string cloneUsername, string clonePassword, Func createVendorApiAdapter) { - using var temporaryFolder = TemporaryDirectory.Create(); - + CredentialsHandler credentialsHandler = (url, usernameFromUrl, types) => new UsernamePasswordCredentials { Username = cloneUsername, Password = clonePassword}; var repositoryPath = Repository.Clone(repositoryUrl, temporaryFolder.DirectoryPath, new CloneOptions() { @@ -120,8 +119,8 @@ async Task TestPullRequest(string repositoryUrl, string defaultBranch, string cl repository.Branches.Update(newBranch, branch => branch.Remote = remote.Name, branch => branch.UpstreamBranch = newBranch.CanonicalName); repository.Network.Push(newBranch, new PushOptions() { CredentialsProvider = credentialsHandler }); - var conn = Substitute.For(); - conn.Url.Returns(new Uri(repositoryUrl)); + var conn = Substitute.For(); + conn.Url.Returns(repositoryUrl); conn.Username.Returns(cloneUsername); conn.Password.Returns(clonePassword); try diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index 842f0252ce..783c4db9a9 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -50,9 +50,9 @@ public void Cleanup() [Test] public void ThrowsExceptionIfUrlDoesNotExist() { - var connection = new GitConnection("username", + var connection = new HttpsGitConnection("username", "password", - new Uri("file://doesNotExist"), + "file://doesNotExist", branchName); Action action = () => repositoryFactory.CloneRepository("name", connection); @@ -67,7 +67,7 @@ public void CanCloneAnExistingRepositoryWithExplicitBranchNameAndAssociatedFiles var originalContent = "This is the file content"; CreateCommitOnOrigin(branchName, filename, originalContent); - var connection = new GitConnection(null, null, new Uri(OriginPath), branchName); + var connection = new HttpsGitConnection(null, null, OriginPath, branchName); var clonedRepository = repositoryFactory.CloneRepository("CanCloneAnExistingRepository", connection); clonedRepository.Should().NotBeNull(); @@ -84,7 +84,7 @@ public void CanCloneAnExistingRepositoryAtHEADAndAssociatedFiles() var originalContent = "This is the file content"; CreateCommitOnOrigin(RepositoryHelpers.MainBranchName, filename, originalContent); - var connection = new GitConnection(null, null, new Uri(OriginPath), new GitHead()); + var connection = new HttpsGitConnection(null, null, OriginPath, new GitHead()); var clonedRepository = repositoryFactory.CloneRepository("CanCloneAnExistingRepository", connection); clonedRepository.Should().NotBeNull(); diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs index ef40c92ae4..c1991234da 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryWrapperTest.cs @@ -52,10 +52,10 @@ public void Init() Arg.Any(), Arg.Any()) .Returns(new PullRequest("title", 1, "url")); - gitVendorAgnosticPullRequestClientFactory.TryResolve(Arg.Any(), Arg.Any(), Arg.Any()).Returns(gitVendorPullRequestClient); - + gitVendorAgnosticPullRequestClientFactory.TryResolve(Arg.Any(), Arg.Any(), Arg.Any()).Returns(gitVendorPullRequestClient); + var repositoryFactory = new RepositoryFactory(log, fileSystem, tempDirectory, gitVendorAgnosticPullRequestClientFactory, new SystemClock()); - gitConnection = new GitConnection(null, null, new Uri(OriginPath), branchName); + gitConnection = new HttpsGitConnection(null, null, OriginPath, branchName); repository = repositoryFactory.CloneRepository(repositoryPath, gitConnection); } @@ -162,8 +162,8 @@ public void CloningAReferenceOtherThanABranchFails() bareOrigin.AddFilesToBranch(branchName, ("file.yaml", "")); bareOrigin.ApplyTag("1.0.0", bareOrigin.Head.Tip.Sha); - gitConnection = new GitConnection(null, null, new Uri(OriginPath), GitReference.CreateFromString("1.0.0")); - + gitConnection = new HttpsGitConnection(null, null, OriginPath, GitReference.CreateFromString("1.0.0")); + var repositoryFactory = new RepositoryFactory(log, fileSystem, tempDirectory, gitVendorAgnosticPullRequestClientFactory, new SystemClock()); var act = () => repositoryFactory.CloneRepository($"{repositoryPath}/sut", gitConnection); diff --git a/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppImagesCommand.cs b/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppImagesCommand.cs index 4c39556754..6eca24253b 100644 --- a/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppImagesCommand.cs +++ b/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppImagesCommand.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.IO; using Calamari.ArgoCD.Conventions; +using Calamari.ArgoCD.Git; using Calamari.ArgoCD.Git.PullRequests; using Calamari.Commands.Support; using Calamari.Common.Commands; @@ -56,7 +57,7 @@ public override int Execute(string[] commandLineArguments) new UpdateArgoCDAppImagesInstallConvention(log, fileSystem, configFactory, - new CustomPropertiesLoader(fileSystem, customPropertiesFile, customPropertiesPassword), + new CustomPropertiesLoader(fileSystem, customPropertiesFile, customPropertiesPassword, new IGitCredentialDtoJsonConverter()), new ArgoCdApplicationManifestParser(), gitVendorPullRequestClientResolver, clock, diff --git a/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppManifestsCommand.cs b/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppManifestsCommand.cs index 4a2a92ecff..efd146d6de 100644 --- a/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppManifestsCommand.cs +++ b/source/Calamari/ArgoCD/Commands/UpdateArgoCDAppManifestsCommand.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using Calamari.ArgoCD.Conventions; +using Calamari.ArgoCD.Git; using Calamari.ArgoCD.Git.PullRequests; using Calamari.Commands; using Calamari.Commands.Support; @@ -94,7 +95,7 @@ public override int Execute(string[] commandLineArguments) PackageDirectoryName, log, configFactory, - new CustomPropertiesLoader(fileSystem, customPropertiesFile, customPropertiesPassword), + new CustomPropertiesLoader(fileSystem, customPropertiesFile, customPropertiesPassword, new IGitCredentialDtoJsonConverter()), new ArgoCdApplicationManifestParser(), argoCDManifestsFileMatcher, gitVendorPullRequestClientResolver, diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs index f34dd68157..d57c53e389 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; using System.Linq; using Calamari.ArgoCD.Conventions.UpdateImageTag; using Calamari.ArgoCD.Git; @@ -60,7 +61,9 @@ public void Install(RunningDeployment deployment) clock); var argoProperties = customPropertiesLoader.Load(); - var gitCredentials = argoProperties.Credentials.ToDictionary(c => c.Url); + var gitCredentials = argoProperties.Credentials + .GroupBy(c => c.Url) + .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs index 9e40be4577..cbecb94f8a 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs @@ -69,7 +69,9 @@ public void Install(RunningDeployment deployment) var argoProperties = customPropertiesLoader.Load(); - var gitCredentials = argoProperties.Credentials.ToDictionary(c => c.Url); + var gitCredentials = argoProperties.Credentials + .GroupBy(c => c.Url) + .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index a5d7f3053f..98b2fbdbbf 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -6,27 +6,31 @@ namespace Calamari.ArgoCD.Git; public class AuthenticatingRepositoryFactory { - readonly Dictionary gitCredentials; - readonly RepositoryFactory repositoryFactory; + readonly Dictionary gitCredentials; + readonly IRepositoryFactory repositoryFactory; readonly ILog log; - - public AuthenticatingRepositoryFactory(Dictionary gitCredentials, RepositoryFactory repositoryFactory, ILog log) + public AuthenticatingRepositoryFactory( + Dictionary gitCredentials, + IRepositoryFactory repositoryFactory, + ILog log) { this.gitCredentials = gitCredentials; this.repositoryFactory = repositoryFactory; this.log = log; } - + public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevision) { var gitCredential = gitCredentials.GetValueOrDefault(requestedUrl); - if (gitCredential == null) + if (gitCredential is GitCredentialDto passwordCredential) { - log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); + var gitConnection = new HttpsGitConnection(passwordCredential.Username, passwordCredential.Password, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); + return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection); } - var gitConnection = new GitConnection(gitCredential?.Username, gitCredential?.Password, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); - return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection); + log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); + var anonGitConnection = new HttpsGitConnection(null, null, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); + return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), anonGitConnection); } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs b/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs index 65f2552dd0..205d0de1a7 100644 --- a/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs +++ b/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs @@ -29,7 +29,7 @@ public static class GitCloneSafeUrl /// This is invoked during yaml deserialisation, and may be applied to repoURLs which will never actually be cloned /// during step execution (eg sources which have not been scoped to the step). /// - public static Uri FromString(string uri) + public static string FromString(string uri) { if (!uri.StartsWith(StandardSshScpPrefix)) { @@ -38,7 +38,7 @@ public static Uri FromString(string uri) { uri = $"oci://{uri}"; } - return new Uri(uri); + return new Uri(uri).AbsoluteUri; } var scpAddress = uri.Substring(StandardSshScpPrefix.Length); @@ -55,6 +55,6 @@ public static Uri FromString(string uri) Host = host, Path = path }; - return uriBuilder.Uri; + return uriBuilder.Uri.AbsoluteUri; } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/GitConnection.cs b/source/Calamari/ArgoCD/Git/GitConnection.cs index d2dacd5df6..771891f52e 100644 --- a/source/Calamari/ArgoCD/Git/GitConnection.cs +++ b/source/Calamari/ArgoCD/Git/GitConnection.cs @@ -1,23 +1,26 @@ #nullable enable -using System; namespace Calamari.ArgoCD.Git { public interface IRepositoryConnection { - public string? Username { get; } - public string? Password { get; } - public Uri Url { get; } + public string Url { get; } } - + public interface IGitConnection : IRepositoryConnection { - public GitReference GitReference { get; } + public GitReference GitReference { get; } + } + + public interface IHttpsGitConnection : IGitConnection + { + string? Username { get; } + string? Password { get; } } - public class GitConnection : IGitConnection + public class HttpsGitConnection : IHttpsGitConnection { - public GitConnection(string? username, string? password, Uri url, GitReference gitReference) + public HttpsGitConnection(string? username, string? password, string url, GitReference gitReference) { Username = username; Password = password; @@ -27,7 +30,7 @@ public GitConnection(string? username, string? password, Uri url, GitReference g public string? Username { get; } public string? Password { get; } - public Uri Url { get; } + public string Url { get; } public GitReference GitReference { get; } } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs b/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs new file mode 100644 index 0000000000..4e0c54655c --- /dev/null +++ b/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs @@ -0,0 +1,39 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Octopus.Calamari.Contracts.ArgoCD; + +namespace Calamari.ArgoCD.Git; + +/// +/// Discriminates an on the Type field emitted by Octopus +/// Server (matching the concrete type name). A missing Type defaults to +/// for backwards compatibility with server versions that pre-date +/// the field. +/// +public class IGitCredentialDtoJsonConverter : JsonConverter +{ + static readonly JsonSerializer ConcreteSerializer = new(); + + public override bool CanWrite => false; + + public override void WriteJson(JsonWriter writer, IGitCredentialDto? value, JsonSerializer serializer) + => throw new NotSupportedException(); + + public override IGitCredentialDto ReadJson( + JsonReader reader, + Type objectType, + IGitCredentialDto? existingValue, + bool hasExistingValue, + JsonSerializer serializer) + { + var obj = JObject.Load(reader); + var type = obj["Type"]?.Value(); + + return type switch + { + null or nameof(GitCredentialDto) => obj.ToObject(ConcreteSerializer)!, + _ => throw new JsonSerializationException($"Unrecognised credential Type '{type}'.") + }; + } +} diff --git a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs index bcbda328f1..cc5579a9c3 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs @@ -9,7 +9,7 @@ namespace Calamari.ArgoCD.Git.PullRequests { public interface IGitVendorPullRequestClientResolver { - Task TryResolve(IRepositoryConnection repositoryConnection, ILog log, + Task TryResolve(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken); } @@ -22,18 +22,24 @@ public GitVendorPullRequestClientResolver(IEnumerable TryResolve(IRepositoryConnection repositoryConnection, ILog log, - CancellationToken cancellationToken) + public async Task TryResolve(IHttpsGitConnection repositoryConnection, ILog log, + CancellationToken cancellationToken) { + if (!Uri.TryCreate(repositoryConnection.Url, UriKind.Absolute, out var repositoryUri)) + { + log.Verbose($"Could not load a Git vendor: URL is not a valid URI: '{repositoryConnection.Url}'"); + return null; + } + //first try getting a handling factory by checking if it can be handled as a cloud hosted repo - var handlingFactory = clientFactories.SingleOrDefault(f => f.CanHandleAsCloudHosted(repositoryConnection.Url)); + var handlingFactory = clientFactories.SingleOrDefault(f => f.CanHandleAsCloudHosted(repositoryUri)); //if we still don't have a handling factory, try the self-hosted checks. if (handlingFactory is null) { foreach (var clientFactory in clientFactories) { - if (!await clientFactory.CanHandleAsSelfHosted(repositoryConnection.Url, cancellationToken)) + if (!await clientFactory.CanHandleAsSelfHosted(repositoryUri, cancellationToken)) { continue; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs index 2a61c4e559..e6e81d10c0 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/IGitVendorPullRequestClientFactory.cs @@ -26,6 +26,6 @@ async Task CanHandleAsSelfHosted(Uri repositoryUri, CancellationToken canc return false; } - Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken); + Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken); } } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/StringExtensionMethods.cs b/source/Calamari/ArgoCD/Git/PullRequests/StringExtensionMethods.cs index d75f75be32..53e64e6fff 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/StringExtensionMethods.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/StringExtensionMethods.cs @@ -13,6 +13,24 @@ public static string StripGitSuffix(this string url) return url; } + /// + /// Parses a repository URL string into a . Pull request clients require + /// HTTPS URLs for REST API calls — SCP-style SSH URLs (e.g. git@host:path) are not valid URIs. + /// The guards against this by returning null + /// for non-URI URLs, but this method provides a clear error if one slips through. + /// + public static Uri ParseAsHttpsUri(this string repositoryUrl) + { + if (!Uri.TryCreate(repositoryUrl, UriKind.Absolute, out var uri)) + { + throw new InvalidOperationException( + $"Pull request operations require an HTTPS repository URL, but got: '{repositoryUrl}'. " + + "SCP-style SSH URLs (e.g. git@github.com:org/repo.git) are not supported for pull request creation."); + } + + return uri; + } + // This extension method is here until we can drop netfx and put it into the interface public static string[] ExtractPropertiesFromUrlPath(this Uri repositoryUri) { diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs index 57d09eb0dc..ab3a239fd7 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs @@ -13,9 +13,9 @@ public class AzureDevOpsPullRequestClient : IGitVendorPullRequestClient { const string CloudHost = "dev.azure.com"; - readonly IRepositoryConnection repositoryConnection; + readonly IHttpsGitConnection repositoryConnection; - public AzureDevOpsPullRequestClient(IRepositoryConnection repositoryConnection) + public AzureDevOpsPullRequestClient(IHttpsGitConnection repositoryConnection) { this.repositoryConnection = repositoryConnection; } @@ -32,7 +32,7 @@ public async Task CreatePullRequest(string pullRequestTitle, Convert.ToBase64String(Encoding.ASCII.GetBytes($"{repositoryConnection.Username}:{repositoryConnection.Password}"))); - var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url); + var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url.ParseAsHttpsUri()); var apiUrl = $"https://{CloudHost}/{organizationName}/{projectName}/_apis/git/repositories/{repositoryName}/pullrequests?api-version=7.1"; var pullRequest = new @@ -63,7 +63,7 @@ public async Task CreatePullRequest(string pullRequestTitle, public string GenerateCommitUrl(string commit) { - var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url); + var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url.ParseAsHttpsUri()); return $"https://{CloudHost}/{organizationName}/{projectName}/_git/{repositoryName}/commit/{commit}"; } } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs index f6aa2594e2..3d330c1e2a 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClientFactory.cs @@ -11,7 +11,7 @@ public class AzureDevOpsPullRequestClientFactory : IGitVendorPullRequestClientFa public bool CanHandleAsCloudHosted(Uri repositoryUri) => AzureDevOpsRepositoryUriParser.IsAzureDevOpsRepository(repositoryUri); - public async Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken) + public async Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { await Task.CompletedTask; return new AzureDevOpsPullRequestClient(repositoryConnection); diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs index 622797cdb4..399f56d1bf 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs @@ -11,17 +11,17 @@ namespace Calamari.ArgoCD.Git.PullRequests.Vendors.BitBucket { public class BitBucketPullRequestClient : IGitVendorPullRequestClient { - readonly IRepositoryConnection repositoryConnection; + readonly IHttpsGitConnection repositoryConnection; readonly Uri baseUrl; readonly string workspace; readonly string repositorySlug; - public BitBucketPullRequestClient(IRepositoryConnection repositoryConnection, Uri baseUrl) + public BitBucketPullRequestClient(IHttpsGitConnection repositoryConnection, Uri baseUrl) { this.repositoryConnection = repositoryConnection; this.baseUrl = baseUrl; - var parts = repositoryConnection.Url.ExtractPropertiesFromUrlPath(); + var parts = repositoryConnection.Url.ParseAsHttpsUri().ExtractPropertiesFromUrlPath(); workspace = parts[0]; repositorySlug = parts[1]; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs index d4e4670ad3..7ebe86876d 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClientFactory.cs @@ -15,7 +15,7 @@ public bool CanHandleAsCloudHosted(Uri repositoryUri) return repositoryUri.Host.Equals(baseUrl.Host, StringComparison.OrdinalIgnoreCase); } - public async Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken) + public async Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { await Task.CompletedTask; return new BitBucketPullRequestClient(repositoryConnection, baseUrl); diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs index abad36002b..39095ffde1 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs @@ -3,7 +3,6 @@ using System.Threading; using System.Threading.Tasks; using Octokit; -using PullRequest = Calamari.ArgoCD.Git.PullRequests.PullRequest; namespace Calamari.ArgoCD.Git.PullRequests.Vendors.GitHub { @@ -14,12 +13,12 @@ public class GitHubPullRequestClient: IGitVendorPullRequestClient readonly string repoOwner; readonly string repoName; - public GitHubPullRequestClient(IGitHubClient client, IRepositoryConnection repositoryConnection, Uri baseUrl) + public GitHubPullRequestClient(IGitHubClient client, IHttpsGitConnection repositoryConnection, Uri baseUrl) { this.client = client; this.baseUrl = baseUrl; - var parts = repositoryConnection.Url.ExtractPropertiesFromUrlPath(); + var parts = repositoryConnection.Url.ParseAsHttpsUri().ExtractPropertiesFromUrlPath(); repoOwner = parts[0]; repoName = parts[1]; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs index bb476b9072..1c1fb6cd42 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClientFactory.cs @@ -16,7 +16,7 @@ public class GitHubPullRequestClientFactory: IGitVendorPullRequestClientFactory public bool CanHandleAsCloudHosted(Uri repositoryUri) => GitHubRepositoryUriParser.IsGitHub(repositoryUri); - public async Task Create(IRepositoryConnection repositoryConnection, ILog log, CancellationToken cancellationToken) + public async Task Create(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { await Task.CompletedTask; diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs index 6168387ad0..7d02696c99 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs @@ -13,12 +13,12 @@ public class GitLabPullRequestClient : IGitVendorPullRequestClient readonly Uri baseUrl; readonly string projectPath; - public GitLabPullRequestClient(GitLabClient gitLabClient, IRepositoryConnection repositoryConnection, Uri baseUrl) + public GitLabPullRequestClient(GitLabClient gitLabClient, IHttpsGitConnection repositoryConnection, Uri baseUrl) { this.gitLabClient = gitLabClient; this.baseUrl = baseUrl; - var parts = repositoryConnection.Url.ExtractPropertiesFromUrlPath(); + var parts = repositoryConnection.Url.ParseAsHttpsUri().ExtractPropertiesFromUrlPath(); projectPath = $"{parts[^2]}/{parts[^1]}"; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs index 151629be52..24a343dc97 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs @@ -21,14 +21,15 @@ public async Task CanHandleAsSelfHosted(Uri repositoryUri, CancellationTok return await selfHostedGitLabInspector.IsSelfHostedGitLabInstance(repositoryUri, cancellationToken); } - public async Task Create(IRepositoryConnection repositoryConnection, ILog taskLog, + public async Task Create(IHttpsGitConnection repositoryConnection, ILog taskLog, CancellationToken cancellationToken) { await Task.CompletedTask; //if we aren't cloud hosted, we must be self-hosted - var host = CanHandleAsCloudHosted(repositoryConnection.Url) + var repositoryUri = repositoryConnection.Url.ParseAsHttpsUri(); + var host = CanHandleAsCloudHosted(repositoryUri) ? CloudHost - : SelfHostedGitLabInspector.GetSelfHostedBaseRepositoryUrl(repositoryConnection.Url); + : SelfHostedGitLabInspector.GetSelfHostedBaseRepositoryUrl(repositoryUri); var client = new GitLabClient(host, repositoryConnection.Password); return new GitLabPullRequestClient(client, repositoryConnection, new Uri(host)); diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index 0ada687dd6..8a1d72b38e 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -66,12 +66,12 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che BranchName = (gitConnection.GitReference as GitBranchName)?.ToFriendlyName() }; - if (gitConnection.Username != null && gitConnection.Password != null) + if (gitConnection is HttpsGitConnection { Username: not null, Password: not null } https) { - options.FetchOptions.CredentialsProvider = (url, usernameFromUrl, types) => new UsernamePasswordCredentials + options.FetchOptions.CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials { - Username = gitConnection.Username!, - Password = gitConnection.Password! + Username = https.Username, + Password = https.Password }; } @@ -81,7 +81,7 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che { try { - repoPath = Repository.Clone(gitConnection.Url.AbsoluteUri, checkoutPath, options); + repoPath = Repository.Clone(gitConnection.Url, checkoutPath, options); timedOp.Complete(); } catch (Exception e) @@ -100,16 +100,16 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che //this is required to handle the issue around "HEAD" var branchToCheckout = repo.GetBranchName(gitConnection.GitReference); var remoteBranch = repo.Branches.First(f => f.IsRemote && f.UpstreamBranchCanonicalName == branchToCheckout.Value); - + log.VerboseFormat("Checking out '{0}' @ {1}", branchToCheckout, remoteBranch.Tip.Sha.Substring(0, 10)); - + //A local branch is required such that libgit2sharp can create "tracking" data // libgit2sharp does not support pushing from a detached head if (repo.Branches[branchToCheckout.Value] == null) { repo.CreateBranch(branchToCheckout.Value, remoteBranch.Tip); } - + LibGit2Sharp.Commands.Checkout(repo, branchToCheckout.ToFriendlyName()); } catch (LibGit2SharpException e) @@ -118,7 +118,10 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che } //TODO(tmm): Make this function (and all callers async). - var gitVendorApiAdapter = gitVendorPullRequestClientResolver.TryResolve(gitConnection, log, CancellationToken.None).Result; + var gitVendorApiAdapter = gitConnection is HttpsGitConnection httpsGitConnection + ? gitVendorPullRequestClientResolver.TryResolve(httpsGitConnection, log, CancellationToken.None).Result + : null; + return new RepositoryWrapper(repo, fileSystem, checkoutPath, diff --git a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs index beb229e432..26a27919ef 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs @@ -33,12 +33,16 @@ public class RepositoryWrapper( readonly IGitVendorPullRequestClient? vendorApiAdapter = vendorApiAdapter; readonly IClock clock = clock; // ReSharper restore ReplaceWithPrimaryConstructorParameter - + readonly Identity repositoryIdentity = new("Octopus", "octopus@octopus.com"); public string WorkingDirectory => repository.Info.WorkingDirectory; - Credentials RepositoryCredentials => new UsernamePasswordCredentials { Username = connection.Username, Password = connection.Password }; + Credentials RepositoryCredentials => connection switch + { + HttpsGitConnection https => new UsernamePasswordCredentials { Username = https.Username, Password = https.Password }, + _ => null + }; // returns true if changes were made to the repository public bool CommitChanges(string summary, string description) @@ -140,7 +144,7 @@ public async Task PushChanges(bool requiresPullRequest, commit.Sha, commit.ShortSha(), commit.Author.When, - connection.Url.AbsoluteUri, + connection.Url, title, uri, number); diff --git a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs index 41d8da5f1b..999e148e20 100644 --- a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs +++ b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs @@ -32,10 +32,10 @@ public CommitToGitRepositorySettings CreateRepositoryConfig(RunningDeployment de var commitParameters = new GitCommitParameters(summary, description, requiresPullRequest); return new CommitToGitRepositorySettings( - new GitConnection( + new HttpsGitConnection( variables.Get(SpecialVariables.Action.Git.Username), variables.Get(SpecialVariables.Action.Git.Password), - new Uri(uriAsString), + uriAsString, GitReference.CreateFromString(gitReferenceAsString)), commitParameters, variables.Get(SpecialVariables.Action.Git.DestinationPath)); diff --git a/source/Calamari/CommitToGit/CommitToGitRepositorySettings.cs b/source/Calamari/CommitToGit/CommitToGitRepositorySettings.cs index ae5d237cd5..5fd27ac192 100644 --- a/source/Calamari/CommitToGit/CommitToGitRepositorySettings.cs +++ b/source/Calamari/CommitToGit/CommitToGitRepositorySettings.cs @@ -6,14 +6,14 @@ namespace Calamari.CommitToGit { public class CommitToGitRepositorySettings { - public CommitToGitRepositorySettings(GitConnection gitConnection, GitCommitParameters commitParameters, string destinationPath) + public CommitToGitRepositorySettings(IGitConnection gitConnection, GitCommitParameters commitParameters, string destinationPath) { GitConnection = gitConnection; DestinationPath = destinationPath; CommitParameters = commitParameters; } - public GitConnection GitConnection { get; } + public IGitConnection GitConnection { get; } public string DestinationPath { get; } public GitCommitParameters CommitParameters { get; } From 2a2a169c754a7c55047187c0f2a54115962890dc Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 8 May 2026 16:53:22 +1000 Subject: [PATCH 23/39] Decouple type name from contract --- .../Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs | 3 ++- .../ArgoCDCustomPropertiesDtoSerializationTests.cs | 2 +- .../Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs | 7 +++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs index 47256f8628..38544da907 100644 --- a/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs +++ b/source/Calamari.Contracts/ArgoCD/ArgoCDCustomPropertiesDto.cs @@ -27,5 +27,6 @@ public interface IGitCredentialDto // UsernamePasswordGitCredentialDto - could rename, but not worth altering the API public record GitCredentialDto(string Url, string Username, string Password) : IGitCredentialDto { - public string Type => nameof(GitCredentialDto); + public const string DiscriminatorValue = "UsernamePassword"; + public string Type => DiscriminatorValue; } \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs index d85bc22361..089b7d6897 100644 --- a/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs +++ b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs @@ -34,7 +34,7 @@ public void TypeGitCredentialDto_DeserializesAsHttpsCredential() "Gateways": [], "Applications": [], "Credentials": [ { - "Type": "GitCredentialDto", + "Type": "UsernamePassword", "Url": "https://github.com/org/repo.git", "Username": "user", "Password": "pass" diff --git a/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs b/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs index 4e0c54655c..b26babda67 100644 --- a/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs +++ b/source/Calamari/ArgoCD/Git/IGitCredentialDtoJsonConverter.cs @@ -7,9 +7,8 @@ namespace Calamari.ArgoCD.Git; /// /// Discriminates an on the Type field emitted by Octopus -/// Server (matching the concrete type name). A missing Type defaults to -/// for backwards compatibility with server versions that pre-date -/// the field. +/// Server. A missing Type defaults to for backwards +/// compatibility with server versions that pre-date the field. /// public class IGitCredentialDtoJsonConverter : JsonConverter { @@ -32,7 +31,7 @@ public override IGitCredentialDto ReadJson( return type switch { - null or nameof(GitCredentialDto) => obj.ToObject(ConcreteSerializer)!, + null or GitCredentialDto.DiscriminatorValue => obj.ToObject(ConcreteSerializer)!, _ => throw new JsonSerializationException($"Unrecognised credential Type '{type}'.") }; } From 323d0a60e333171a9538e414d0887ab8987745c5 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Mon, 11 May 2026 09:20:27 +1000 Subject: [PATCH 24/39] Push Uri into HttpsGitConnection --- .../UpdateArgoCDAppImagesInstallConvention.cs | 2 ++ ...oCDApplicationManifestsInstallConvention.cs | 1 + source/Calamari/ArgoCD/Git/GitConnection.cs | 18 ++++++++++++++++++ .../GitVendorPullRequestClientResolver.cs | 2 +- .../Git/PullRequests/StringExtensionMethods.cs | 18 ------------------ .../AzureDevOpsPullRequestClient.cs | 4 ++-- .../BitBucket/BitBucketPullRequestClient.cs | 2 +- .../Vendors/GitHub/GitHubPullRequestClient.cs | 2 +- .../Vendors/GitLab/GitLabPullRequestClient.cs | 2 +- .../GitLab/GitLabPullRequestClientFactory.cs | 5 ++--- 10 files changed, 29 insertions(+), 27 deletions(-) diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs index d57c53e389..9e28628355 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs @@ -61,6 +61,8 @@ public void Install(RunningDeployment deployment) clock); var argoProperties = customPropertiesLoader.Load(); + + // Takes the first git credential per URL, with a preference for username/password credentials (they are more broadly useful) var gitCredentials = argoProperties.Credentials .GroupBy(c => c.Url) .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs index cbecb94f8a..f51fb53ed1 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs @@ -69,6 +69,7 @@ public void Install(RunningDeployment deployment) var argoProperties = customPropertiesLoader.Load(); + // Takes the first git credential per URL, with a preference for username/password credentials (they are more broadly useful) var gitCredentials = argoProperties.Credentials .GroupBy(c => c.Url) .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); diff --git a/source/Calamari/ArgoCD/Git/GitConnection.cs b/source/Calamari/ArgoCD/Git/GitConnection.cs index 771891f52e..0c4527a3da 100644 --- a/source/Calamari/ArgoCD/Git/GitConnection.cs +++ b/source/Calamari/ArgoCD/Git/GitConnection.cs @@ -1,5 +1,7 @@ #nullable enable +using System; + namespace Calamari.ArgoCD.Git { public interface IRepositoryConnection @@ -16,6 +18,8 @@ public interface IHttpsGitConnection : IGitConnection { string? Username { get; } string? Password { get; } + + public Lazy Uri { get; } } public class HttpsGitConnection : IHttpsGitConnection @@ -32,5 +36,19 @@ public HttpsGitConnection(string? username, string? password, string url, GitRef public string? Password { get; } public string Url { get; } public GitReference GitReference { get; } + + public Lazy Uri => new(() => ParseAsHttpsUri(Url)); + + static Uri ParseAsHttpsUri(string repositoryUrl) + { + if (!System.Uri.TryCreate(repositoryUrl, UriKind.Absolute, out var uri)) + { + throw new InvalidOperationException( + $"Pull request operations require an HTTPS repository URL, but got: '{repositoryUrl}'. " + + "SCP-style SSH URLs (e.g. git@github.com:org/repo.git) are not supported for pull request creation."); + } + + return uri; + } } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs index cc5579a9c3..9aeb371158 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs @@ -27,7 +27,7 @@ public GitVendorPullRequestClientResolver(IEnumerable - /// Parses a repository URL string into a . Pull request clients require - /// HTTPS URLs for REST API calls — SCP-style SSH URLs (e.g. git@host:path) are not valid URIs. - /// The guards against this by returning null - /// for non-URI URLs, but this method provides a clear error if one slips through. - /// - public static Uri ParseAsHttpsUri(this string repositoryUrl) - { - if (!Uri.TryCreate(repositoryUrl, UriKind.Absolute, out var uri)) - { - throw new InvalidOperationException( - $"Pull request operations require an HTTPS repository URL, but got: '{repositoryUrl}'. " + - "SCP-style SSH URLs (e.g. git@github.com:org/repo.git) are not supported for pull request creation."); - } - - return uri; - } // This extension method is here until we can drop netfx and put it into the interface public static string[] ExtractPropertiesFromUrlPath(this Uri repositoryUri) diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs index ab3a239fd7..01893d3085 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/AzureDevOps/AzureDevOpsPullRequestClient.cs @@ -32,7 +32,7 @@ public async Task CreatePullRequest(string pullRequestTitle, Convert.ToBase64String(Encoding.ASCII.GetBytes($"{repositoryConnection.Username}:{repositoryConnection.Password}"))); - var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url.ParseAsHttpsUri()); + var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Uri.Value); var apiUrl = $"https://{CloudHost}/{organizationName}/{projectName}/_apis/git/repositories/{repositoryName}/pullrequests?api-version=7.1"; var pullRequest = new @@ -63,7 +63,7 @@ public async Task CreatePullRequest(string pullRequestTitle, public string GenerateCommitUrl(string commit) { - var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Url.ParseAsHttpsUri()); + var (organizationName, projectName, repositoryName) = AzureDevOpsRepositoryUriParser.Parse(repositoryConnection.Uri.Value); return $"https://{CloudHost}/{organizationName}/{projectName}/_git/{repositoryName}/commit/{commit}"; } } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs index 399f56d1bf..0c86af235d 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/BitBucket/BitBucketPullRequestClient.cs @@ -21,7 +21,7 @@ public BitBucketPullRequestClient(IHttpsGitConnection repositoryConnection, Uri this.repositoryConnection = repositoryConnection; this.baseUrl = baseUrl; - var parts = repositoryConnection.Url.ParseAsHttpsUri().ExtractPropertiesFromUrlPath(); + var parts = repositoryConnection.Uri.Value.ExtractPropertiesFromUrlPath(); workspace = parts[0]; repositorySlug = parts[1]; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs index 39095ffde1..1a151804e1 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitHub/GitHubPullRequestClient.cs @@ -18,7 +18,7 @@ public GitHubPullRequestClient(IGitHubClient client, IHttpsGitConnection reposit this.client = client; this.baseUrl = baseUrl; - var parts = repositoryConnection.Url.ParseAsHttpsUri().ExtractPropertiesFromUrlPath(); + var parts = repositoryConnection.Uri.Value.ExtractPropertiesFromUrlPath(); repoOwner = parts[0]; repoName = parts[1]; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs index 7d02696c99..0c3b141d72 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClient.cs @@ -18,7 +18,7 @@ public GitLabPullRequestClient(GitLabClient gitLabClient, IHttpsGitConnection re this.gitLabClient = gitLabClient; this.baseUrl = baseUrl; - var parts = repositoryConnection.Url.ParseAsHttpsUri().ExtractPropertiesFromUrlPath(); + var parts = repositoryConnection.Uri.Value.ExtractPropertiesFromUrlPath(); projectPath = $"{parts[^2]}/{parts[^1]}"; } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs index 24a343dc97..cd377d4eb2 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/Vendors/GitLab/GitLabPullRequestClientFactory.cs @@ -26,10 +26,9 @@ public async Task Create(IHttpsGitConnection reposi { await Task.CompletedTask; //if we aren't cloud hosted, we must be self-hosted - var repositoryUri = repositoryConnection.Url.ParseAsHttpsUri(); - var host = CanHandleAsCloudHosted(repositoryUri) + var host = CanHandleAsCloudHosted(repositoryConnection.Uri.Value) ? CloudHost - : SelfHostedGitLabInspector.GetSelfHostedBaseRepositoryUrl(repositoryUri); + : SelfHostedGitLabInspector.GetSelfHostedBaseRepositoryUrl(repositoryConnection.Uri.Value); var client = new GitLabClient(host, repositoryConnection.Password); return new GitLabPullRequestClient(client, repositoryConnection, new Uri(host)); From 2af45f9831d75d394e0aa1b6c9c7ed0a8623c169 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Mon, 11 May 2026 09:45:02 +1000 Subject: [PATCH 25/39] Fix tests --- .../GitPullRequestClientResolverTests.cs | 18 ++++++++++++------ source/Calamari/ArgoCD/Git/GitConnection.cs | 3 ++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs index 73ca89625f..23f7d15b47 100644 --- a/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/PullRequests/GitPullRequestClientResolverTests.cs @@ -39,6 +39,12 @@ public void TearDown() cache.Dispose(); } + void ConfigureConnection(string url) + { + connection.Url.Returns(url); + connection.Uri.Returns(new Lazy(() => new Uri(url))); + } + GitVendorPullRequestClientResolver CreateResolverWithAllRealFactories() { var inspector = new SelfHostedGitLabInspector(cache); @@ -54,7 +60,7 @@ GitVendorPullRequestClientResolver CreateResolverWithAllRealFactories() [Test] public async Task GitHubUrl_ResolvesToGitHubClient() { - connection.Url.Returns("https://github.com/org/repo"); + ConfigureConnection("https://github.com/org/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -65,7 +71,7 @@ public async Task GitHubUrl_ResolvesToGitHubClient() [Test] public async Task GitLabCloudUrl_ResolvesToGitLabClient() { - connection.Url.Returns("https://gitlab.com/org/repo"); + ConfigureConnection("https://gitlab.com/org/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -76,7 +82,7 @@ public async Task GitLabCloudUrl_ResolvesToGitLabClient() [Test] public async Task AzureDevOpsUrl_ResolvesToAzureDevOpsClient() { - connection.Url.Returns("https://dev.azure.com/org/project/_git/repo"); + ConfigureConnection("https://dev.azure.com/org/project/_git/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -87,7 +93,7 @@ public async Task AzureDevOpsUrl_ResolvesToAzureDevOpsClient() [Test] public async Task BitBucketUrl_ResolvesToBitBucketClient() { - connection.Url.Returns("https://bitbucket.org/org/repo"); + ConfigureConnection("https://bitbucket.org/org/repo"); var resolver = CreateResolverWithAllRealFactories(); var client = await resolver.TryResolve(connection, log, CancellationToken.None); @@ -98,7 +104,7 @@ public async Task BitBucketUrl_ResolvesToBitBucketClient() [Test] public async Task UnrecognisedUrl_ReturnsNull() { - connection.Url.Returns("https://someunknown.example/org/repo"); + ConfigureConnection("https://someunknown.example/org/repo"); var resolver = new GitVendorPullRequestClientResolver(new IGitVendorPullRequestClientFactory[] { new NeverMatchesFactory() @@ -112,7 +118,7 @@ public async Task UnrecognisedUrl_ReturnsNull() [Test] public async Task SelfHostedUrl_WithMatchingSelfHostedFactory_ReturnsExpectedClient() { - connection.Url.Returns("https://mygitlab.company.com/org/repo"); + ConfigureConnection("https://mygitlab.company.com/org/repo"); var expectedClient = Substitute.For(); var factory = Substitute.For(); factory.CanHandleAsCloudHosted(Arg.Any()).Returns(false); diff --git a/source/Calamari/ArgoCD/Git/GitConnection.cs b/source/Calamari/ArgoCD/Git/GitConnection.cs index 0c4527a3da..54ba139abe 100644 --- a/source/Calamari/ArgoCD/Git/GitConnection.cs +++ b/source/Calamari/ArgoCD/Git/GitConnection.cs @@ -30,6 +30,7 @@ public HttpsGitConnection(string? username, string? password, string url, GitRef Password = password; Url = url; GitReference = gitReference; + Uri = new Lazy(() => ParseAsHttpsUri(Url)); } public string? Username { get; } @@ -37,7 +38,7 @@ public HttpsGitConnection(string? username, string? password, string url, GitRef public string Url { get; } public GitReference GitReference { get; } - public Lazy Uri => new(() => ParseAsHttpsUri(Url)); + public Lazy Uri { get; } static Uri ParseAsHttpsUri(string repositoryUrl) { From d66d72b7bff4c4c6a8aaadce1d2d96ab326bd863 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Mon, 11 May 2026 17:29:02 +1000 Subject: [PATCH 26/39] Test exclusion --- source/Calamari.Tests/CommitToGitCommandTest.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/source/Calamari.Tests/CommitToGitCommandTest.cs b/source/Calamari.Tests/CommitToGitCommandTest.cs index da94fbb6cd..fb303ca50f 100644 --- a/source/Calamari.Tests/CommitToGitCommandTest.cs +++ b/source/Calamari.Tests/CommitToGitCommandTest.cs @@ -18,6 +18,7 @@ namespace Calamari.Tests; [TestFixture] [Category(TestCategory.CompatibleOS.OnlyNixOrMac)] +[Category(TestCategory.RequiresOpenSsl1_1OrOpenSsl3)] public class CommitToGitCommandTest { readonly ICalamariFileSystem fileSystem = TestCalamariPhysicalFileSystem.GetPhysicalFileSystem(); From 9f1334d1fab1f03ecd1469a4c78998cb3c1fa0e7 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 13 May 2026 10:31:28 +1000 Subject: [PATCH 27/39] Review comments --- .../Plumbing/Variables/CustomPropertiesLoader.cs | 7 +++---- source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs | 8 ++++---- .../ArgoCD/Git/AuthenticatingRepositoryFactory.cs | 4 ++-- source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs | 2 +- source/Calamari/ArgoCD/Git/GitConnection.cs | 6 +++++- .../PullRequests/GitVendorPullRequestClientResolver.cs | 2 ++ 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs b/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs index 2e4762de93..83deb21db5 100644 --- a/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs +++ b/source/Calamari.Common/Plumbing/Variables/CustomPropertiesLoader.cs @@ -4,6 +4,7 @@ using Calamari.Common.Plumbing.Extensions; using Calamari.Common.Plumbing.FileSystem; using Newtonsoft.Json; +using NuGet.Packaging; namespace Calamari.Common.Plumbing.Variables { @@ -30,10 +31,8 @@ public CustomPropertiesLoader(ICalamariFileSystem fileSystem, string customPrope TypeNameHandling = TypeNameHandling.None, DateParseHandling = DateParseHandling.None, }; - foreach (var converter in converters) - { - serializerSettings.Converters.Add(converter); - } + + serializerSettings.Converters.AddRange(converters); } public T Load() diff --git a/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs index 2f264149d6..2e1c4a1d7a 100644 --- a/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/GitCloneSafeUrlTests.cs @@ -12,7 +12,7 @@ public class GitCloneSafeUrlTests [TestCase("git@bitbucket.com:FooBar.git", "https://bitbucket.com/FooBar.git")] public void FromString_ShouldConvertGitScpAddressToUri(string scpAddress, string expectedUrl) { - var result = GitCloneSafeUrl.FromString(scpAddress); + var result = GitCloneSafeUrl.ConvertToUriString(scpAddress); result.Should().Be(expectedUrl); } @@ -20,7 +20,7 @@ public void FromString_ShouldConvertGitScpAddressToUri(string scpAddress, string public void FromString_ShouldReturnValidUriUnmodified() { var uri = "https://github.com/Foo/Bar.git"; - var result = GitCloneSafeUrl.FromString(uri); + var result = GitCloneSafeUrl.ConvertToUriString(uri); result.Should().Be(uri); } @@ -28,7 +28,7 @@ public void FromString_ShouldReturnValidUriUnmodified() public void FromString_ShouldThrowInvalidGitScpAddress() { var uri = "git@ihavenopath.com"; - var func = () => GitCloneSafeUrl.FromString(uri); + var func = () => GitCloneSafeUrl.ConvertToUriString(uri); func.Should().Throw(); } @@ -36,7 +36,7 @@ public void FromString_ShouldThrowInvalidGitScpAddress() public void ANonProtocoledString_AutomaticallyAddsOci() { var uri = "registry-1.docker.io/bitnamicharts"; - var result = GitCloneSafeUrl.FromString(uri); + var result = GitCloneSafeUrl.ConvertToUriString(uri); result.Should().Be($"oci://{uri}"); } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index 98b2fbdbbf..716be8a57d 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -25,12 +25,12 @@ public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevis var gitCredential = gitCredentials.GetValueOrDefault(requestedUrl); if (gitCredential is GitCredentialDto passwordCredential) { - var gitConnection = new HttpsGitConnection(passwordCredential.Username, passwordCredential.Password, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); + var gitConnection = new HttpsGitConnection(passwordCredential.Username, passwordCredential.Password, GitCloneSafeUrl.ConvertToUriString(requestedUrl), GitReference.CreateFromString(targetRevision)); return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection); } log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); - var anonGitConnection = new HttpsGitConnection(null, null, GitCloneSafeUrl.FromString(requestedUrl), GitReference.CreateFromString(targetRevision)); + var anonGitConnection = new HttpsGitConnection(null, null, GitCloneSafeUrl.ConvertToUriString(requestedUrl), GitReference.CreateFromString(targetRevision)); return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), anonGitConnection); } } \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs b/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs index 205d0de1a7..256d5a8658 100644 --- a/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs +++ b/source/Calamari/ArgoCD/Git/GitCloneSafeUrl.cs @@ -29,7 +29,7 @@ public static class GitCloneSafeUrl /// This is invoked during yaml deserialisation, and may be applied to repoURLs which will never actually be cloned /// during step execution (eg sources which have not been scoped to the step). /// - public static string FromString(string uri) + public static string ConvertToUriString(string uri) { if (!uri.StartsWith(StandardSshScpPrefix)) { diff --git a/source/Calamari/ArgoCD/Git/GitConnection.cs b/source/Calamari/ArgoCD/Git/GitConnection.cs index 54ba139abe..6c1b1ac687 100644 --- a/source/Calamari/ArgoCD/Git/GitConnection.cs +++ b/source/Calamari/ArgoCD/Git/GitConnection.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using Calamari.Common.Commands; namespace Calamari.ArgoCD.Git { @@ -19,6 +20,9 @@ public interface IHttpsGitConnection : IGitConnection string? Username { get; } string? Password { get; } + // Resolved as lazy because we don't have a strong trust that the input data is of the correct format + // If this is _not_ a URI, the existing code would throw an error when it gets used - so we want to + // replicate that same lazy error throwing. public Lazy Uri { get; } } @@ -44,7 +48,7 @@ static Uri ParseAsHttpsUri(string repositoryUrl) { if (!System.Uri.TryCreate(repositoryUrl, UriKind.Absolute, out var uri)) { - throw new InvalidOperationException( + throw new CommandException( $"Pull request operations require an HTTPS repository URL, but got: '{repositoryUrl}'. " + "SCP-style SSH URLs (e.g. git@github.com:org/repo.git) are not supported for pull request creation."); } diff --git a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs index 9aeb371158..70ade4abee 100644 --- a/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs +++ b/source/Calamari/ArgoCD/Git/PullRequests/GitVendorPullRequestClientResolver.cs @@ -25,6 +25,8 @@ public GitVendorPullRequestClientResolver(IEnumerable TryResolve(IHttpsGitConnection repositoryConnection, ILog log, CancellationToken cancellationToken) { + // Avoid using repositoryConnection.Uri here as we do not want to throw if we somehow got here without a + // valid Uri - if we can gather confidence that this is impossible then we could remove this guard if (!Uri.TryCreate(repositoryConnection.Url, UriKind.Absolute, out var repositoryUri)) { log.Verbose($"Could not load a Git vendor: URL is not a valid URI '{repositoryConnection.Url}'"); From 9308011b62ecb92099e118751dabae9998fb78c1 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 13 May 2026 10:44:42 +1000 Subject: [PATCH 28/39] Fix merge error --- .../CommitToGit/CommitToGitConfigFactoryTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs b/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs index 5342594168..6960ade56f 100644 --- a/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs +++ b/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs @@ -1,4 +1,5 @@ using System; +using Calamari.ArgoCD.Git; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Variables; using Calamari.CommitToGit; @@ -44,8 +45,10 @@ public void CreateRepositoryConfig_UsesUsernameAndPasswordFromLoadedProperties() var config = factory.CreateRepositoryConfig(deployment, loader); - config.GitConnection.Username.Should().Be("user-from-file"); - config.GitConnection.Password.Should().Be("pwd-from-file"); - config.GitConnection.Url.Should().Be(new Uri("https://example.invalid/repo.git")); + var httpsGitConnection = config.GitConnection as HttpsGitConnection; + httpsGitConnection.Should().NotBeNull(); + httpsGitConnection!.Username.Should().Be("user-from-file"); + httpsGitConnection.Password.Should().Be("pwd-from-file"); + httpsGitConnection.Uri.Value.Should().Be(new Uri("https://example.invalid/repo.git")); } } From e1170ab3c7afeefbe720cc6c9870918e40dd12cc Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 13 May 2026 11:46:39 +1000 Subject: [PATCH 29/39] Update Octopus.LibGit2Sharp --- source/Calamari/Calamari.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari/Calamari.csproj b/source/Calamari/Calamari.csproj index cd134e56b2..9c3440e9ea 100644 --- a/source/Calamari/Calamari.csproj +++ b/source/Calamari/Calamari.csproj @@ -51,7 +51,7 @@ - + From 03c1cf1004ee73a46a5e2365df8e62ff428b5e1a Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 13 May 2026 11:47:32 +1000 Subject: [PATCH 30/39] Fix test --- .../CommitToGit/CommitToGitConfigFactoryTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs b/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs index 5342594168..df73bf2e37 100644 --- a/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs +++ b/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs @@ -1,4 +1,5 @@ using System; +using Calamari.ArgoCD.Git; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Variables; using Calamari.CommitToGit; @@ -44,8 +45,10 @@ public void CreateRepositoryConfig_UsesUsernameAndPasswordFromLoadedProperties() var config = factory.CreateRepositoryConfig(deployment, loader); - config.GitConnection.Username.Should().Be("user-from-file"); - config.GitConnection.Password.Should().Be("pwd-from-file"); - config.GitConnection.Url.Should().Be(new Uri("https://example.invalid/repo.git")); + var httpsGitConnection = config.GitConnection as HttpsGitConnection; + httpsGitConnection.Should().NotBeNull(); + httpsGitConnection!.Username.Should().Be("user-from-file"); + httpsGitConnection.Password.Should().Be("pwd-from-file"); + httpsGitConnection.Url.Should().Be("https://example.invalid/repo.git"); } } From 64b36f0a7eb27ed9b14e56d0290e54b27bd34306 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 13 May 2026 18:15:14 +1000 Subject: [PATCH 31/39] Self review --- ...licationManifestsInstallConventionTests.cs | 76 ------------------- ...CDCustomPropertiesDtoSerializationTests.cs | 6 +- .../AuthenticatingRepositoryFactoryTests.cs | 47 +++++------- .../LibGit2SharpTransportRegistrationTests.cs | 32 ++++++++ .../UpdateArgoCDAppImagesInstallConvention.cs | 6 +- ...CDApplicationManifestsInstallConvention.cs | 6 +- .../Git/AuthenticatingRepositoryFactory.cs | 25 ++++-- .../Git/LibGit2SharpTransportRegistration.cs | 23 +++--- 8 files changed, 88 insertions(+), 133 deletions(-) create mode 100644 source/Calamari.Tests/ArgoCD/Git/LibGit2SharpTransportRegistrationTests.cs diff --git a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs index 6752018471..702956e6ee 100644 --- a/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs +++ b/source/Calamari.Tests/ArgoCD/Commands/Conventions/UpdateArgoCDApplicationManifestsInstallConventionTests.cs @@ -646,82 +646,6 @@ public void PurgeDoesNotRemoveFilesThatExistInBothRepoAndTemplates() AssertOutputVariables(); } - [Test] - public void ExecuteCopiesFilesWhenUsingSshCredentials() - { - // Arrange — reconfigure the custom properties to use SSH credentials - // instead of HTTPS, using the raw local path as the URL (mimicking an SCP-style - // URL that bypasses GitCloneSafeUrl) - var argoCdCustomPropertiesDto = new ArgoCDCustomPropertiesDto( - [ - new ArgoCDGatewayDto(GatewayId, "Gateway1") - ], - [ - new ArgoCDApplicationDto(GatewayId, - "App1", - "argocd", - "yaml", - "docker.io", - "http://my-argo.com") - ], - [ - new SshKeyGitCredentialDto(RepoUrl, "git", "private-key") - ]); - customPropertiesLoader.Load().Returns(argoCdCustomPropertiesDto); - - // The application source URL must match the SSH credential URL - var argoCdApplicationFromYaml = new ArgoCDApplicationBuilder() - .WithName("App1") - .WithAnnotations(new Dictionary() - { - [ArgoCDConstants.Annotations.OctopusProjectAnnotationKey(null)] = ProjectSlug, - [ArgoCDConstants.Annotations.OctopusEnvironmentAnnotationKey(null)] = EnvironmentSlug, - }) - .WithSource(new ApplicationSource() - { - OriginalRepoUrl = RepoUrl, - Path = "", - TargetRevision = ArgoCDBranchFriendlyName, - }, - SourceTypeConstants.Directory) - .Build(); - - argoCdApplicationManifestParser.ParseManifest(Arg.Any()) - .Returns(argoCdApplicationFromYaml); - - const string firstFilename = "first.yaml"; - CreateFileUnderPackageDirectory(firstFilename); - - var nonSensitiveCalamariVariables = new NonSensitiveCalamariVariables() - { - [KnownVariables.OriginalPackageDirectoryPath] = WorkingDirectory, - [SpecialVariables.Git.InputPath] = "", - [SpecialVariables.Git.CommitMethod] = "DirectCommit", - [SpecialVariables.Git.CommitMessageSummary] = "Octopus did this via SSH", - [ProjectVariables.Slug] = ProjectSlug, - [DeploymentEnvironment.Slug] = EnvironmentSlug, - }; - var allVariables = new CalamariVariables(); - allVariables.Merge(nonSensitiveCalamariVariables); - - var runningDeployment = new RunningDeployment("./arbitraryFile.txt", allVariables); - runningDeployment.CurrentDirectoryProvider = DeploymentWorkingDirectory.StagingDirectory; - runningDeployment.StagingDirectory = WorkingDirectory; - - // Act - var convention = CreateConvention(nonSensitiveCalamariVariables); - convention.Install(runningDeployment); - - // Assert - var resultPath = RepositoryHelpers.CloneOrigin(tempDirectory, OriginPath, argoCDBranchName); - File.Exists(Path.Combine(resultPath, firstFilename)).Should().BeTrue(); - var resultContent = File.ReadAllText(Path.Combine(resultPath, firstFilename)); - resultContent.Should().Be(firstFilename); - - using var resultRepo = new Repository(resultPath); - resultRepo.Head.Tip.Message.TrimEnd().Should().Be("Octopus did this via SSH"); - } - void AssertOutputVariables(bool updated = true, string matchingApplicationTotalSourceCounts = "1") { using var _ = new AssertionScope(); diff --git a/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs index 697ccde009..482bc4b90b 100644 --- a/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs +++ b/source/Calamari.Tests/ArgoCD/Contracts/ArgoCDCustomPropertiesDtoSerializationTests.cs @@ -63,8 +63,7 @@ public void TypeSshKey_DeserializesAsSshCredential() "Type": "SshKey", "Url": "git@github.com:org/repo.git", "Username": "git", - "PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----", - "Passphrase": "my-passphrase" + "PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----" } ] } @@ -96,8 +95,7 @@ public void MixedArray_BothTypesPreserved() "Type": "SshKey", "Url": "git@github.com:org/other.git", "Username": "git", - "PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----", - "Passphrase": null + "PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----" } ] } diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs index e1959872b5..b87092fdd8 100644 --- a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.IO; -using System.Linq; using Calamari.ArgoCD.Git; using Calamari.ArgoCD.Git.PullRequests; using Calamari.Common.Plumbing.FileSystem; @@ -56,10 +54,7 @@ public void HttpsCredentialIsSelectedWhenUrlMatchesHttpsCredential() { var httpsUrl = RepositoryHelpers.ToFileUri(OriginPath); var factory = new AuthenticatingRepositoryFactory( - new Dictionary - { - [httpsUrl] = new GitCredentialDto(httpsUrl, "", "") - }, + [new GitCredentialDto(httpsUrl, "", "")], repositoryFactory, log); @@ -72,7 +67,7 @@ public void AnonymousCloneWhenNoCredentialsMatch() { var originUrl = RepositoryHelpers.ToFileUri(OriginPath); var factory = new AuthenticatingRepositoryFactory( - new Dictionary(), + [], repositoryFactory, log); @@ -86,19 +81,24 @@ public void AnonymousCloneWhenNoCredentialsMatch() public class SshUrlTests : AuthenticatingRepositoryFactoryTestBase { [Test] - public void SshCredentialIsSelectedWhenUrlMatchesSshCredential() + public void SshCredentialBranch_IsSelectedAndDispatchesSshGitConnection() { + // Use an ssh:// URL so the new strict validation allows it, and mock the factory + // so no real SSH connection is attempted. + const string sshUrl = "ssh://git@github.com/org/repo.git"; + var mockRepoFactory = Substitute.For(); + var factory = new AuthenticatingRepositoryFactory( - new Dictionary - { - // Use the local path as the SSH credential URL so the clone actually works - [OriginPath] = new SshKeyGitCredentialDto(OriginPath, "git", "private-key") - }, - repositoryFactory, + [new SshKeyGitCredentialDto(sshUrl, "git", "private-key")], + mockRepoFactory, log); - using var wrapper = factory.CloneRepository(OriginPath, branchName.ToFriendlyName()); - wrapper.Should().NotBeNull(); + factory.CloneRepository(sshUrl, branchName.ToFriendlyName()); + + mockRepoFactory.Received() + .CloneRepository( + Arg.Any(), + Arg.Is(c => c is SshGitConnection)); } [Test] @@ -119,10 +119,7 @@ public void ScpStyleUrlDoesNotMatchHttpsCredential() var httpsUrl = "https://github.com/org/repo.git"; var factory = new AuthenticatingRepositoryFactory( - new Dictionary - { - [httpsUrl] = new GitCredentialDto(httpsUrl, "user", "pass") - }, + [new GitCredentialDto(httpsUrl, "user", "pass")], repositoryFactory, log); @@ -150,14 +147,8 @@ protected void AssertHttpsCredentialTakesPriorityOverSsh(string url) new GitCredentialDto(url, "https-user", "https-pass"), new SshKeyGitCredentialDto(url, "ssh-user", "private-key") ]; - var credentialDictionary = rawCredentials - .GroupBy(c => c.Url) - .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); - - // The HTTPS credential must have been selected by the GroupBy rule. - credentialDictionary[url].Should().BeOfType("HTTPS credentials take priority over SSH for the same URL"); - var factory = new AuthenticatingRepositoryFactory(credentialDictionary, mockRepoFactory, log); + var factory = new AuthenticatingRepositoryFactory(rawCredentials, mockRepoFactory, log); factory.CloneRepository(url, "main"); @@ -166,4 +157,4 @@ protected void AssertHttpsCredentialTakesPriorityOverSsh(string url) Arg.Any(), Arg.Is(c => c is HttpsGitConnection)); } -} +} \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Git/LibGit2SharpTransportRegistrationTests.cs b/source/Calamari.Tests/ArgoCD/Git/LibGit2SharpTransportRegistrationTests.cs new file mode 100644 index 0000000000..cbdc765dd9 --- /dev/null +++ b/source/Calamari.Tests/ArgoCD/Git/LibGit2SharpTransportRegistrationTests.cs @@ -0,0 +1,32 @@ +using System; +using Calamari.ArgoCD.Git; +using Calamari.Common.Commands; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.Tests.ArgoCD.Git; + +[TestFixture] +public class LibGit2SharpTransportRegistrationTests +{ + [Test] + public void RegisterWith_WhenDelegateThrowsTypeInitializationExceptionWithDllNotFoundException_ThrowsCommandExceptionWithOpenSslGuidance() + { + var dllNotFoundException = new DllNotFoundException("libcrypto.so.3"); + var typeInitEx = new TypeInitializationException("SomeType", dllNotFoundException); + + Action act = () => LibGit2SharpTransportRegistration.RegisterWith(() => throw typeInitEx); + + act.Should().Throw() + .WithMessage("*OpenSSL 3*") + .WithMessage("*end-of-life*"); + } + + [Test] + public void RegisterWith_WhenDelegateSucceeds_ReturnsTrue() + { + var result = LibGit2SharpTransportRegistration.RegisterWith(() => { }); + + result.Should().BeTrue(); + } +} diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs index 9e28628355..04fcc55a49 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDAppImagesInstallConvention.cs @@ -62,11 +62,7 @@ public void Install(RunningDeployment deployment) var argoProperties = customPropertiesLoader.Load(); - // Takes the first git credential per URL, with a preference for username/password credentials (they are more broadly useful) - var gitCredentials = argoProperties.Credentials - .GroupBy(c => c.Url) - .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); - var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, repositoryFactory, log); + var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(argoProperties.Credentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); log.LogApplicationCounts(deploymentScope, argoProperties.Applications); diff --git a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs index f51fb53ed1..00de914204 100644 --- a/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs +++ b/source/Calamari/ArgoCD/Conventions/UpdateArgoCDApplicationManifestsInstallConvention.cs @@ -69,11 +69,7 @@ public void Install(RunningDeployment deployment) var argoProperties = customPropertiesLoader.Load(); - // Takes the first git credential per URL, with a preference for username/password credentials (they are more broadly useful) - var gitCredentials = argoProperties.Credentials - .GroupBy(c => c.Url) - .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); - var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(gitCredentials, repositoryFactory, log); + var authenticatingRepositoryFactory = new AuthenticatingRepositoryFactory(argoProperties.Credentials, repositoryFactory, log); var deploymentScope = deployment.Variables.GetDeploymentScope(); log.LogApplicationCounts(deploymentScope, argoProperties.Applications); diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index 9191c94d8e..f0cd7d616d 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using Calamari.Common.Plumbing.Logging; using Octopus.Calamari.Contracts.ArgoCD; @@ -11,11 +13,15 @@ public class AuthenticatingRepositoryFactory readonly ILog log; public AuthenticatingRepositoryFactory( - Dictionary gitCredentials, + IReadOnlyCollection gitCredentials, IRepositoryFactory repositoryFactory, ILog log) { - this.gitCredentials = gitCredentials; + // Takes the first git credential per URL, with a preference for username/password credentials (they are more broadly useful as they can be used for PR creation) + this.gitCredentials = gitCredentials + .GroupBy(c => c.Url) + .ToDictionary(g => g.Key, g => g.OfType().FirstOrDefault() ?? g.First()); + this.repositoryFactory = repositoryFactory; this.log = log; } @@ -39,12 +45,19 @@ public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevis sshCredential.PrivateKey); return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), sshConnection); } + case null: + { + log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); + break; + } + default: + { + log.Warn($"An unrecognised credential type '{gitCredential.GetType().Name}' was found for '{requestedUrl}'. Ignoring the credentials and attempting an anonymous clone."); + break; + } } - log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); - // SCP-style URLs (git@github.com:org/repo.git) are rewritten to HTTPS by GitCloneSafeUrl. - // Anonymous HTTPS clone may fail with 401/404, which is confusing for SSH-only repos. var anonGitConnection = new HttpsGitConnection(null, null, GitCloneSafeUrl.ConvertToUriString(requestedUrl), GitReference.CreateFromString(targetRevision)); return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), anonGitConnection); } -} +} \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs b/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs index f61a81ed9a..60b8547573 100644 --- a/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs +++ b/source/Calamari/ArgoCD/Git/LibGit2SharpTransportRegistration.cs @@ -8,10 +8,10 @@ namespace Calamari.ArgoCD.Git; /// Lazily registers custom smart sub-transports for libgit2sharp so that the native /// library is only loaded when git operations are actually needed, rather than during /// startup. -/// The only reason not to do it during startup is that we have a new dependency on -/// OpenSSL3 that older (now unsupported) OS versions may not fulfill. Instead of -/// breaking everyone if they are running older systems, we will only break them -/// if they use git functionality. +/// This class supports workers that have either OpenSSL 3 (libcrypto.so.3) or +/// OpenSSL 1.1 (libcrypto.so.1.1) installed. When neither is found, a +/// is raised with a +/// user-actionable message explaining what to install. /// static class LibGit2SharpTransportRegistration { @@ -19,20 +19,25 @@ static class LibGit2SharpTransportRegistration public static void EnsureRegistered() => _ = Registered.Value; - static bool Register() + static bool Register() => RegisterWith(() => + { + GlobalSettings.RegisterSmartSubtransport("http"); + GlobalSettings.RegisterSmartSubtransport("https"); + }); + + internal static bool RegisterWith(Action registerTransports) { try { - GlobalSettings.RegisterSmartSubtransport("http"); - GlobalSettings.RegisterSmartSubtransport("https"); + registerTransports(); } catch (TypeInitializationException ex) when (ex.InnerException is DllNotFoundException dllEx) { var message = $""" Failed to load the native libgit2 library required for Git operations. - On Linux, libgit2 requires either OpenSSL 3 (libcrypto.so.3) or OpenSSL 1.1 (libcrypto.so.1.1) to be installed on the worker. - Please install one of them according to your distributions guidance or update to a supported OS. + On Linux, libgit2 requires OpenSSL 3 (libcrypto.so.3). Install it according to your distribution's guidance or update to a supported OS. + If you are running a legacy distribution that does not provide OpenSSL 3, OpenSSL 1.1 (libcrypto.so.1.1) may be used as a transitional fallback, but note that OpenSSL 1.1 is end-of-life and should not be relied upon long-term. Original exception: {dllEx.Message} From 9512a96c96d217ac5862e10082b5e8fa88f8fd7a Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 15 May 2026 15:18:53 +1000 Subject: [PATCH 32/39] Pull in ext method for creating the credential handler --- ...SharpCredentialsHandlerExtensionMethods.cs | 60 +++++++++++++++++++ .../Calamari/ArgoCD/Git/RepositoryWrapper.cs | 11 +--- 2 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs diff --git a/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs b/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs new file mode 100644 index 0000000000..22994faaed --- /dev/null +++ b/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs @@ -0,0 +1,60 @@ +using System; +using System.Net; +using LibGit2Sharp; +using LibGit2Sharp.Handlers; + +namespace Calamari.ArgoCD.Git; + +public static class LibGit2SharpCredentialsHandlerExtensionMethods +{ + public static CredentialsHandler ToLibGit2SharpCredentialHandler(this IGitConnection? connection) + { + return connection switch + { + HttpsGitConnection https => UsernamePassword(https), + SshGitConnection sshKey => SshKey(sshKey), + null => Anonymous(), + _ => throw new NotSupportedException(), + }; + } + + static CredentialsHandler Anonymous() + { + return null!; // A null CredentialsHandler is valid for LibGit2Sharp + } + + static CredentialsHandler SshKey(SshGitConnection connection) + { + return (_, userFromUrl, types) => + { + if (!types.HasFlag(SupportedCredentialTypes.SshMemory)) + { + throw new InvalidOperationException("SSH key credentials provided but are not supported by this endpoint."); + } + + return new SshKeyMemoryCredentials + { + Username = connection.Username ?? userFromUrl, + PrivateKey = connection.PrivateKey, + }; + }; + } + + static CredentialsHandler UsernamePassword(HttpsGitConnection connection) + { + return (_, _, types) => + { + if (!types.HasFlag(SupportedCredentialTypes.UsernamePassword)) + { + throw new InvalidOperationException("Username/password credentials provided but are not supported by this endpoint."); + } + + var securePassword = new NetworkCredential(string.Empty, connection.Password).SecurePassword; + return new SecureUsernamePasswordCredentials + { + Username = connection.Username, + Password = securePassword, + }; + }; + } +} \ No newline at end of file diff --git a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs index 2b4b9cfc49..ed98886625 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs @@ -38,13 +38,6 @@ public class RepositoryWrapper( public string WorkingDirectory => repository.Info.WorkingDirectory; - Credentials RepositoryCredentials => connection switch - { - SshGitConnection ssh => new SshKeyMemoryCredentials { Username = ssh.Username, PrivateKey = ssh.PrivateKey }, - HttpsGitConnection https => new UsernamePasswordCredentials { Username = https.Username, Password = https.Password }, - _ => null - }; - // returns true if changes were made to the repository public bool CommitChanges(string summary, string description) { @@ -198,7 +191,7 @@ public void PushChanges(GitBranchName branchName) PushStatusError? errorsDetected = null; var pushOptions = new PushOptions { - CredentialsProvider = (url, usernameFromUrl, types) => RepositoryCredentials, + CredentialsProvider = connection.ToLibGit2SharpCredentialHandler(), OnPushStatusError = errors => errorsDetected = errors, CertificateCheck = connection is SshGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null }; @@ -216,7 +209,7 @@ void FetchAndRebase(GitBranchName branchName) var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToList(); var fetchOptions = new FetchOptions { - CredentialsProvider = (url, usernameFromUrl, types) => RepositoryCredentials, + CredentialsProvider = connection.ToLibGit2SharpCredentialHandler(), CertificateCheck = connection is SshGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null }; From ee1837c672279897babed7f4a40ac90ce5d863c8 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Fri, 15 May 2026 15:39:48 +1000 Subject: [PATCH 33/39] Update to release LibGit2Sharp --- source/Calamari/Calamari.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari/Calamari.csproj b/source/Calamari/Calamari.csproj index 9c3440e9ea..7e90632feb 100644 --- a/source/Calamari/Calamari.csproj +++ b/source/Calamari/Calamari.csproj @@ -51,7 +51,7 @@ - + From aebbbaa87371adf2b64e8e788c64ad0bf9898ef8 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Mon, 18 May 2026 16:54:27 +1000 Subject: [PATCH 34/39] Update LibGit2Sharp (branch build w/ mac fix) --- source/Calamari/Calamari.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari/Calamari.csproj b/source/Calamari/Calamari.csproj index 7e90632feb..da6a365123 100644 --- a/source/Calamari/Calamari.csproj +++ b/source/Calamari/Calamari.csproj @@ -51,7 +51,7 @@ - + From 9a1154ab4c1dba11e7f973517390d5e03a642da9 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Tue, 19 May 2026 11:58:21 +1000 Subject: [PATCH 35/39] Handle CommitToGit changes --- source/Calamari.Contracts/Git/GitCredentials.cs | 2 +- .../ArgoCD/Git/AuthenticatingRepositoryFactory.cs | 4 ++-- source/Calamari/ArgoCD/Git/GitConnection.cs | 10 +++++----- .../Calamari/CommitToGit/CommitToGitConfigFactory.cs | 11 ++++++----- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/source/Calamari.Contracts/Git/GitCredentials.cs b/source/Calamari.Contracts/Git/GitCredentials.cs index f25b0263c4..d7e752e320 100644 --- a/source/Calamari.Contracts/Git/GitCredentials.cs +++ b/source/Calamari.Contracts/Git/GitCredentials.cs @@ -13,7 +13,7 @@ public record UsernamePasswordGitCredentialDto(string Name, string Url, string U public string Type => DiscriminatorValue; } -public record SshKeyGitCredentialDto(string Name, string Url) +public record SshKeyGitCredentialDto(string Name, string Url, string Username, string PrivateKey) : IGitCredentialDto { public const string DiscriminatorValue = "SshKey"; public string Type => DiscriminatorValue; diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index f0cd7d616d..5f4eba8a4e 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -41,8 +41,8 @@ public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevis var sshConnection = new SshGitConnection( sshCredential.Username, requestedUrl, - GitReference.CreateFromString(targetRevision), - sshCredential.PrivateKey); + sshCredential.PrivateKey, + GitReference.CreateFromString(targetRevision)); return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), sshConnection); } case null: diff --git a/source/Calamari/ArgoCD/Git/GitConnection.cs b/source/Calamari/ArgoCD/Git/GitConnection.cs index 408fcc87c6..fefac3a39b 100644 --- a/source/Calamari/ArgoCD/Git/GitConnection.cs +++ b/source/Calamari/ArgoCD/Git/GitConnection.cs @@ -61,19 +61,19 @@ public class SshGitConnection : IGitConnection { public SshGitConnection( string? username, + string privateKey, string url, - GitReference gitReference, - string privateKey) + GitReference gitReference) { Username = username; + PrivateKey = privateKey; Url = url; GitReference = gitReference; - PrivateKey = privateKey; } public string? Username { get; } + public string PrivateKey { get; } public string Url { get; } public GitReference GitReference { get; } - public string PrivateKey { get; } } -} +} \ No newline at end of file diff --git a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs index 6b437fe33f..73deef4b20 100644 --- a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs +++ b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs @@ -36,11 +36,12 @@ public CommitToGitRepositorySettings CreateRepositoryConfig(RunningDeployment de var properties = customPropertiesLoader.Load(); - var connection = properties.GitCredential switch - { - UsernamePasswordGitCredentialDto usernamePassword => new HttpsGitConnection(usernamePassword.Username, usernamePassword.Password, uriAsString, GitReference.CreateFromString(gitReferenceAsString)), - _ => throw new NotSupportedException("Commit-To-Git only supports the use of username/password. Please select a username/password based credential in your step configuration."), - }; + IGitConnection connection = properties.GitCredential switch + { + UsernamePasswordGitCredentialDto usernamePassword => new HttpsGitConnection(usernamePassword.Username, usernamePassword.Password, uriAsString, GitReference.CreateFromString(gitReferenceAsString)), + SshKeyGitCredentialDto ssh => new SshGitConnection(ssh.Username, ssh.PrivateKey, uriAsString, GitReference.CreateFromString(gitReferenceAsString)), + _ => throw new NotSupportedException($"An unrecognised credential type '{properties.GitCredential.GetType().Name}' was found for '{uriAsString}'"), + }; return new CommitToGitRepositorySettings(connection, commitParameters, From af56772bf7fe0354f725231fb183aeb1e2e708c1 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 20 May 2026 13:00:47 +1000 Subject: [PATCH 36/39] Tidy + bring up to date --- .../ArgoCD/Git/RepositoryFactoryTests.cs | 4 ++-- .../Git/AuthenticatingRepositoryFactory.cs | 2 +- source/Calamari/ArgoCD/Git/RepositoryFactory.cs | 16 ++-------------- source/Calamari/Calamari.csproj | 2 +- 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index 8bf13b9128..9c120d89cd 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -108,9 +108,9 @@ public void CloningSshGitConnectionDoesNotResolveAPullRequestClientAndLogsVerbos var sshConnection = new SshGitConnection( username: "git", + privateKey: "private-key", url: OriginPath, - gitReference: branchName, - privateKey: "private-key"); + gitReference: branchName); // libgit2 skips credential callbacks for local file paths, so this test validates only pull-request-client resolution and verbose logging — not SSH credential validity. // Act diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index 5f4eba8a4e..615a3fb0ea 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -40,8 +40,8 @@ public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevis { var sshConnection = new SshGitConnection( sshCredential.Username, - requestedUrl, sshCredential.PrivateKey, + requestedUrl, GitReference.CreateFromString(targetRevision)); return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), sshConnection); } diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index ad8ea4e624..7da091fb6d 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -66,23 +66,11 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che BranchName = (gitConnection.GitReference as GitBranchName)?.ToFriendlyName() }; - if (gitConnection is SshGitConnection ssh) + options.FetchOptions.CredentialsProvider = gitConnection.ToLibGit2SharpCredentialHandler(); + if (gitConnection is SshGitConnection) { - options.FetchOptions.CredentialsProvider = (_, _, _) => new SshKeyMemoryCredentials - { - Username = ssh.Username, - PrivateKey = ssh.PrivateKey, - }; options.FetchOptions.CertificateCheck = SshHostKeyVerificationBypass.AcceptAll; } - else if (gitConnection is HttpsGitConnection { Username: not null, Password: not null } https) - { - options.FetchOptions.CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials - { - Username = https.Username, - Password = https.Password - }; - } string repoPath; log.InfoFormat("Cloning repository {0}", log.FormatLink(gitConnection.Url)); diff --git a/source/Calamari/Calamari.csproj b/source/Calamari/Calamari.csproj index da6a365123..33e273534d 100644 --- a/source/Calamari/Calamari.csproj +++ b/source/Calamari/Calamari.csproj @@ -51,7 +51,7 @@ - + From 88686977ff11ebb57f7d4c4a0f7981833713e81b Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 20 May 2026 14:50:05 +1000 Subject: [PATCH 37/39] Rename SshGit... to SshKeyGit... --- .../ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs | 4 ++-- source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs | 4 ++-- source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs | 2 +- source/Calamari/ArgoCD/Git/GitConnection.cs | 4 ++-- .../Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs | 4 ++-- source/Calamari/ArgoCD/Git/RepositoryFactory.cs | 4 ++-- source/Calamari/ArgoCD/Git/RepositoryWrapper.cs | 4 ++-- source/Calamari/CommitToGit/CommitToGitConfigFactory.cs | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs index 5a3a0edf43..e471ca16ba 100644 --- a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -81,7 +81,7 @@ public void AnonymousCloneWhenNoCredentialsMatch() public class SshUrlTests : AuthenticatingRepositoryFactoryTestBase { [Test] - public void SshCredentialBranch_IsSelectedAndDispatchesSshGitConnection() + public void SshCredentialBranch_IsSelectedAndDispatchesSshKeyGitConnection() { // Use an ssh:// URL so the new strict validation allows it, and mock the factory // so no real SSH connection is attempted. @@ -98,7 +98,7 @@ [new SshKeyGitCredentialDto(sshUrl, "git", "private-key", [])], mockRepoFactory.Received() .CloneRepository( Arg.Any(), - Arg.Is(c => c is SshGitConnection)); + Arg.Is(c => c is SshKeyGitConnection)); } [Test] diff --git a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs index 9c120d89cd..c624b86c80 100644 --- a/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs @@ -96,7 +96,7 @@ public void CanCloneAnExistingRepositoryAtHEADAndAssociatedFiles() } [Test] - public void CloningSshGitConnectionDoesNotResolveAPullRequestClientAndLogsVerboseMessage() + public void CloningSshKeyGitConnectionDoesNotResolveAPullRequestClientAndLogsVerboseMessage() { // Arrange var filename = "sshTest.txt"; @@ -106,7 +106,7 @@ public void CloningSshGitConnectionDoesNotResolveAPullRequestClientAndLogsVerbos var mockResolver = Substitute.For(); var factoryWithMockedResolver = new RepositoryFactory(log, fileSystem, tempDirectory, mockResolver, new SystemClock()); - var sshConnection = new SshGitConnection( + var sshConnection = new SshKeyGitConnection( username: "git", privateKey: "private-key", url: OriginPath, diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index 615a3fb0ea..4fab8845ab 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -38,7 +38,7 @@ public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevis } case SshKeyGitCredentialDto sshCredential: { - var sshConnection = new SshGitConnection( + var sshConnection = new SshKeyGitConnection( sshCredential.Username, sshCredential.PrivateKey, requestedUrl, diff --git a/source/Calamari/ArgoCD/Git/GitConnection.cs b/source/Calamari/ArgoCD/Git/GitConnection.cs index fefac3a39b..5c85112742 100644 --- a/source/Calamari/ArgoCD/Git/GitConnection.cs +++ b/source/Calamari/ArgoCD/Git/GitConnection.cs @@ -57,9 +57,9 @@ static Uri ParseAsHttpsUri(string repositoryUrl) } } - public class SshGitConnection : IGitConnection + public class SshKeyGitConnection : IGitConnection { - public SshGitConnection( + public SshKeyGitConnection( string? username, string privateKey, string url, diff --git a/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs b/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs index 22994faaed..452c2d9922 100644 --- a/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs +++ b/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs @@ -12,7 +12,7 @@ public static CredentialsHandler ToLibGit2SharpCredentialHandler(this IGitConnec return connection switch { HttpsGitConnection https => UsernamePassword(https), - SshGitConnection sshKey => SshKey(sshKey), + SshKeyGitConnection sshKey => SshKey(sshKey), null => Anonymous(), _ => throw new NotSupportedException(), }; @@ -23,7 +23,7 @@ static CredentialsHandler Anonymous() return null!; // A null CredentialsHandler is valid for LibGit2Sharp } - static CredentialsHandler SshKey(SshGitConnection connection) + static CredentialsHandler SshKey(SshKeyGitConnection connection) { return (_, userFromUrl, types) => { diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index 7da091fb6d..f91644f1c7 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -67,7 +67,7 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che }; options.FetchOptions.CredentialsProvider = gitConnection.ToLibGit2SharpCredentialHandler(); - if (gitConnection is SshGitConnection) + if (gitConnection is SshKeyGitConnection) { options.FetchOptions.CertificateCheck = SshHostKeyVerificationBypass.AcceptAll; } @@ -119,7 +119,7 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che ? gitVendorPullRequestClientResolver.TryResolve(httpsGitConnection, log, CancellationToken.None).Result : null; - if (gitConnection is SshGitConnection) + if (gitConnection is SshKeyGitConnection) { log.Verbose("Git is using SSH authentication, Git vendor functionality will not be available"); } diff --git a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs index ed98886625..2d0da332a8 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs @@ -193,7 +193,7 @@ public void PushChanges(GitBranchName branchName) { CredentialsProvider = connection.ToLibGit2SharpCredentialHandler(), OnPushStatusError = errors => errorsDetected = errors, - CertificateCheck = connection is SshGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null + CertificateCheck = connection is SshKeyGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null }; repository.Network.Push(repository.Head, pushOptions); @@ -210,7 +210,7 @@ void FetchAndRebase(GitBranchName branchName) var fetchOptions = new FetchOptions { CredentialsProvider = connection.ToLibGit2SharpCredentialHandler(), - CertificateCheck = connection is SshGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null + CertificateCheck = connection is SshKeyGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null }; try diff --git a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs index 73deef4b20..30b3f2da9d 100644 --- a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs +++ b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs @@ -39,7 +39,7 @@ public CommitToGitRepositorySettings CreateRepositoryConfig(RunningDeployment de IGitConnection connection = properties.GitCredential switch { UsernamePasswordGitCredentialDto usernamePassword => new HttpsGitConnection(usernamePassword.Username, usernamePassword.Password, uriAsString, GitReference.CreateFromString(gitReferenceAsString)), - SshKeyGitCredentialDto ssh => new SshGitConnection(ssh.Username, ssh.PrivateKey, uriAsString, GitReference.CreateFromString(gitReferenceAsString)), + SshKeyGitCredentialDto ssh => new SshKeyGitConnection(ssh.Username, ssh.PrivateKey, uriAsString, GitReference.CreateFromString(gitReferenceAsString)), _ => throw new NotSupportedException($"An unrecognised credential type '{properties.GitCredential.GetType().Name}' was found for '{uriAsString}'"), }; From 4ce68bca0b73f50b2b5ce019b548cca91de4b289 Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 20 May 2026 14:53:45 +1000 Subject: [PATCH 38/39] Tidy up --- NuGet.Config | 2 -- .../LibGit2SharpCredentialsHandlerExtensionMethods.cs | 9 +++++++++ source/Calamari/ArgoCD/Git/RepositoryFactory.cs | 5 +---- source/Calamari/ArgoCD/Git/RepositoryWrapper.cs | 4 ++-- .../Calamari/ArgoCD/Git/SshHostKeyVerificationBypass.cs | 2 +- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/NuGet.Config b/NuGet.Config index e412ce6e95..5d46b5bafc 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -24,8 +24,6 @@ - - diff --git a/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs b/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs index 452c2d9922..a981fdb72b 100644 --- a/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs +++ b/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs @@ -18,6 +18,15 @@ public static CredentialsHandler ToLibGit2SharpCredentialHandler(this IGitConnec }; } + public static CertificateCheckHandler? ToLibGit2SharpCertificateCheckHandler(this IGitConnection? connection) + { + return connection switch + { + SshKeyGitConnection sshKey => SshHostKeyVerificationBypass.AcceptAll, + _ => null + }; + } + static CredentialsHandler Anonymous() { return null!; // A null CredentialsHandler is valid for LibGit2Sharp diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index f91644f1c7..382d5a3f95 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -67,10 +67,7 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che }; options.FetchOptions.CredentialsProvider = gitConnection.ToLibGit2SharpCredentialHandler(); - if (gitConnection is SshKeyGitConnection) - { - options.FetchOptions.CertificateCheck = SshHostKeyVerificationBypass.AcceptAll; - } + options.FetchOptions.CertificateCheck = gitConnection.ToLibGit2SharpCertificateCheckHandler(); string repoPath; log.InfoFormat("Cloning repository {0}", log.FormatLink(gitConnection.Url)); diff --git a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs index 2d0da332a8..7e1c0c5778 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryWrapper.cs @@ -193,7 +193,7 @@ public void PushChanges(GitBranchName branchName) { CredentialsProvider = connection.ToLibGit2SharpCredentialHandler(), OnPushStatusError = errors => errorsDetected = errors, - CertificateCheck = connection is SshKeyGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null + CertificateCheck = connection.ToLibGit2SharpCertificateCheckHandler() }; repository.Network.Push(repository.Head, pushOptions); @@ -210,7 +210,7 @@ void FetchAndRebase(GitBranchName branchName) var fetchOptions = new FetchOptions { CredentialsProvider = connection.ToLibGit2SharpCredentialHandler(), - CertificateCheck = connection is SshKeyGitConnection ? SshHostKeyVerificationBypass.AcceptAll : null + CertificateCheck = connection.ToLibGit2SharpCertificateCheckHandler() }; try diff --git a/source/Calamari/ArgoCD/Git/SshHostKeyVerificationBypass.cs b/source/Calamari/ArgoCD/Git/SshHostKeyVerificationBypass.cs index 0e2bd6bf28..3723cc7781 100644 --- a/source/Calamari/ArgoCD/Git/SshHostKeyVerificationBypass.cs +++ b/source/Calamari/ArgoCD/Git/SshHostKeyVerificationBypass.cs @@ -1,6 +1,6 @@ namespace Calamari.ArgoCD.Git; -internal static class SshHostKeyVerificationBypass +static class SshHostKeyVerificationBypass { // TODO(eddy): Implement proper host key verification public static readonly LibGit2Sharp.Handlers.CertificateCheckHandler AcceptAll = (cert, valid, host) => true; From c906c256ac3543350cdbaa6ef7a2c6884af430de Mon Sep 17 00:00:00 2001 From: Eddy Moulton Date: Wed, 20 May 2026 15:34:06 +1000 Subject: [PATCH 39/39] Handle null username/password explicitly --- .../ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs b/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs index a981fdb72b..3296b54357 100644 --- a/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs +++ b/source/Calamari/ArgoCD/Git/LibGit2SharpCredentialsHandlerExtensionMethods.cs @@ -11,6 +11,7 @@ public static CredentialsHandler ToLibGit2SharpCredentialHandler(this IGitConnec { return connection switch { + HttpsGitConnection { Username: null, Password: null } => Anonymous(), HttpsGitConnection https => UsernamePassword(https), SshKeyGitConnection sshKey => SshKey(sshKey), null => Anonymous(),