Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
2bb8b15
Add a credential helper to avoid plain-text storage warnings
matt-richardson Aug 28, 2025
4a48588
Improve debugability
matt-richardson Aug 28, 2025
91e8425
Make it works
matt-richardson Aug 29, 2025
5a456b9
Try fixing DockerCredentialScriptsFixture
matt-richardson Sep 1, 2025
5f7d88b
Update DockerCredentialScriptsFixture.cs
matt-richardson Sep 8, 2025
d12b8c6
Merge branch 'main' into mattr/docker-credential-helper
matt-richardson Sep 8, 2025
aeb2bf8
Update DockerCredentialScriptsFixture.cs
matt-richardson Sep 8, 2025
55abbf0
Update docker-credential-octopus.ps1
matt-richardson Sep 8, 2025
2a3111b
Update DockerCredentialScriptsFixture.cs
matt-richardson Sep 8, 2025
341a9e4
Update DockerCredentialScriptsFixture.cs
matt-richardson Sep 8, 2025
ca0e60e
Update docker-credential-octopus.ps1
matt-richardson Sep 8, 2025
2c585bd
Merge branch 'main' into mattr/docker-credential-helper
matt-richardson Sep 8, 2025
6f6a96c
Update Build.cs
matt-richardson Sep 8, 2025
f9dda25
Update Build.cs
matt-richardson Sep 8, 2025
0a64a30
Update KubernetesContextScriptWrapperLiveFixture.cs
matt-richardson Sep 9, 2025
d021330
Update InstallTools.cs
matt-richardson Sep 9, 2025
3f555d8
Ignore on CloudMac & AmazonLinux agent
matt-richardson Sep 16, 2025
70f2b0d
Use vars from password store
matt-richardson Sep 16, 2025
af2d579
Merge branch 'main' into mattr/docker-credential-helper
matt-richardson Oct 20, 2025
0e5ca6d
Fix bad merge
matt-richardson Oct 20, 2025
a233717
Fix bad merge
matt-richardson Oct 20, 2025
09645a4
Undo changes to RequiresNonMacAttribute
matt-richardson Oct 20, 2025
ede2e57
Discard changes to source/Calamari.Testing/EnvironmentVariables.cs
matt-richardson Oct 20, 2025
f5c7961
Discard changes to source/Calamari.Testing/Requirements/RequiresNonMa…
matt-richardson Oct 20, 2025
bd67658
Discard changes to source/Calamari.Tests/Fixtures/Integration/Package…
matt-richardson Oct 20, 2025
2b617bb
Discard changes to source/Calamari.Testing/EnvironmentVariables.cs
matt-richardson Oct 20, 2025
5998e99
Discard changes to source/Calamari.Tests/KubernetesFixtures/Helm3Upgr…
matt-richardson Oct 20, 2025
e120f7c
Discard changes to source/Calamari.Tests/Fixtures/Integration/Package…
matt-richardson Oct 20, 2025
a483bda
Merge branch 'main' into mattr/docker-credential-helper
matt-richardson Oct 20, 2025
a13c75b
undo changes we dont want
matt-richardson Oct 20, 2025
d6c49a4
Discard changes to source/Calamari.Tests/KubernetesFixtures/InstallTo…
matt-richardson Oct 20, 2025
2b2b59d
Restore BOM encoding for HelmChartPackageDownloaderFixture.cs
matt-richardson Oct 20, 2025
9bb5fd8
Restore BOM encoding for Helm3UpgradeFixture.cs
matt-richardson Oct 20, 2025
2186487
Restore BOM encoding for EnvironmentVariables.cs
matt-richardson Oct 20, 2025
12155ad
Update DockerImagePackageDownloader.cs
matt-richardson Oct 20, 2025
cdc71f7
Merge branch 'main' into mattr/docker-credential-helper
matt-richardson Oct 21, 2025
943282c
Merge branch 'main' into mattr/docker-credential-helper
matt-richardson Feb 16, 2026
a81fb15
Fix merge
matt-richardson Feb 16, 2026
f9133ad
Only use inMemorySink for this specific case
matt-richardson Feb 16, 2026
0544cca
Update CommandResult.cs
matt-richardson Feb 16, 2026
e24ed46
Cleanup
matt-richardson Feb 16, 2026
8e9d725
Discard changes to source/Calamari.Common/Features/Processes/CommandR…
matt-richardson Feb 16, 2026
ba83937
Discard changes to source/Calamari.Common/Features/Processes/CommandR…
matt-richardson Feb 16, 2026
16899d2
Discard changes to source/Calamari.Shared/Integration/Processes/Libra…
matt-richardson Feb 16, 2026
b8ad51d
Discard changes to source/Calamari.Common/Features/Processes/CommandR…
matt-richardson Feb 16, 2026
2640069
Discard changes to source/Calamari.Tests/Fixtures/Deployment/Conventi…
matt-richardson Feb 16, 2026
9f959e5
Merge branch 'main' into mattr/docker-credential-helper
matt-richardson Feb 16, 2026
f6e9332
Add design doc for standalone Docker credential helper app
matt-richardson May 31, 2026
913eeec
Refine fallback: string match is diagnostic, retain output capture
matt-richardson May 31, 2026
fc13d64
Add implementation plan for standalone Docker credential helper app
matt-richardson May 31, 2026
ff7084e
Extract DockerCredentialStore into Calamari.Common
matt-richardson May 31, 2026
ac0cf21
Clarify DockerCredentialStore.Get catch intent; add corrupt-file test
matt-richardson May 31, 2026
129d6ec
Add Calamari.DockerCredentialHelper project and protocol handler
matt-richardson May 31, 2026
47c6283
Handle malformed store input in DockerCredentialProtocol; add tests
matt-richardson May 31, 2026
9e65f87
Add docker-credential-octopus entry point
matt-richardson May 31, 2026
38b1675
Document why docker-credential-octopus requires DOCKER_CONFIG and OCT…
matt-richardson May 31, 2026
62f17c0
Rewire downloader to use standalone credential helper binary
matt-richardson May 31, 2026
77cc20d
Clear credential env on cleanup; capture stderr for login fallback di…
matt-richardson May 31, 2026
732f14f
Revert invasive DeferredLogger plumbing; remove docker-credential sub…
matt-richardson May 31, 2026
160e3df
Overlay docker-credential-octopus into Calamari publish output
matt-richardson May 31, 2026
39defa4
Fail build if helper project missing; sign docker-credential-octopus …
matt-richardson May 31, 2026
a00dd7f
Clean up Docker credential helper test fixtures
matt-richardson May 31, 2026
5902f90
Remove implementation plan doc from PR
matt-richardson Jun 1, 2026
4ea35a2
Remove design spec doc from PR
matt-richardson Jun 1, 2026
38ae180
Revert unrelated BOM change to CommandResult.cs
matt-richardson Jun 1, 2026
1ded4df
Merge remote-tracking branch 'origin/main' into mattr/docker-credenti…
matt-richardson Jun 1, 2026
994bd40
Revert unrelated BOM change to PackagedScriptConventionFixture.cs
matt-richardson Jun 1, 2026
1d6a487
Clean up Calamari.Tests.csproj merge artifacts; keep only the helper …
matt-richardson Jun 1, 2026
ab67070
Strip merge/whitespace artifacts from DockerImagePackageDownloaderFix…
matt-richardson Jun 1, 2026
89f6ff6
Use an ephemeral random password for Docker credential encryption
matt-richardson Jun 1, 2026
6178c81
Fix self-contained helper overlay; restore cleanup log; align test as…
matt-richardson Jun 1, 2026
82d9397
Drop redundant credential pre-store in SetupCredentialHelper
matt-richardson Jun 1, 2026
920223d
Always clean up Docker credential artifacts via try/finally
matt-richardson Jun 1, 2026
1c8a6da
Address remaining review findings (list verb, login fallback, verifie…
matt-richardson Jun 1, 2026
a865fb1
Compare overlay files by version, not raw bytes
matt-richardson Jun 1, 2026
19f9853
Stamp the helper publish with Calamari's version
matt-richardson Jun 1, 2026
74c58fe
Include both versions in the overlay divergence error message
matt-richardson Jun 1, 2026
f110631
Make docker-credential-octopus a standalone trimmed binary in its own…
matt-richardson Jun 1, 2026
6dd6380
Ship docker-credential-octopus in Calamari's top-level folder
matt-richardson Jun 1, 2026
878dbc5
Fix credential-helper integration test: apply trim/single-file at pub…
matt-richardson Jun 1, 2026
d449406
Restore the executable bit on docker-credential-octopus before login
matt-richardson Jun 1, 2026
e80b41b
Adversarial-review hardening: UTF-8 helper IO, warn on plaintext fall…
matt-richardson Jun 2, 2026
5669603
Revert credential-helper fallback log to Verbose
matt-richardson Jun 2, 2026
a83bb87
Restrict credential files to the owner (0600 file, 0700 dir)
matt-richardson Jun 2, 2026
2f6ffd1
Refactor credential-file permission helpers
matt-richardson Jun 2, 2026
b722a66
Cleanup
matt-richardson Jun 2, 2026
44ea463
Merge remote-tracking branch 'origin/main' into mattr/docker-credenti…
matt-richardson Jun 2, 2026
8830f20
Address PR review: convert credential DTOs to records, trim comments
matt-richardson Jun 3, 2026
d1d402d
Remove unused param
matt-richardson Jun 3, 2026
aa8cb03
Merge remote-tracking branch 'origin/main' into mattr/docker-credenti…
matt-richardson Jun 3, 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
33 changes: 33 additions & 0 deletions build/Build.PackageCalamariProjects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,39 @@ public partial class Build

await Task.WhenAll(ridTasks);

// Publish the standalone docker-credential-octopus (a single self-contained,
// trimmed binary) and drop it straight into Calamari's folder. Its name is unique,
// so it doesn't collide with any Calamari file; the downloader adds the Calamari
// folder to PATH so Docker can invoke it, and it gets signed with Calamari's binaries.
Comment thread
APErebus marked this conversation as resolved.
var calamariProject = Solution.AllProjects.FirstOrDefault(p => p.Name == "Calamari")
?? throw new InvalidOperationException("Could not find the 'Calamari' project.");
var helperProject = Solution.AllProjects.FirstOrDefault(p => p.Name == "Calamari.DockerCredentialHelper")
?? throw new InvalidOperationException("Could not find the 'Calamari.DockerCredentialHelper' project.");
foreach (var rid in GetRuntimeIdentifiers(calamariProject))
{
var stagingDirectory = KnownPaths.PublishDirectory / "Calamari.DockerCredentialHelper" / rid;
Log.Information("Publishing docker-credential-octopus for {Rid}", rid);
// Trimming / single-file / invariant-globalization are applied here (not in the
// csproj) so they don't leak into how Calamari.Tests consumes the project reference.
Comment thread
APErebus marked this conversation as resolved.
DotNetPublish(s => s
.SetConfiguration(Configuration)
.SetProject(helperProject)
.SetFramework(Frameworks.Net80)
.SetRuntime(rid)
.SetVersion(NugetVersion.Value)
.SetInformationalVersion(OctoVersionInfo.Value?.InformationalVersion)
.EnableSelfContained()
.EnablePublishSingleFile()
.EnablePublishTrimmed()
.SetOutput(stagingDirectory));

var calamariRidDirectory = (KnownPaths.PublishDirectory / "Calamari" / rid).ToString();
foreach (var helperFile in Directory.GetFiles(stagingDirectory.ToString(), "docker-credential-octopus*"))
File.Copy(helperFile, Path.Combine(calamariRidDirectory, Path.GetFileName(helperFile)), overwrite: true);
Comment thread
APErebus marked this conversation as resolved.

stagingDirectory.DeleteDirectory();
}

// Sign and compress tasks
Log.Information("Signing published binaries...");
var signTasks = outputPaths
Expand Down
4 changes: 3 additions & 1 deletion build/Signing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ public static void SignAndTimestampBinaries(
"Calamari*.exe",
"Calamari*.dll",
"Octo*.exe",
"Octo*.dll")
"Octo*.dll",
"docker-credential-octopus*.exe",
"docker-credential-octopus*.dll")
.Where(f => !HasAuthenticodeSignature(f))
.ToArray();

