diff --git a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs index 5d559298f..3c06b56d5 100644 --- a/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs +++ b/source/Calamari.Tests/ArgoCD/Git/AuthenticatingRepositoryFactoryTests.cs @@ -2,6 +2,7 @@ using System.IO; 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; @@ -58,7 +59,7 @@ [new GitCredentialDto(httpsUrl, "", "")], repositoryFactory, log); - using var wrapper = factory.CloneRepository(httpsUrl, branchName.ToFriendlyName()); + using var wrapper = factory.CloneRepository(httpsUrl, branchName.ToFriendlyName(), requiresPullRequest: false); wrapper.Should().NotBeNull(); } @@ -71,10 +72,34 @@ public void AnonymousCloneWhenNoCredentialsMatch() repositoryFactory, log); - using var wrapper = factory.CloneRepository(originUrl, branchName.ToFriendlyName()); + using var wrapper = factory.CloneRepository(originUrl, branchName.ToFriendlyName(), requiresPullRequest: false); wrapper.Should().NotBeNull(); log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); } + + [Test] + public void LogsAnInformationalMessageWhenCloningAnonymously() + { + var mockRepoFactory = Substitute.For(); + var factory = new AuthenticatingRepositoryFactory([], mockRepoFactory, log); + + factory.CloneRepository("https://github.com/org/repo.git", "main", requiresPullRequest: false); + + mockRepoFactory.Received() + .CloneRepository(Arg.Any(), Arg.Is(c => c is AnonymousGitConnection)); + log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); + } + + [Test] + public void ThrowsWhenCreatingAPullRequestWithoutCredentials() + { + var mockRepoFactory = Substitute.For(); + var factory = new AuthenticatingRepositoryFactory([], mockRepoFactory, log); + + var act = () => factory.CloneRepository("https://github.com/org/repo.git", "main", requiresPullRequest: true); + + act.Should().Throw().And.Message.Should().Contain("requires Git repository credentials"); + } } [TestFixture] @@ -95,12 +120,26 @@ [new SshKeyGitCredentialDto(sshUrl, "git", "private-key", [])], mockRepoFactory, log); - factory.CloneRepository(sshUrl, branchName.ToFriendlyName()); + factory.CloneRepository(sshUrl, branchName.ToFriendlyName(), requiresPullRequest: false); mockRepoFactory.Received() - .CloneRepository( - Arg.Any(), - Arg.Is(c => c is SshKeyGitConnection)); + .CloneRepository(Arg.Any(), Arg.Is(c => c is SshKeyGitConnection)); + } + + [Test] + public void ThrowsWhenCreatingAPullRequestWithAnSshKeyCredential() + { + const string sshUrl = "ssh://git@github.com/org/repo.git"; + var mockRepoFactory = Substitute.For(); + + var factory = new AuthenticatingRepositoryFactory( + [new SshKeyGitCredentialDto(sshUrl, "git", "private-key", [])], + mockRepoFactory, + log); + + var act = () => factory.CloneRepository(sshUrl, branchName.ToFriendlyName(), requiresPullRequest: true); + + act.Should().Throw().And.Message.Should().Contain("SSH key authentication"); } [Test] @@ -127,7 +166,7 @@ [new SshKeyGitCredentialDto(sshUrl, "git", "private-key", knownHosts)], mockRepoFactory, log); - factory.CloneRepository(sshUrl, branchName.ToFriendlyName()); + factory.CloneRepository(sshUrl, branchName.ToFriendlyName(), requiresPullRequest: false); mockRepoFactory.Received() .CloneRepository( @@ -159,7 +198,7 @@ [new GitCredentialDto(httpsUrl, "user", "pass")], // 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"); + var act = () => factory.CloneRepository(scpUrl, "main", requiresPullRequest: false); act.Should().Throw(); // clone failure expected log.Messages.Should().Contain(m => m.FormattedMessage.Contains("No Git credentials found")); } @@ -184,11 +223,35 @@ protected void AssertHttpsCredentialTakesPriorityOverSsh(string url) var factory = new AuthenticatingRepositoryFactory(rawCredentials, mockRepoFactory, log); - factory.CloneRepository(url, "main"); + factory.CloneRepository(url, "main", requiresPullRequest: false); mockRepoFactory.Received() .CloneRepository( Arg.Any(), Arg.Is(c => c is HttpsGitConnection)); } + + [TestFixture] + public class UrlNormalizationTests : AuthenticatingRepositoryFactoryTestBase + { + [Test] + public void SchemelessUrlIsNormalizedWithOciSchemeForNonSshCredential() + { + const string schemelessUrl = "registry.example.com/charts/myapp"; + const string expectedNormalizedUrl = "oci://registry.example.com/charts/myapp"; + + var mockRepoFactory = Substitute.For(); + var factory = new AuthenticatingRepositoryFactory( + [new GitCredentialDto(schemelessUrl, "user", "password")], + mockRepoFactory, + log); + + factory.CloneRepository(schemelessUrl, "main", requiresPullRequest: false); + + mockRepoFactory.Received() + .CloneRepository( + Arg.Any(), + Arg.Is(c => c.Url == expectedNormalizedUrl)); + } + } } \ No newline at end of file diff --git a/source/Calamari.Tests/ArgoCD/Git/GitConnectionFactoryTests.cs b/source/Calamari.Tests/ArgoCD/Git/GitConnectionFactoryTests.cs new file mode 100644 index 000000000..b8b1418f5 --- /dev/null +++ b/source/Calamari.Tests/ArgoCD/Git/GitConnectionFactoryTests.cs @@ -0,0 +1,70 @@ +using Calamari.ArgoCD.Git; +using FluentAssertions; +using NUnit.Framework; +using ArgoCdPasswordCredential = Octopus.Calamari.Contracts.ArgoCD.GitCredentialDto; +using ArgoCdSshKeyCredential = Octopus.Calamari.Contracts.ArgoCD.SshKeyGitCredentialDto; +using GitCredential = Octopus.Calamari.Contracts.Git.IGitCredentialDto; +using GitPasswordCredential = Octopus.Calamari.Contracts.Git.UsernamePasswordGitCredentialDto; +using GitSshKeyCredential = Octopus.Calamari.Contracts.Git.SshKeyGitCredentialDto; +using GitSshKnownHostDto = Octopus.Calamari.Contracts.Git.SshKnownHostDto; + +namespace Calamari.Tests.ArgoCD.Git; + +[TestFixture] +public class GitConnectionFactoryTests +{ + static readonly GitReference Reference = GitBranchName.CreateFromFriendlyName("main"); + const string HttpsUrl = "https://github.com/org/repo.git"; + const string SshUrl = "ssh://git@github.com/org/repo.git"; + + [Test] + public void ArgoCdSshKeyCredentialIsMappedToSshKeyGitConnection() + { + var connection = GitConnectionFactory.Create(new ArgoCdSshKeyCredential(SshUrl, "git", "private-key", []), SshUrl, Reference); + + connection.Should().BeEquivalentTo(new SshKeyGitConnection("git", "private-key", SshUrl, Reference, [])); + } + + [Test] + public void ArgoCdUsernamePasswordCredentialIsMappedToUsernamePasswordGitConnection() + { + var connection = GitConnectionFactory.Create(new ArgoCdPasswordCredential(HttpsUrl, "user", "password"), HttpsUrl, Reference); + + connection.Should().BeEquivalentTo(new UsernamePasswordGitConnection("user", "password", HttpsUrl, Reference), options => options.Excluding(c => c.Uri)); + } + + [Test] + public void SshKeyCredentialIsMappedToSshKeyGitConnection() + { + GitSshKnownHostDto[] knownHosts = + [ + new ("github.com", "AAAAB3NzaC1yc2EAAAADAQABAAABAQ=="), + new ("bitbucket.org", "AAAAC3NzaC1lZDI1NTE5AAAAIA=="), + ]; + var connection = GitConnectionFactory.Create(new GitSshKeyCredential("cred", SshUrl, "git", "private-key", knownHosts), SshUrl, Reference); + + + SshKnownHost[] expectedKnownHosts = + [ + new("github.com", "AAAAB3NzaC1yc2EAAAADAQABAAABAQ"), + new("bitbucket.org", "AAAAC3NzaC1lZDI1NTE5AAAAIA=="), + ]; + connection.Should().BeEquivalentTo(new SshKeyGitConnection("git", "private-key", SshUrl, Reference, expectedKnownHosts)); + } + + [Test] + public void UsernamePasswordCredentialIsMappedToUsernamePasswordGitConnection() + { + var connection = GitConnectionFactory.Create(new GitPasswordCredential("cred", HttpsUrl, "user", "password"), HttpsUrl, Reference); + + connection.Should().BeEquivalentTo(new UsernamePasswordGitConnection("user", "password", HttpsUrl, Reference), options => options.Excluding(c => c.Uri)); + } + + [Test] + public void NoCredentialIsMappedToAnonymousGitConnection() + { + var connection = GitConnectionFactory.Create((GitCredential)null, HttpsUrl, Reference); + + connection.Should().BeEquivalentTo(new AnonymousGitConnection(HttpsUrl, Reference), options => options.Excluding(c => c.Uri)); + } +} diff --git a/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs b/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs index 86a525bad..9aac6d9b3 100644 --- a/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs +++ b/source/Calamari.Tests/CommitToGit/CommitToGitConfigFactoryTests.cs @@ -53,6 +53,34 @@ public void CreateRepositoryConfig_UsesUsernameAndPasswordFromLoadedProperties() httpsGitConnection.Uri.Value.Should().Be(new Uri("https://example.invalid/repo.git")); } + [Test] + public void CreateRepositoryConfig_WhenCreatingPullRequestWithSshKeyCredential_Throws() + { + variables.GetFlag(SpecialVariables.Action.Git.PullRequest.Create).Returns(true); + loader.Load() + .Returns(new CommitToGitCustomPropertiesDto(new SshKeyGitCredentialDto("MyCred", "https://example.invalid/repo.git", "git", "private-key", []))); + + var deployment = new RunningDeployment(null, variables); + + var act = () => factory.CreateRepositoryConfig(deployment, loader); + + act.Should().Throw().And.Message.Should().Contain("SSH key authentication"); + } + + [Test] + public void CreateRepositoryConfig_WhenCreatingPullRequestWithoutCredentials_Throws() + { + variables.GetFlag(SpecialVariables.Action.Git.PullRequest.Create).Returns(true); + loader.Load() + .Returns(new CommitToGitCustomPropertiesDto(null)); + + var deployment = new RunningDeployment(null, variables); + + var act = () => factory.CreateRepositoryConfig(deployment, loader); + + act.Should().Throw().And.Message.Should().Contain("requires Git repository credentials"); + } + [Test] public void CreateRepositoryConfig_WhenDestinationPathIsMissing_DefaultsToEmptyString() { diff --git a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs index d24b3ee2c..022b299fc 100644 --- a/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Calamari.Common.Commands; using Calamari.Common.Plumbing.Logging; using Octopus.Calamari.Contracts.ArgoCD; @@ -26,39 +27,28 @@ public AuthenticatingRepositoryFactory( this.log = log; } - public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevision) + public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevision, bool requiresPullRequest) { var gitCredential = gitCredentials.GetValueOrDefault(requestedUrl); - switch (gitCredential) + var cloneUrl = gitCredential is SshKeyGitCredentialDto ? requestedUrl : GitCloneSafeUrl.ConvertToUriString(requestedUrl); + var gitConnection = GitConnectionFactory.Create(gitCredential, cloneUrl, GitReference.CreateFromString(targetRevision)); + + if (requiresPullRequest) { - case GitCredentialDto passwordCredential: - { - var gitConnection = new HttpsGitConnection(passwordCredential.Username, passwordCredential.Password, GitCloneSafeUrl.ConvertToUriString(requestedUrl), GitReference.CreateFromString(targetRevision)); - return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection); - } - case SshKeyGitCredentialDto sshCredential: - { - var sshConnection = new SshKeyGitConnection( - sshCredential.Username, - sshCredential.PrivateKey, - requestedUrl, - GitReference.CreateFromString(targetRevision), - sshCredential.KnownHosts.Select(kh => new SshKnownHost(kh.Host, kh.PublicKey)).ToArray()); - return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), sshConnection); - } - case null: + switch (gitConnection) { - 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; + case SshKeyGitConnection: + throw new CommandException("Creating PRs is not possible when using SSH key authentication, please use a username and password instead"); + case AnonymousGitConnection: + throw new CommandException("Creating a pull request requires Git repository credentials, but none were provided. Please configure a username and password."); } } - var anonGitConnection = new HttpsGitConnection(null, null, GitCloneSafeUrl.ConvertToUriString(requestedUrl), GitReference.CreateFromString(targetRevision)); - return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), anonGitConnection); + if (gitConnection is AnonymousGitConnection) + { + log.Info($"No Git credentials found for: '{requestedUrl}', will attempt to clone repository anonymously."); + } + + return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection); } -} \ No newline at end of file +} diff --git a/source/Calamari/ArgoCD/Git/GitConnection.cs b/source/Calamari/ArgoCD/Git/GitConnection.cs index a7af69e1f..529752421 100644 --- a/source/Calamari/ArgoCD/Git/GitConnection.cs +++ b/source/Calamari/ArgoCD/Git/GitConnection.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Linq; using Calamari.Common.Commands; namespace Calamari.ArgoCD.Git @@ -59,6 +58,12 @@ static Uri ParseAsHttpsUri(string repositoryUrl) } } + public class AnonymousGitConnection(string url, GitReference gitReference) + : HttpsGitConnection(null, null, url, gitReference); + + public class UsernamePasswordGitConnection(string username, string password, string url, GitReference gitReference) + : HttpsGitConnection(username, password, url, gitReference); + public record SshKeyGitConnection( string? Username, string PrivateKey, diff --git a/source/Calamari/ArgoCD/Git/GitConnectionFactory.cs b/source/Calamari/ArgoCD/Git/GitConnectionFactory.cs new file mode 100644 index 000000000..4f9820c2c --- /dev/null +++ b/source/Calamari/ArgoCD/Git/GitConnectionFactory.cs @@ -0,0 +1,61 @@ +#nullable enable + +using System; +using System.Linq; +using Calamari.Common.Commands; +using Octopus.Calamari.Contracts.Git; +using ArgoCdIGitCredentialDto = Octopus.Calamari.Contracts.ArgoCD.IGitCredentialDto; +using ArgoCdGitCredentialDto = Octopus.Calamari.Contracts.ArgoCD.GitCredentialDto; +using ArgoCdSshKeyGitCredentialDto = Octopus.Calamari.Contracts.ArgoCD.SshKeyGitCredentialDto; + +namespace Calamari.ArgoCD.Git; + +public static class GitConnectionFactory +{ + // Converts Argo credentials into the common type + // ideally we migrate the Argo code path to use the common type from server and we can delete this + public static IGitConnection Create(ArgoCdIGitCredentialDto? credential, string url, GitReference gitReference) + { + return credential switch + { + ArgoCdGitCredentialDto password => Create( + // ArgoCD credentials don't pass a name + new UsernamePasswordGitCredentialDto(string.Empty, password.Url, password.Username, password.Password), + url, + gitReference + ), + ArgoCdSshKeyGitCredentialDto ssh => Create( + new SshKeyGitCredentialDto( + // ArgoCD credentials don't pass a name + string.Empty, + ssh.Url, + ssh.Username, + ssh.PrivateKey, + ssh.KnownHosts.Select(kh => new SshKnownHostDto(kh.Host, kh.PublicKey)).ToArray()), + url, + gitReference + ), + null => Create((IGitCredentialDto?)null, url, gitReference), + _ => throw new CommandException($"An unrecognised credential type '{credential.GetType().Name}' was found for '{url}'"), + }; + } + + public static IGitConnection Create(IGitCredentialDto? credential, string url, GitReference gitReference) + { + return credential switch + { + UsernamePasswordGitCredentialDto password + => new UsernamePasswordGitConnection(password.Username, password.Password, url, gitReference), + SshKeyGitCredentialDto ssh + => new SshKeyGitConnection( + ssh.Username, + ssh.PrivateKey, + url, + gitReference, + ssh.KnownHosts.Select(kh => new SshKnownHost(kh.Host, kh.PublicKey)).ToArray() + ), + null => new AnonymousGitConnection(url, gitReference), + _ => throw new CommandException($"An unrecognised credential type '{credential.GetType().Name}' was found for '{url}'") + }; + } +} diff --git a/source/Calamari/ArgoCD/Git/RepositoryAdapter.cs b/source/Calamari/ArgoCD/Git/RepositoryAdapter.cs index b8abeddc9..53962d26e 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryAdapter.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryAdapter.cs @@ -19,7 +19,7 @@ public RepositoryAdapter(AuthenticatingRepositoryFactory repositoryFactory, public SourceUpdateResult Process(ApplicationSourceWithMetadata sourceWithMetadata, ISourceUpdater updater) { - using var repository = repositoryFactory.CloneRepository(sourceWithMetadata.Source.OriginalRepoUrl, sourceWithMetadata.Source.TargetRevision); + using var repository = repositoryFactory.CloneRepository(sourceWithMetadata.Source.OriginalRepoUrl, sourceWithMetadata.Source.TargetRevision, repositoryUpdater.RequiresPr); var filesUpdated = updater.Process(sourceWithMetadata, repository.WorkingDirectory); if (filesUpdated.HasChanges()) diff --git a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs index 771c9b83b..cec059349 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryFactory.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryFactory.cs @@ -114,7 +114,7 @@ RepositoryWrapper CheckoutGitRepository(IGitConnection gitConnection, string che } //TODO(tmm): Make this function (and all callers async). - var gitVendorApiAdapter = gitConnection is HttpsGitConnection httpsGitConnection + var gitVendorApiAdapter = gitConnection is UsernamePasswordGitConnection httpsGitConnection ? gitVendorPullRequestClientResolver.TryResolve(httpsGitConnection, log, CancellationToken.None).Result : null; diff --git a/source/Calamari/ArgoCD/Git/RepositoryUpdater.cs b/source/Calamari/ArgoCD/Git/RepositoryUpdater.cs index 7f1bfe003..72398633d 100644 --- a/source/Calamari/ArgoCD/Git/RepositoryUpdater.cs +++ b/source/Calamari/ArgoCD/Git/RepositoryUpdater.cs @@ -17,6 +17,8 @@ public RepositoryUpdater(GitCommitParameters commitParameters, ILog log, ICommit this.log = log; this.commitMessageGenerator = commitMessageGenerator; } + + public bool RequiresPr => commitParameters.RequiresPr; public PushResult? PushToRemote( RepositoryWrapper repository, diff --git a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs index 43bafc535..018bbfa7a 100644 --- a/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs +++ b/source/Calamari/CommitToGit/CommitToGitConfigFactory.cs @@ -1,13 +1,10 @@ using System; -using System.Linq; -using Amazon.ECS.Model; using Calamari.ArgoCD.Conventions; using Calamari.ArgoCD.Git; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Variables; using Calamari.Deployment; using Octopus.Calamari.Contracts.CommitToGit; -using Octopus.Calamari.Contracts.Git; namespace Calamari.CommitToGit { @@ -37,17 +34,24 @@ public CommitToGitRepositorySettings CreateRepositoryConfig(RunningDeployment de var properties = customPropertiesLoader.Load(); - IGitConnection connection = properties.GitCredential switch - { - UsernamePasswordGitCredentialDto usernamePassword => new HttpsGitConnection(usernamePassword.Username, usernamePassword.Password, uriAsString, GitReference.CreateFromString(gitReferenceAsString)), - SshKeyGitCredentialDto ssh => new SshKeyGitConnection(ssh.Username, ssh.PrivateKey, uriAsString, GitReference.CreateFromString(gitReferenceAsString), ssh.KnownHosts.Select(kh => new SshKnownHost(kh.Host, kh.PublicKey)).ToArray()), - _ => throw new NotSupportedException($"An unrecognised credential type '{properties.GitCredential.GetType().Name}' was found for '{uriAsString}'"), - }; + var connection = GitConnectionFactory.Create(properties.GitCredential, uriAsString, GitReference.CreateFromString(gitReferenceAsString)); + + if (requiresPullRequest) + { + switch (connection) + { + case SshKeyGitConnection: + throw new CommandException("Creating PRs is not possible when using SSH key authentication, please use a username and password instead"); + case AnonymousGitConnection: + throw new CommandException("Creating a pull request requires Git repository credentials, but none were provided. Please configure a username and password."); + } + } //Note: Octopus server removes variables containing empty strings, thus a missing property should default to an empty string. - return new CommitToGitRepositorySettings(connection, - commitParameters, - variables.Get(SpecialVariables.Action.Git.DestinationPath) ?? string.Empty); + return new CommitToGitRepositorySettings( + connection, + commitParameters, + variables.Get(SpecialVariables.Action.Git.DestinationPath) ?? string.Empty); } string EvaluateNonsensitiveExpression(string expression) @@ -63,4 +67,4 @@ string EvaluateNonsensitiveExpression(string expression) return result; } } -} \ No newline at end of file +}