Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
219fb83
Add ssh credentials for git
eddymoulton Apr 23, 2026
9e80fee
Nullable for new field
eddymoulton Apr 23, 2026
5dcdc68
Source libgit2sharp from feedz.io
eddymoulton Apr 23, 2026
b27f13d
And native binaries
eddymoulton Apr 23, 2026
b254337
Test cleanup
eddymoulton Apr 24, 2026
a4ff1dd
test: basic auth headers are sent for git requests
eddymoulton Apr 24, 2026
9b72271
Lazily register git transport
eddymoulton Apr 24, 2026
1439f6a
Merge branch 'em/lazily-register-git-transport' into em/add-ssh-crede…
eddymoulton Apr 24, 2026
eaff4ff
Fix test
eddymoulton Apr 24, 2026
725c9d5
Merge branch 'main' into em/add-ssh-credentials-for-git
eddymoulton Apr 24, 2026
cc856e0
Rename and testing
eddymoulton Apr 27, 2026
d92b5ed
Add test category for requiring OpenSSL 3
eddymoulton Apr 28, 2026
2cd2cce
Better error message
eddymoulton Apr 28, 2026
7c8b7af
Ensure registration in tests
eddymoulton Apr 28, 2026
d18524f
Merge branch 'main' into em/add-ssh-credentials-for-git
eddymoulton Apr 28, 2026
778cbea
Needed an interface
eddymoulton Apr 28, 2026
b06b2be
Merge branch 'main' into em/add-ssh-credentials-for-git
eddymoulton Apr 28, 2026
757001a
Merge branch 'main' into em/add-ssh-credentials-for-git
eddymoulton Apr 28, 2026
68b8a2d
Self review
eddymoulton Apr 29, 2026
d434151
Merge branch 'main' into em/add-ssh-credentials-for-git
eddymoulton Apr 30, 2026
22afdad
Clean up DTOs + libgit models
eddymoulton May 5, 2026
ee32c01
Merge branch 'main' into em/add-ssh-credentials-for-git
eddymoulton May 6, 2026
8cf57a0
Add type discriminator
eddymoulton May 6, 2026
193b3b0
openssl1.1 handling
eddymoulton May 7, 2026
4dd5cbe
Update libgit2sharp
eddymoulton May 8, 2026
cb5907c
Restore openssl test categories
eddymoulton May 8, 2026
2974315
Merge branch 'main' into em/openssl1.1-handling
eddymoulton May 8, 2026
df74be6
Fix self ref
eddymoulton May 8, 2026
1918b75
Merge conflict
eddymoulton May 8, 2026
0a0e91b
Convert GitCredentialDto to IGitCredentialDto
eddymoulton May 8, 2026
2a2a169
Decouple type name from contract
eddymoulton May 8, 2026
323d0a6
Push Uri into HttpsGitConnection
eddymoulton May 10, 2026
2af45f9
Fix tests
eddymoulton May 10, 2026
dee1ef1
Merge branch 'main' into em/convert-gitcredentialdto-to-igitcredenti
eddymoulton May 11, 2026
249f725
Merge branch 'main' into em/convert-gitcredentialdto-to-igitcredenti
eddymoulton May 11, 2026
5063c23
Merge branch 'main' into em/openssl1.1-handling
eddymoulton May 11, 2026
d66d72b
Test exclusion
eddymoulton May 11, 2026
2ae0de7
Merge branch 'main' into em/convert-gitcredentialdto-to-igitcredenti
eddymoulton May 13, 2026
9f1334d
Review comments
eddymoulton May 13, 2026
9308011
Fix merge error
eddymoulton May 13, 2026
0c3b11e
Merge branch 'main' into em/openssl1.1-handling
eddymoulton May 13, 2026
e1170ab
Update Octopus.LibGit2Sharp
eddymoulton May 13, 2026
03c1cf1
Fix test
eddymoulton May 13, 2026
eda90b4
Merge branch 'em/convert-gitcredentialdto-to-igitcredenti' into em/op…
eddymoulton May 13, 2026
46b6f7b
Merge branch 'main' into em/openssl1.1-handling
eddymoulton May 13, 2026
64b36f0
Self review
eddymoulton May 13, 2026
c0e00c8
Merge branch 'main' into em/openssl1.1-handling
eddymoulton May 14, 2026
18c997a
Merge branch 'main' into em/openssl1.1-handling
eddymoulton May 15, 2026
9512a96
Pull in ext method for creating the credential handler
eddymoulton May 15, 2026
ee1837c
Update to release LibGit2Sharp
eddymoulton May 15, 2026
eec8ba7
Merge branch 'main' into em/openssl1.1-handling
eddymoulton May 18, 2026
aebbbaa
Update LibGit2Sharp (branch build w/ mac fix)
eddymoulton May 18, 2026
9a1154a
Handle CommitToGit changes
eddymoulton May 19, 2026
619d6e2
Merge branch 'main' into em/openssl1.1-handling
eddymoulton May 19, 2026
af56772
Tidy + bring up to date
eddymoulton May 20, 2026
d74e47f
Merge branch 'main' into em/openssl1.1-handling
eddymoulton May 20, 2026
8868697
Rename SshGit... to SshKeyGit...
eddymoulton May 20, 2026
4ce68bc
Tidy up
eddymoulton May 20, 2026
c906c25
Handle null username/password explicitly
eddymoulton May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ namespace Calamari.Tests.ArgoCD.Contracts;
/// <summary>
/// Deserialisation tests for <see cref="ArgoCDCustomPropertiesDto"/> with a focus on
/// the polymorphic <see cref="IGitCredentialDto"/> array. The converter discriminates on
/// the <c>Type</c> field emitted by Octopus Server (the concrete type name); a missing
/// <c>Type</c> defaults to <see cref="GitCredentialDto"/> for backwards compatibility.
/// the <c>Type</c> field emitted by Octopus Server; a missing <c>Type</c> defaults to
/// <see cref="GitCredentialDto"/> for backwards compatibility.
/// </summary>
[TestFixture]
public class ArgoCDCustomPropertiesDtoSerializationTests
Expand All @@ -27,7 +27,7 @@ static T DeserializeRaw<T>(string json)
=> JsonConvert.DeserializeObject<T>(json, Settings)!;