Expand Down
2 changes: 2 additions & 0 deletions source/Calamari.Common/FeatureToggles/OctopusFeatureToggle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ public static class KnownSlugs
public const string ArgoCDHelmReplacePathFromContainerReferenceFeatureToggle = "argo-cd-helm-replace-path-from-container-reference";
public const string KustomizePatchImageUpdatesFeatureToggle = "kustomize-patch-image-updates";
public const string ArgoRolloutsSupportFeatureToggle = "argo-rollouts-support";
public const string UseDockerCredentialHelper = "calamari-use-docker-credential-helper";
};

public static readonly OctopusFeatureToggle ArgoCDHelmReplacePathFromContainerReferenceFeatureToggle = new(KnownSlugs.ArgoCDHelmReplacePathFromContainerReferenceFeatureToggle);
public static readonly OctopusFeatureToggle KustomizePatchImageUpdatesFeatureToggle = new(KnownSlugs.KustomizePatchImageUpdatesFeatureToggle);
public static readonly OctopusFeatureToggle ArgoRolloutsSupportFeatureToggle = new(KnownSlugs.ArgoRolloutsSupportFeatureToggle);
public static readonly OctopusFeatureToggle UseDockerCredentialHelperFeatureToggle = new(KnownSlugs.UseDockerCredentialHelper);