[Test]
public void TypeGitCredentialDto_DeserializesAsHttpsCredential()
public void TypeUsernamePassword_DeserializesAsHttpsCredential()
{
const string json = """
{
Expand All @@ -52,6 +52,62 @@ public void TypeGitCredentialDto_DeserializesAsHttpsCredential()
credential.Password.Should().Be("pass");
}

[Test]
public void TypeSshKey_DeserializesAsSshCredential()
{
const string json = """
{
"Gateways": [], "Applications": [],
"Credentials": [
{
"Type": "SshKey",
"Url": "git@github.com:org/repo.git",
"Username": "git",
"PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----"
}
]
}
""";

var result = DeserializeRaw<ArgoCDCustomPropertiesDto>(json);

result.Credentials.Should().HaveCount(1);
var credential = result.Credentials[0].Should().BeOfType<SshKeyGitCredentialDto>().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": [
{
"Type": "UsernamePassword",
"Url": "https://github.com/org/repo.git",
"Username": "user",
"Password": "pass"
},
{
"Type": "SshKey",
"Url": "git@github.com:org/other.git",
"Username": "git",
"PrivateKey": "-----BEGIN OPENSSH PRIVATE KEY-----"
}
]
}
""";

var result = DeserializeRaw<ArgoCDCustomPropertiesDto>(json);

result.Credentials.Should().HaveCount(2);
result.Credentials[0].Should().BeOfType<GitCredentialDto>();
result.Credentials[1].Should().BeOfType<SshKeyGitCredentialDto>();
}

[Test]
public void MissingType_DefaultsToHttpsCredential()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using Calamari.ArgoCD.Git;
using Calamari.ArgoCD.Git.PullRequests;
Expand All @@ -8,6 +7,7 @@
using Calamari.Testing.Helpers;
using Calamari.Tests.Fixtures.Integration.FileSystem;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using Octopus.Calamari.Contracts.ArgoCD;

Expand Down Expand Up @@ -54,10 +54,7 @@ public void HttpsCredentialIsSelectedWhenUrlMatchesHttpsCredential()
{
var httpsUrl = RepositoryHelpers.ToFileUri(OriginPath);
var factory = new AuthenticatingRepositoryFactory(
new Dictionary<string, IGitCredentialDto>
{
[httpsUrl] = new GitCredentialDto(httpsUrl, "", "")
},
[new GitCredentialDto(httpsUrl, "", "")],
repositoryFactory,
log);

Expand All @@ -70,7 +67,7 @@ public void AnonymousCloneWhenNoCredentialsMatch()
{
var originUrl = RepositoryHelpers.ToFileUri(OriginPath);
var factory = new AuthenticatingRepositoryFactory(
new Dictionary<string, IGitCredentialDto>(),
[],
repositoryFactory,
log);

Expand All @@ -80,6 +77,37 @@ public void AnonymousCloneWhenNoCredentialsMatch()
}
}

[TestFixture]
public class SshUrlTests : AuthenticatingRepositoryFactoryTestBase
{
[Test]
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.
const string sshUrl = "ssh://git@github.com/org/repo.git";
var mockRepoFactory = Substitute.For<IRepositoryFactory>();

var factory = new AuthenticatingRepositoryFactory(
[new SshKeyGitCredentialDto(sshUrl, "git", "private-key", [])],
mockRepoFactory,
log);

factory.CloneRepository(sshUrl, branchName.ToFriendlyName());

mockRepoFactory.Received()
.CloneRepository(
Arg.Any<string>(),
Arg.Is<IGitConnection>(c => c is SshKeyGitConnection));
}

[Test]
public void HttpsCredentialTakesPriorityOverSshWhenBothMatchAnSshUrl()
{
AssertHttpsCredentialTakesPriorityOverSsh("ssh://git@github.com/org/repo.git");
}
}

[TestFixture]
public class ScpStyleUrlTests : AuthenticatingRepositoryFactoryTestBase
{
Expand All @@ -91,10 +119,7 @@ public void ScpStyleUrlDoesNotMatchHttpsCredential()
var httpsUrl = "https://github.com/org/repo.git";

var factory = new AuthenticatingRepositoryFactory(
new Dictionary<string, IGitCredentialDto>
{
[httpsUrl] = new GitCredentialDto(httpsUrl, "user", "pass")
},
[new GitCredentialDto(httpsUrl, "user", "pass")],
repositoryFactory,
log);

Expand All @@ -104,5 +129,32 @@ public void ScpStyleUrlDoesNotMatchHttpsCredential()
act.Should().Throw<Exception>(); // 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");
}
}