public class OctopusFeatureToggle
{
Expand Down
14 changes: 12 additions & 2 deletions source/Calamari.Common/Features/Processes/CommandLineRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,26 @@ public class CommandLineRunner : ICommandLineRunner
{
readonly ILog log;
readonly IVariables variables;
readonly ICommandInvocationOutputSink? additionalInvocationOutputSink;

public CommandLineRunner(ILog log, IVariables variables)
: this(log, variables, null)
{
}

public CommandLineRunner(ILog log, IVariables variables, ICommandInvocationOutputSink? additionalInvocationOutputSink = null)
{
this.log = log;
this.variables = variables;
this.additionalInvocationOutputSink = additionalInvocationOutputSink;
}

public CommandResult Execute(CommandLineInvocation invocation)
{
var commandOutput = new SplitCommandInvocationOutputSink(GetCommandOutputs(invocation));
var outputSinks = GetCommandOutputs(invocation);
if (additionalInvocationOutputSink != null)
outputSinks.Add(additionalInvocationOutputSink);
var commandOutput = new SplitCommandInvocationOutputSink(outputSinks);

try
{
Expand Down Expand Up @@ -112,4 +122,4 @@ public static string ConstructWin32ExceptionMessage(string executable)
$"Unable to execute {executable}, please ensure that {executable} is installed and is in the PATH.{Environment.NewLine}";
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.Text;
using Calamari.Common.Plumbing.Commands;

namespace Calamari.Common.Features.Processes
{
public class InMemoryCommandOutputSink : ICommandInvocationOutputSink
{
readonly StringBuilder stdOut = new StringBuilder();
readonly StringBuilder stdErr = new StringBuilder();
public string StdOut => stdOut.ToString();
public string StdErr => stdErr.ToString();

public void WriteInfo(string line)
{
stdOut.AppendLine(line);
}

public void WriteError(string line)
{
stdErr.AppendLine(line);
}
}
}
80 changes: 80 additions & 0 deletions source/Calamari.DockerCredentialHelper/AesEncryption.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

namespace Calamari.DockerCredentialHelper
{
// Self-contained AES used only by docker-credential-octopus to protect the short-lived credential
// files it writes during a package acquisition. The helper both writes (on `docker login`) and
// reads (on `docker pull`) these files, so this does not need to interoperate with Calamari's AesEncryption
public class AesEncryption
{
const int KeySizeBits = 256;
const int BlockSizeBits = 128;
const int PasswordSaltIterations = 1000;
static readonly byte[] PasswordPaddingSalt = Encoding.UTF8.GetBytes("Octopuss");
static readonly byte[] IvPrefix = Encoding.UTF8.GetBytes("IV__");

readonly byte[] encryptionKey;

AesEncryption(string password)
{
encryptionKey = Rfc2898DeriveBytes.Pbkdf2(password, PasswordPaddingSalt, PasswordSaltIterations, HashAlgorithmName.SHA1, KeySizeBits / 8);
}

public static AesEncryption ForScripts(string password) => new AesEncryption(password);

public byte[] Encrypt(string plaintext)
{
var plainTextBytes = Encoding.UTF8.GetBytes(plaintext);
using var algorithm = CreateAlgorithm();
using var encryptor = algorithm.CreateEncryptor();
using var stream = new MemoryStream();

// The IV is random per-encrypt and prepended (after a marker) so Decrypt can recover it.
stream.Write(IvPrefix, 0, IvPrefix.Length);
stream.Write(algorithm.IV, 0, algorithm.IV.Length);
using (var cryptoStream = new CryptoStream(stream, encryptor, CryptoStreamMode.Write))
cryptoStream.Write(plainTextBytes, 0, plainTextBytes.Length);

return stream.ToArray();
}

public string Decrypt(byte[] encrypted)
{
var aesBytes = ExtractIV(encrypted, out var iv);
using var algorithm = CreateAlgorithm();
algorithm.IV = iv;
using var decryptor = algorithm.CreateDecryptor();
using var memoryStream = new MemoryStream(aesBytes);
using var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read);
using var reader = new StreamReader(cryptoStream, Encoding.UTF8);
return reader.ReadToEnd();
}

Aes CreateAlgorithm()
{
var algorithm = Aes.Create();
algorithm.Mode = CipherMode.CBC;
algorithm.Padding = PaddingMode.PKCS7;
algorithm.KeySize = KeySizeBits;
algorithm.BlockSize = BlockSizeBits;
algorithm.Key = encryptionKey;
return algorithm;
}

static byte[] ExtractIV(byte[] encrypted, out byte[] iv)
{
var ivLength = BlockSizeBits / 8;
iv = new byte[ivLength];
Buffer.BlockCopy(encrypted, IvPrefix.Length, iv, 0, ivLength);

var ivDataLength = IvPrefix.Length + ivLength;
var aesDataLength = encrypted.Length - ivDataLength;
var aesData = new byte[aesDataLength];
Buffer.BlockCopy(encrypted, ivDataLength, aesData, 0, aesDataLength);
return aesData;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>docker-credential-octopus</AssemblyName>
Comment thread
APErebus marked this conversation as resolved.
<RootNamespace>Calamari.DockerCredentialHelper</RootNamespace>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;linux-arm;linux-arm64</RuntimeIdentifiers>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<!-- NOTE: trimming/single-file are applied at publish time by build/Build.PackageCalamariProjects.cs,
NOT here. Setting them as project properties leaks into how Calamari.Tests consumes this
project reference and produces a non-runnable apphost in the test output. -->
</PropertyGroup>

<ItemGroup>
<!-- Match the System.Text.Json version the rest of the solution uses, so the assemblies in a
shared output directory (e.g. Calamari.Tests) agree with this project's deps.json. -->
<PackageReference Include="System.Text.Json" Version="9.0.16" />
</ItemGroup>

</Project>
33 changes: 33 additions & 0 deletions source/Calamari.DockerCredentialHelper/CredentialModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;

namespace Calamari.DockerCredentialHelper
{
public record DockerCredential
{
public string Username { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
}

public record StoreRequest
{
public string ServerURL { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
}

public record GetResponse
{
public string ServerURL { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
}

// Source-generated serialization keeps System.Text.Json trim-safe (no reflection), so the
// published binary can be trimmed without losing (de)serialization of these types.
[JsonSerializable(typeof(DockerCredential))]
[JsonSerializable(typeof(StoreRequest))]
[JsonSerializable(typeof(GetResponse))]
internal partial class CredentialJsonContext : JsonSerializerContext
{
}
}
96 changes: 96 additions & 0 deletions source/Calamari.DockerCredentialHelper/DockerCredentialProtocol.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System;
using System.IO;
using System.Text.Json;

namespace Calamari.DockerCredentialHelper
{
public class DockerCredentialProtocol
{
readonly DockerCredentialStore store;

public DockerCredentialProtocol(DockerCredentialStore store)
{
this.store = store;
}

public int Run(string operation, TextReader input, TextWriter output, TextWriter error, string encryptionPassword, string dockerConfigPath)
{
switch (operation.ToLowerInvariant())
{
case "store":
return Store(input, error, encryptionPassword, dockerConfigPath);
case "get":
return Get(input, output, error, encryptionPassword, dockerConfigPath);
case "erase":
return Erase(input, dockerConfigPath);
case "list":
return List(output);
default:
error.WriteLine($"Invalid operation: {operation}. Valid operations are: store, get, erase, list");
return 1;
}
}

// Docker's 'list' expects a JSON map of ServerURL -> Username. We don't enumerate stored
// credentials (they're short-lived, per-acquisition), so we report none.
int List(TextWriter output)
{
output.WriteLine("{}");
return 0;
}

// Docker sends a JSON object on stdin for 'store'.
int Store(TextReader input, TextWriter error, string encryptionPassword, string dockerConfigPath)
{
StoreRequest? request;
try
{
request = JsonSerializer.Deserialize(input.ReadToEnd(), CredentialJsonContext.Default.StoreRequest);
}
catch (Exception)
{
error.WriteLine("Invalid store request");
return 1;
}

if (request == null || string.IsNullOrEmpty(request.ServerURL))
{
error.WriteLine("Invalid store request");
return 1;
}

store.Store(request.ServerURL, request.Username, request.Secret, encryptionPassword, dockerConfigPath);
return 0;
}

// Docker sends a bare server URL line on stdin for 'get' and 'erase'.
int Get(TextReader input, TextWriter output, TextWriter error, string encryptionPassword, string dockerConfigPath)
{
var serverUrl = input.ReadLine()?.Trim();
if (string.IsNullOrEmpty(serverUrl))
{
error.WriteLine("No server URL provided");
return 1;
}

var credential = store.Get(serverUrl, encryptionPassword, dockerConfigPath);
if (credential == null)
{
error.WriteLine("credentials not found in native keychain");
return 1;
}

var response = new GetResponse { ServerURL = serverUrl, Username = credential.Username, Secret = credential.Secret };
output.WriteLine(JsonSerializer.Serialize(response, CredentialJsonContext.Default.GetResponse));
return 0;
}

int Erase(TextReader input, string dockerConfigPath)
{
var serverUrl = input.ReadLine()?.Trim();
if (!string.IsNullOrEmpty(serverUrl))
store.Erase(serverUrl, dockerConfigPath);
return 0;
}
}
}
Loading