protected void AssertHttpsCredentialTakesPriorityOverSsh(string url)
{
var mockRepoFactory = Substitute.For<IRepositoryFactory>();

// 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 factory = new AuthenticatingRepositoryFactory(rawCredentials, mockRepoFactory, log);

factory.CloneRepository(url, "main");

mockRepoFactory.Received()
.CloneRepository(
Arg.Any<string>(),
Arg.Is<IGitConnection>(c => c is HttpsGitConnection));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ async Task TestPullRequest(string repositoryUrl, string defaultBranch, string cl
using var temporaryFolder = TemporaryDirectory.Create();

CredentialsHandler credentialsHandler = (url, usernameFromUrl, types) => new UsernamePasswordCredentials { Username = cloneUsername, Password = clonePassword};
LibGit2SharpTransportRegistration.EnsureRegistered();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We enter git usage in a few weird places in the tests, so for now I've just called this method so we get decent errors if the test runner doesn't meet the dependencies.

Normally this gets done in the repo wrapper and it's a bit cleaner.

var repositoryPath = Repository.Clone(repositoryUrl, temporaryFolder.DirectoryPath, new CloneOptions()
{
FetchOptions =
Expand Down
28 changes: 28 additions & 0 deletions source/Calamari.Tests/ArgoCD/Git/RepositoryFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,6 +95,33 @@ public void CanCloneAnExistingRepositoryAtHEADAndAssociatedFiles()
fileContent.Should().Be(originalContent);
}

[Test]
public void CloningSshKeyGitConnectionDoesNotResolveAPullRequestClientAndLogsVerboseMessage()
{
// Arrange
var filename = "sshTest.txt";
var content = "ssh test content";
CreateCommitOnOrigin(branchName, filename, content);

var mockResolver = Substitute.For<IGitVendorPullRequestClientResolver>();
var factoryWithMockedResolver = new RepositoryFactory(log, fileSystem, tempDirectory, mockResolver, new SystemClock());

var sshConnection = new SshKeyGitConnection(
username: "git",
privateKey: "private-key",
url: OriginPath,
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
factoryWithMockedResolver.CloneRepository("Clone_WithSshConnection", sshConnection);

mockResolver.DidNotReceive().TryResolve(Arg.Any<IHttpsGitConnection>(), Arg.Any<ILog>(), Arg.Any<System.Threading.CancellationToken>());

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";
Expand Down
6 changes: 6 additions & 0 deletions source/Calamari.Tests/ArgoCD/Git/RepositoryHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -19,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);
Expand Down Expand Up @@ -47,6 +51,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,7 @@ public void Install(RunningDeployment deployment)

var argoProperties = customPropertiesLoader.Load<ArgoCDCustomPropertiesDto>();

// 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<GitCredentialDto>().FirstOrDefault<IGitCredentialDto>() ?? 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,7 @@ public void Install(RunningDeployment deployment)

var argoProperties = customPropertiesLoader.Load<ArgoCDCustomPropertiesDto>();

// 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<GitCredentialDto>().FirstOrDefault<IGitCredentialDto>() ?? 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);
Expand Down
39 changes: 33 additions & 6 deletions source/Calamari/ArgoCD/Git/AuthenticatingRepositoryFactory.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Calamari.Common.Plumbing.Logging;
using Octopus.Calamari.Contracts.ArgoCD;

Expand All @@ -11,25 +13,50 @@ public class AuthenticatingRepositoryFactory
readonly ILog log;

public AuthenticatingRepositoryFactory(
Dictionary<string, IGitCredentialDto> gitCredentials,
IReadOnlyCollection<IGitCredentialDto> 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<GitCredentialDto>().FirstOrDefault<IGitCredentialDto>() ?? g.First());

this.repositoryFactory = repositoryFactory;
this.log = log;
}

public RepositoryWrapper CloneRepository(string requestedUrl, string targetRevision)
{
var gitCredential = gitCredentials.GetValueOrDefault(requestedUrl);
if (gitCredential is GitCredentialDto passwordCredential)
switch (gitCredential)
{
var gitConnection = new HttpsGitConnection(passwordCredential.Username, passwordCredential.Password, GitCloneSafeUrl.ConvertToUriString(requestedUrl), GitReference.CreateFromString(targetRevision));
return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), gitConnection);
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));
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.");
var anonGitConnection = new HttpsGitConnection(null, null, GitCloneSafeUrl.ConvertToUriString(requestedUrl), GitReference.CreateFromString(targetRevision));
return repositoryFactory.CloneRepository(UniqueRepoNameGenerator.Generate(), anonGitConnection);
}
Expand Down
20 changes: 20 additions & 0 deletions source/Calamari/ArgoCD/Git/GitConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,24 @@ static Uri ParseAsHttpsUri(string repositoryUrl)
return uri;
}
}

public class SshKeyGitConnection : IGitConnection
{
public SshKeyGitConnection(
string? username,
string privateKey,
string url,
GitReference gitReference)
{
Username = username;
PrivateKey = privateKey;
Url = url;
GitReference = gitReference;
}

public string? Username { get; }
public string PrivateKey { get; }
public string Url { get; }
public GitReference GitReference { get; }
}
}
Loading