diff --git a/build/Build.PackageCalamariProjects.cs b/build/Build.PackageCalamariProjects.cs
index f218838c9c..000ae8b3a9 100644
--- a/build/Build.PackageCalamariProjects.cs
+++ b/build/Build.PackageCalamariProjects.cs
@@ -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.
+ 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.
+ 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);
+
+ stagingDirectory.DeleteDirectory();
+ }
+
// Sign and compress tasks
Log.Information("Signing published binaries...");
var signTasks = outputPaths
diff --git a/build/Signing.cs b/build/Signing.cs
index fef330ecea..88676b54e0 100644
--- a/build/Signing.cs
+++ b/build/Signing.cs
@@ -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();
diff --git a/source/Calamari.Common/FeatureToggles/OctopusFeatureToggle.cs b/source/Calamari.Common/FeatureToggles/OctopusFeatureToggle.cs
index a92256c777..8047a069a8 100644
--- a/source/Calamari.Common/FeatureToggles/OctopusFeatureToggle.cs
+++ b/source/Calamari.Common/FeatureToggles/OctopusFeatureToggle.cs
@@ -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
{
diff --git a/source/Calamari.Common/Features/Processes/CommandLineRunner.cs b/source/Calamari.Common/Features/Processes/CommandLineRunner.cs
index 46eec48982..2256b1ad0f 100644
--- a/source/Calamari.Common/Features/Processes/CommandLineRunner.cs
+++ b/source/Calamari.Common/Features/Processes/CommandLineRunner.cs
@@ -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
{
@@ -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}";
}
}
-}
\ No newline at end of file
+}
diff --git a/source/Calamari.Common/Features/Processes/InMemoryCommandOutputSink.cs b/source/Calamari.Common/Features/Processes/InMemoryCommandOutputSink.cs
new file mode 100644
index 0000000000..27a20e898b
--- /dev/null
+++ b/source/Calamari.Common/Features/Processes/InMemoryCommandOutputSink.cs
@@ -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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Calamari.DockerCredentialHelper/AesEncryption.cs b/source/Calamari.DockerCredentialHelper/AesEncryption.cs
new file mode 100644
index 0000000000..d90d30c609
--- /dev/null
+++ b/source/Calamari.DockerCredentialHelper/AesEncryption.cs
@@ -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;
+ }
+ }
+}
diff --git a/source/Calamari.DockerCredentialHelper/Calamari.DockerCredentialHelper.csproj b/source/Calamari.DockerCredentialHelper/Calamari.DockerCredentialHelper.csproj
new file mode 100644
index 0000000000..347a26eec4
--- /dev/null
+++ b/source/Calamari.DockerCredentialHelper/Calamari.DockerCredentialHelper.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+ docker-credential-octopus
+ Calamari.DockerCredentialHelper
+ net8.0
+ win-x64;linux-x64;osx-x64;linux-arm;linux-arm64
+ enable
+ true
+ false
+
+
+
+
+
+
+
+
+
diff --git a/source/Calamari.DockerCredentialHelper/CredentialModels.cs b/source/Calamari.DockerCredentialHelper/CredentialModels.cs
new file mode 100644
index 0000000000..8f68ca60a3
--- /dev/null
+++ b/source/Calamari.DockerCredentialHelper/CredentialModels.cs
@@ -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
+ {
+ }
+}
diff --git a/source/Calamari.DockerCredentialHelper/DockerCredentialProtocol.cs b/source/Calamari.DockerCredentialHelper/DockerCredentialProtocol.cs
new file mode 100644
index 0000000000..b61a25526e
--- /dev/null
+++ b/source/Calamari.DockerCredentialHelper/DockerCredentialProtocol.cs
@@ -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;
+ }
+ }
+}
diff --git a/source/Calamari.DockerCredentialHelper/DockerCredentialStore.cs b/source/Calamari.DockerCredentialHelper/DockerCredentialStore.cs
new file mode 100644
index 0000000000..c4f106b32f
--- /dev/null
+++ b/source/Calamari.DockerCredentialHelper/DockerCredentialStore.cs
@@ -0,0 +1,78 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Text.Json;
+
+namespace Calamari.DockerCredentialHelper
+{
+ public class DockerCredentialStore
+ {
+ const string CredentialsDirectory = "credentials";
+
+ public void Store(string serverUrl, string username, string secret, string encryptionPassword, string dockerConfigPath)
+ {
+ var credentialsDir = Path.Combine(dockerConfigPath, CredentialsDirectory);
+ Directory.CreateDirectory(credentialsDir);
+ RestrictDirectoryToOwner(credentialsDir);
+
+ var credential = new DockerCredential { Username = username, Secret = secret };
+ var credentialJson = JsonSerializer.Serialize(credential, CredentialJsonContext.Default.DockerCredential);
+
+ var encryptor = AesEncryption.ForScripts(encryptionPassword);
+ var encryptedBytes = encryptor.Encrypt(credentialJson);
+
+ var filePath = Path.Combine(credentialsDir, GetCredentialFileName(serverUrl));
+ File.WriteAllBytes(filePath, encryptedBytes);
+ RestrictFileToOwner(filePath);
+ }
+
+ static void RestrictDirectoryToOwner(string path) => RestrictToOwner(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+
+ static void RestrictFileToOwner(string path) => RestrictToOwner(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+
+ // The credential files only contain ciphertext, but restrict them to the owner anyway as
+ // defense-in-depth (dir 0700, file 0600). No-op on Windows, which has no Unix file modes.
+ static void RestrictToOwner(string path, UnixFileMode mode)
+ {
+ if (!OperatingSystem.IsWindows())
+ File.SetUnixFileMode(path, mode);
+ }
+
+ public DockerCredential? Get(string serverUrl, string encryptionPassword, string dockerConfigPath)
+ {
+ var filePath = Path.Combine(dockerConfigPath, CredentialsDirectory, GetCredentialFileName(serverUrl));
+ if (!File.Exists(filePath))
+ return null;
+
+ try
+ {
+ var encryptedBytes = File.ReadAllBytes(filePath);
+ var encryptor = AesEncryption.ForScripts(encryptionPassword);
+ var credentialJson = encryptor.Decrypt(encryptedBytes);
+ return JsonSerializer.Deserialize(credentialJson, CredentialJsonContext.Default.DockerCredential);
+ }
+ // A missing, corrupt, or wrong-password credential is treated as "not found".
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ public void Erase(string serverUrl, string dockerConfigPath)
+ {
+ var filePath = Path.Combine(dockerConfigPath, CredentialsDirectory, GetCredentialFileName(serverUrl));
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+
+ public static string GetCredentialFileName(string serverUrl)
+ {
+ var serverBytes = Encoding.UTF8.GetBytes(serverUrl);
+ var base64Server = Convert.ToBase64String(serverBytes)
+ .Replace("/", "_")
+ .Replace("+", "-")
+ .Replace("=", "");
+ return $"{base64Server}.cred";
+ }
+ }
+}
diff --git a/source/Calamari.DockerCredentialHelper/Program.cs b/source/Calamari.DockerCredentialHelper/Program.cs
new file mode 100644
index 0000000000..7c826cb6cb
--- /dev/null
+++ b/source/Calamari.DockerCredentialHelper/Program.cs
@@ -0,0 +1,56 @@
+using System;
+using System.IO;
+using System.Text;
+
+namespace Calamari.DockerCredentialHelper
+{
+ public static class Program
+ {
+ public static int Main(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.Error.WriteLine("A credential operation is required (store, get, erase)");
+ return 1;
+ }
+
+ var operation = args[0];
+ // This helper is only ever invoked by Docker during a Calamari package acquisition.
+ // Calamari sets DOCKER_CONFIG (where the encrypted .cred files live) and
+ // OCTOPUS_CREDENTIAL_PASSWORD (the decryption key) before invoking Docker, and Docker
+ // passes the environment through to this process. Both are therefore required.
+ var encryptionPassword = Environment.GetEnvironmentVariable("OCTOPUS_CREDENTIAL_PASSWORD");
+ var dockerConfigPath = Environment.GetEnvironmentVariable("DOCKER_CONFIG");
+
+ if (string.IsNullOrEmpty(encryptionPassword))
+ {
+ Console.Error.WriteLine("OCTOPUS_CREDENTIAL_PASSWORD environment variable not set");
+ return 1;
+ }
+
+ if (string.IsNullOrEmpty(dockerConfigPath))
+ {
+ Console.Error.WriteLine("DOCKER_CONFIG environment variable not set");
+ return 1;
+ }
+
+ try
+ {
+ // Read/write the protocol streams as UTF-8 explicitly. Docker exchanges UTF-8 JSON over
+ // stdin/stdout; relying on Console's default encoding would mangle non-ASCII credentials
+ // on platforms whose console code page isn't UTF-8 (e.g. Windows).
+ var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
+ using var input = new StreamReader(Console.OpenStandardInput(), utf8);
+ using var output = new StreamWriter(Console.OpenStandardOutput(), utf8) { AutoFlush = true };
+
+ var protocol = new DockerCredentialProtocol(new DockerCredentialStore());
+ return protocol.Run(operation, input, output, Console.Error, encryptionPassword, dockerConfigPath);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Docker credential operation failed: {ex.Message}");
+ return 1;
+ }
+ }
+ }
+}
diff --git a/source/Calamari.Shared/Integration/Packages/Download/DockerCredentialHelper.cs b/source/Calamari.Shared/Integration/Packages/Download/DockerCredentialHelper.cs
new file mode 100644
index 0000000000..4265313079
--- /dev/null
+++ b/source/Calamari.Shared/Integration/Packages/Download/DockerCredentialHelper.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Calamari.Common.Plumbing;
+using Calamari.Common.Plumbing.Extensions;
+using Calamari.Common.Plumbing.Logging;
+using Newtonsoft.Json;
+
+namespace Calamari.Integration.Packages.Download
+{
+ public class DockerCredentialHelper
+ {
+ // Docker resolves credential helpers by the binary name `docker-credential-`.
+ const string CredentialHelperName = "octopus";
+
+ readonly ILog log;
+
+ public DockerCredentialHelper(ILog log)
+ {
+ this.log = log;
+ }
+
+ public void SetupCredentialHelper(Dictionary environmentVariables,
+ Uri feedUri,
+ string dockerHubRegistry)
+ {
+ try
+ {
+ var dockerConfigPath = environmentVariables["DOCKER_CONFIG"];
+ Directory.CreateDirectory(dockerConfigPath);
+
+ // Ephemeral password for the duration of the acquisition.
+ var encryptionPassword = AesEncryption.RandomString(16);
+
+ EnsureCredentialHelperIsExecutable();
+ AddDirectoryToPath(environmentVariables, AppContext.BaseDirectory);
+ environmentVariables["OCTOPUS_CREDENTIAL_PASSWORD"] = encryptionPassword;
+
+ CreateDockerConfig(dockerConfigPath, BuildCredHelpers(feedUri, dockerHubRegistry));
+
+ log.Verbose($"Configured Docker credential helper for {GetServerUrlForCredentialHelper(feedUri, dockerHubRegistry)}");
+ }
+ catch (Exception ex)
+ {
+ log.Warn($"Failed to setup credential helper: {ex.Message}");
+ }
+ }
+
+ public void CleanupCredentialHelper(Dictionary environmentVariables)
+ {
+ try
+ {
+ if (environmentVariables.TryGetValue("DOCKER_CONFIG", out var dockerConfigPath))
+ {
+ var credentialsDir = Path.Combine(dockerConfigPath, "credentials");
+ if (Directory.Exists(credentialsDir))
+ {
+ Directory.Delete(credentialsDir, recursive: true);
+ log.Verbose("Cleaned up Docker credential files");
+ }
+
+ var configFilePath = Path.Combine(dockerConfigPath, "config.json");
+ if (File.Exists(configFilePath))
+ File.Delete(configFilePath);
+ }
+
+ environmentVariables.Remove("OCTOPUS_CREDENTIAL_PASSWORD");
+ RemoveDirectoryFromPath(environmentVariables, AppContext.BaseDirectory);
+ }
+ catch (Exception ex)
+ {
+ log.Verbose($"Failed to cleanup credential helper files: {ex.Message}");
+ }
+ }
+
+ public string CreateDockerConfig(string dockerConfigPath, Dictionary credHelpers)
+ {
+ var config = new DockerConfig { CredHelpers = credHelpers };
+ var configJson = JsonConvert.SerializeObject(config, Formatting.Indented);
+ var configFilePath = Path.Combine(dockerConfigPath, "config.json");
+ File.WriteAllText(configFilePath, configJson);
+ return configFilePath;
+ }
+
+ public static string GetServerUrlForCredentialHelper(Uri feedUri, string dockerHubRegistry)
+ {
+ if (feedUri.Host.Equals(dockerHubRegistry, StringComparison.OrdinalIgnoreCase))
+ return "https://index.docker.io/v1/";
+
+ return feedUri.GetLeftPart(UriPartial.Authority);
+ }
+
+ static Dictionary BuildCredHelpers(Uri feedUri, string dockerHubRegistry)
+ {
+ var credHelpers = new Dictionary();
+ if (feedUri.Host.Equals(dockerHubRegistry, StringComparison.OrdinalIgnoreCase))
+ {
+ credHelpers["index.docker.io"] = CredentialHelperName;
+ credHelpers["docker.io"] = CredentialHelperName;
+ credHelpers["registry-1.docker.io"] = CredentialHelperName;
+ credHelpers["https://index.docker.io/v1/"] = CredentialHelperName;
+ }
+ else
+ {
+ credHelpers[feedUri.Host] = CredentialHelperName;
+ if (feedUri.Port != -1 && feedUri.Port != 80 && feedUri.Port != 443)
+ credHelpers[$"{feedUri.Host}:{feedUri.Port}"] = CredentialHelperName;
+ }
+
+ return credHelpers;
+ }
+
+ static void AddDirectoryToPath(Dictionary environmentVariables, string directory)
+ {
+ var pathSeparator = CalamariEnvironment.IsRunningOnWindows ? ";" : ":";
+ var currentPath = environmentVariables.TryGetValue("PATH", out var existing)
+ ? existing
+ : Environment.GetEnvironmentVariable("PATH") ?? "";
+
+ if (!currentPath.Split(pathSeparator.ToCharArray()).Contains(directory))
+ environmentVariables["PATH"] = $"{directory}{pathSeparator}{currentPath}";
+ }
+
+ static void RemoveDirectoryFromPath(Dictionary environmentVariables, string directory)
+ {
+ if (!environmentVariables.TryGetValue("PATH", out var currentPath))
+ return;
+
+ var pathSeparator = CalamariEnvironment.IsRunningOnWindows ? ";" : ":";
+ var parts = currentPath.Split(pathSeparator.ToCharArray()).Where(p => p != directory);
+ environmentVariables["PATH"] = string.Join(pathSeparator, parts);
+ }
+
+ static void EnsureCredentialHelperIsExecutable()
+ {
+ if (CalamariEnvironment.IsRunningOnWindows)
+ return;
+
+ var helperPath = Path.Combine(AppContext.BaseDirectory, "docker-credential-octopus");
+ if (!File.Exists(helperPath))
+ return;
+
+ var mode = File.GetUnixFileMode(helperPath);
+ File.SetUnixFileMode(helperPath, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
+ }
+ }
+
+ public class DockerConfig
+ {
+ [JsonProperty("credHelpers")]
+ public Dictionary CredHelpers { get; set; } = new Dictionary();
+ }
+}
diff --git a/source/Calamari.Shared/Integration/Packages/Download/DockerImagePackageDownloader.cs b/source/Calamari.Shared/Integration/Packages/Download/DockerImagePackageDownloader.cs
index 8bd77e96ae..0b6362815d 100644
--- a/source/Calamari.Shared/Integration/Packages/Download/DockerImagePackageDownloader.cs
+++ b/source/Calamari.Shared/Integration/Packages/Download/DockerImagePackageDownloader.cs
@@ -1,14 +1,13 @@
using System;
using System.Collections.Generic;
-using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using Calamari.Common.Commands;
-using Calamari.Common.Features.EmbeddedResources;
using Calamari.Common.Features.Packages;
using Calamari.Common.Features.Processes;
using Calamari.Common.Features.Scripting;
+using Calamari.Common.FeatureToggles;
using Calamari.Common.Features.Scripts;
using Calamari.Common.Plumbing;
using Calamari.Common.Plumbing.FileSystem;
@@ -19,17 +18,18 @@
namespace Calamari.Integration.Packages.Download
{
- // Note about moving this class: GetScript method uses the namespace of this class as part of the
+ // Note about moving this class: the ScriptExtractor.GetScript method uses the namespace of this class as part of the
// get Embedded Resource to find the DockerLogin and DockerPull scripts. If you move this file, be sure look at that method
// and make sure it can still find the scripts
public class DockerImagePackageDownloader : IPackageDownloader
{
readonly IScriptEngine scriptEngine;
readonly ICalamariFileSystem fileSystem;
- readonly ICommandLineRunner commandLineRunner;
readonly IVariables variables;
readonly ILog log;
readonly IFeedLoginDetailsProviderFactory feedLoginDetailsProviderFactory;
+ readonly bool useCredentialHelper;
+ readonly DockerCredentialHelper dockerCredentialHelper;
const string DockerHubRegistry = "index.docker.io";
static readonly HashSet SupportedLoginDetailsFeedTypes = new HashSet
@@ -39,27 +39,29 @@ public class DockerImagePackageDownloader : IPackageDownloader
FeedType.GoogleContainerRegistry
};
+ const string DockerConfigFolder = "./octo-docker-configs";
+
// Ensures that any credential details are only available for the duration of the acquisition
readonly Dictionary environmentVariables = new Dictionary()
{
{
- "DOCKER_CONFIG", "./octo-docker-configs"
+ "DOCKER_CONFIG", DockerConfigFolder
}
};
public DockerImagePackageDownloader(IScriptEngine scriptEngine,
ICalamariFileSystem fileSystem,
- ICommandLineRunner commandLineRunner,
IVariables variables,
ILog log,
IFeedLoginDetailsProviderFactory feedLoginDetailsProviderFactory)
{
this.scriptEngine = scriptEngine;
this.fileSystem = fileSystem;
- this.commandLineRunner = commandLineRunner;
this.variables = variables;
this.log = log;
this.feedLoginDetailsProviderFactory = feedLoginDetailsProviderFactory;
+ this.useCredentialHelper = OctopusFeatureToggles.UseDockerCredentialHelperFeatureToggle.IsEnabled(variables);
+ this.dockerCredentialHelper = new DockerCredentialHelper(log);
}
(string Username, string Password, Uri FeedUri) GetContainerRegistryLoginDetails(string feedTypeStr, string username, string password, Uri feedUri)
@@ -98,21 +100,55 @@ public PackagePhysicalFileMetadata DownloadPackage(string packageId,
var feedHost = GetFeedHost(feedUri);
var strategy = PackageDownloaderRetryUtils.CreateRetryStrategy(maxDownloadAttempts, downloadAttemptBackoff, log);
- strategy.Execute(() => PerformLogin(username, password, feedHost));
-
- const string cachedWorkerToolsShortLink = "https://g.octopushq.com/CachedWorkerToolsImages";
- var imageNotCachedMessage =
- "The docker image '{0}' may not be cached." + " Please note images that have not been cached may take longer to be acquired than expected." + " Your deployment will begin as soon as all images have been pulled." + $" Please see {cachedWorkerToolsShortLink} for more information on cached worker-tools image versions.";
- if (!IsImageCached(fullImageName))
+ try
{
- log.InfoFormat(imageNotCachedMessage, fullImageName);
- }
+ var credentialHelperConfigured = useCredentialHelper && !string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password);
+ if (credentialHelperConfigured)
+ {
+ strategy.Execute(() => dockerCredentialHelper.SetupCredentialHelper(environmentVariables, feedUri, DockerHubRegistry));
+ }
+
+ try
+ {
+ strategy.Execute(() => PerformLogin(username, password, feedHost, environmentVariables));
+ }
+ catch (CommandException) when (credentialHelperConfigured)
+ {
+ // The credential-helper login failed (after retries); tear the helper down and retry
+ // login once without it. (Docker emits its own "stored unencrypted" warning in this case.)
+ log.Verbose("Docker login failed while the credential helper was enabled; retrying without the credential helper.");
+ dockerCredentialHelper.CleanupCredentialHelper(environmentVariables);
+ strategy.Execute(() => PerformLogin(username, password, feedHost, environmentVariables));
+ }
+
+ const string cachedWorkerToolsShortLink = "https://g.octopushq.com/CachedWorkerToolsImages";
+ var imageNotCachedMessage =
+ "The docker image '{0}' may not be cached." + " Please note images that have not been cached may take longer to be acquired than expected." + " Your deployment will begin as soon as all images have been pulled." + $" Please see {cachedWorkerToolsShortLink} for more information on cached worker-tools image versions.";
+
+ if (!IsImageCached(fullImageName))
+ {
+ log.InfoFormat(imageNotCachedMessage, fullImageName);
+ }
+
+ strategy.Execute(() => PerformPull(fullImageName, environmentVariables));
+
+ var (hash, size) = GetImageDetails(fullImageName);
- strategy.Execute(() => PerformPull(fullImageName));
+ return new PackagePhysicalFileMetadata(new PackageFileNameMetadata(packageId, version, version, ""), string.Empty, hash, size);
+ }
+ finally
+ {
+ // Always remove the temporary Docker config and any credential-helper artifacts,
+ // even if login/pull/inspect throws, so credentials are never left on disk.
+ if (useCredentialHelper)
+ {
+ dockerCredentialHelper.CleanupCredentialHelper(environmentVariables);
+ }
- var (hash, size) = GetImageDetails(fullImageName);
- return new PackagePhysicalFileMetadata(new PackageFileNameMetadata(packageId, version, version, ""), string.Empty, hash, size);
+ if (fileSystem.DirectoryExists(DockerConfigFolder))
+ fileSystem.DeleteDirectory(DockerConfigFolder);
+ }
}
static string GetFullImageName(string packageId, IVersion version, Uri feedUri)
@@ -137,19 +173,24 @@ static string GetFeedHost(Uri feedUri)
return $"{feedUri.Host}:{feedUri.Port}";
}
- void PerformLogin(string? username, string? password, string feed)
+ void PerformLogin(string? username, string? password, string feed, Dictionary environmentVariables)
{
- var result = ExecuteScript("DockerLogin",
- new Dictionary
- {
- ["DockerUsername"] = username,
- ["DockerPassword"] = password,
- ["FeedUri"] = feed
- });
+ var dockerLoginEnvironmentVariables = new Dictionary(environmentVariables);
+ dockerLoginEnvironmentVariables["DockerUsername"] = username;
+ dockerLoginEnvironmentVariables["DockerPassword"] = password;
+ dockerLoginEnvironmentVariables["FeedUri"] = feed;
+
+ var (result, output) = ExecuteScript("DockerLogin", dockerLoginEnvironmentVariables);
if (result == null)
throw new CommandException("Null result attempting to log in Docker registry");
if (result.ExitCode != 0)
+ {
+ // Diagnostic only: surface the most common credential-helper failure when we see it.
+ if (useCredentialHelper && output.Contains("Error saving credentials"))
+ log.Verbose("Docker login failed due to a credential helper error.");
+
throw new CommandException("Unable to log in Docker registry");
+ }
}
bool IsImageCached(string fullImageName)
@@ -166,22 +207,21 @@ bool IsImageCached(string fullImageName)
return cachedDigests.Intersect(selectedDigests).Any();
}
- void PerformPull(string fullImageName)
+ void PerformPull(string fullImageName, Dictionary dictionary)
{
- var result = ExecuteScript("DockerPull",
- new Dictionary
- {
- ["Image"] = fullImageName
- });
+ var envVars = new Dictionary(dictionary);
+ envVars["Image"] = fullImageName;
+
+ var (result, _) = ExecuteScript("DockerPull", envVars);
if (result == null)
throw new CommandException("Null result attempting to pull Docker image");
if (result.ExitCode != 0)
throw new CommandException("Unable to pull Docker image");
}
- CommandResult ExecuteScript(string scriptName, Dictionary envVars)
+ (CommandResult CommandResult, string StdOut) ExecuteScript(string scriptName, Dictionary envVars)
{
- var file = GetScript(scriptName);
+ var file = ScriptExtractor.GetScript(fileSystem, scriptName, "Octopus.");
using (new TemporaryFile(file))
{
var clone = variables.Clone();
@@ -190,7 +230,10 @@ CommandResult ExecuteScript(string scriptName, Dictionary envVa
clone[keyValuePair.Key] = keyValuePair.Value;
}
- return scriptEngine.Execute(new Script(file), clone, commandLineRunner, environmentVariables);
+ var inMemorySink = new InMemoryCommandOutputSink();
+ var commandLineRunner = new CommandLineRunner(log, clone, inMemorySink);
+ var commandResult = scriptEngine.Execute(new Script(file), clone, commandLineRunner, environmentVariables);
+ return (commandResult, inMemorySink.StdOut + inMemorySink.StdErr);
}
}
@@ -290,28 +333,5 @@ CommandResult ExecuteScript(string scriptName, Dictionary envVa
return null;
}
}
-
- string GetScript(string scriptName)
- {
- var syntax = ScriptSyntaxHelper.GetPreferredScriptSyntaxForEnvironment();
-
- string contextFile;
- switch (syntax)
- {
- case ScriptSyntax.Bash:
- contextFile = $"{scriptName}.sh";
- break;
- case ScriptSyntax.PowerShell:
- contextFile = $"{scriptName}.ps1";
- break;
- default:
- throw new InvalidOperationException("No kubernetes context wrapper exists for " + syntax);
- }
-
- var scriptFile = Path.Combine(".", $"Octopus.{contextFile}");
- var contextScript = new AssemblyEmbeddedResources().GetEmbeddedResourceText(Assembly.GetExecutingAssembly(), $"{typeof(DockerImagePackageDownloader).Namespace}.Scripts.{contextFile}");
- fileSystem.OverwriteFile(scriptFile, contextScript);
- return scriptFile;
- }
}
-}
\ No newline at end of file
+}
diff --git a/source/Calamari.Shared/Integration/Packages/Download/PackageDownloaderStrategy.cs b/source/Calamari.Shared/Integration/Packages/Download/PackageDownloaderStrategy.cs
index 478d7aa327..e72d33ce06 100644
--- a/source/Calamari.Shared/Integration/Packages/Download/PackageDownloaderStrategy.cs
+++ b/source/Calamari.Shared/Integration/Packages/Download/PackageDownloaderStrategy.cs
@@ -68,7 +68,7 @@ public PackagePhysicalFileMetadata DownloadPackage(string packageId,
case FeedType.AwsElasticContainerRegistry:
case FeedType.AzureContainerRegistry:
case FeedType.GoogleContainerRegistry:
- downloader = new DockerImagePackageDownloader(engine, fileSystem, commandLineRunner, variables, log, new FeedLoginDetailsProviderFactory());
+ downloader = new DockerImagePackageDownloader(engine, fileSystem, variables, log, new FeedLoginDetailsProviderFactory());
break;
case FeedType.S3:
downloader = new S3PackageDownloader(variables, log, fileSystem);
diff --git a/source/Calamari.Shared/Integration/Packages/Download/ScriptExtractor.cs b/source/Calamari.Shared/Integration/Packages/Download/ScriptExtractor.cs
new file mode 100644
index 0000000000..79d7cc8d73
--- /dev/null
+++ b/source/Calamari.Shared/Integration/Packages/Download/ScriptExtractor.cs
@@ -0,0 +1,35 @@
+using System;
+using System.IO;
+using System.Reflection;
+using Calamari.Common.Features.EmbeddedResources;
+using Calamari.Common.Features.Scripts;
+using Calamari.Common.Plumbing.FileSystem;
+
+namespace Calamari.Integration.Packages.Download
+{
+ public static class ScriptExtractor
+ {
+ internal static string GetScript(ICalamariFileSystem fileSystem, string scriptName, string? outputFileNamePrefix = null)
+ {
+ var syntax = ScriptSyntaxHelper.GetPreferredScriptSyntaxForEnvironment();
+
+ string contextFile;
+ switch (syntax)
+ {
+ case ScriptSyntax.Bash:
+ contextFile = $"{scriptName}.sh";
+ break;
+ case ScriptSyntax.PowerShell:
+ contextFile = $"{scriptName}.ps1";
+ break;
+ default:
+ throw new InvalidOperationException("No script wrapper exists for " + syntax);
+ }
+
+ var scriptFile = Path.Combine(".", $"{outputFileNamePrefix}{contextFile}");
+ var contextScript = new AssemblyEmbeddedResources().GetEmbeddedResourceText(Assembly.GetExecutingAssembly(), $"{typeof(DockerImagePackageDownloader).Namespace}.Scripts.{contextFile}");
+ fileSystem.OverwriteFile(scriptFile, contextScript);
+ return scriptFile;
+ }
+ }
+}
diff --git a/source/Calamari.Tests/Calamari.Tests.csproj b/source/Calamari.Tests/Calamari.Tests.csproj
index 26f119a297..1efbc4eb60 100644
--- a/source/Calamari.Tests/Calamari.Tests.csproj
+++ b/source/Calamari.Tests/Calamari.Tests.csproj
@@ -37,6 +37,7 @@
+
diff --git a/source/Calamari.Tests/Fixtures/Docker/DockerCredentialProtocolFixture.cs b/source/Calamari.Tests/Fixtures/Docker/DockerCredentialProtocolFixture.cs
new file mode 100644
index 0000000000..ea48701f14
--- /dev/null
+++ b/source/Calamari.Tests/Fixtures/Docker/DockerCredentialProtocolFixture.cs
@@ -0,0 +1,105 @@
+using System.IO;
+using Calamari.DockerCredentialHelper;
+using NUnit.Framework;
+
+namespace Calamari.Tests.Fixtures.Docker
+{
+ [TestFixture]
+ public class DockerCredentialProtocolFixture
+ {
+ const string Password = "password123";
+ string configPath;
+ DockerCredentialProtocol protocol;
+
+ [SetUp]
+ public void SetUp()
+ {
+ configPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(configPath);
+ protocol = new DockerCredentialProtocol(new DockerCredentialStore());
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(configPath))
+ Directory.Delete(configPath, true);
+ }
+
+ [Test]
+ public void Store_ThenGet_WritesCredentialJsonToStdout()
+ {
+ var storeInput = new StringReader("{\"ServerURL\":\"https://example.com\",\"Username\":\"alice\",\"Secret\":\"s3cret\"}");
+ var storeExit = protocol.Run("store", storeInput, new StringWriter(), new StringWriter(), Password, configPath);
+ Assert.That(storeExit, Is.EqualTo(0));
+
+ var getOutput = new StringWriter();
+ var getExit = protocol.Run("get", new StringReader("https://example.com"), getOutput, new StringWriter(), Password, configPath);
+
+ Assert.That(getExit, Is.EqualTo(0));
+ Assert.That(getOutput.ToString(), Does.Contain("alice"));
+ Assert.That(getOutput.ToString(), Does.Contain("s3cret"));
+ }
+
+ [Test]
+ public void Get_WhenMissing_ReturnsExitOneAndNotFoundMessage()
+ {
+ var error = new StringWriter();
+ var exit = protocol.Run("get", new StringReader("https://example.com"), new StringWriter(), error, Password, configPath);
+
+ Assert.That(exit, Is.EqualTo(1));
+ Assert.That(error.ToString(), Does.Contain("credentials not found"));
+ }
+
+ [Test]
+ public void Erase_RemovesCredential()
+ {
+ protocol.Run("store",
+ new StringReader("{\"ServerURL\":\"https://example.com\",\"Username\":\"alice\",\"Secret\":\"s3cret\"}"),
+ new StringWriter(), new StringWriter(), Password, configPath);
+
+ var eraseExit = protocol.Run("erase", new StringReader("https://example.com"), new StringWriter(), new StringWriter(), Password, configPath);
+ Assert.That(eraseExit, Is.EqualTo(0));
+
+ var getExit = protocol.Run("get", new StringReader("https://example.com"), new StringWriter(), new StringWriter(), Password, configPath);
+ Assert.That(getExit, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void Run_WithUnknownOperation_ReturnsExitOne()
+ {
+ var error = new StringWriter();
+ var exit = protocol.Run("bogus", new StringReader(""), new StringWriter(), error, Password, configPath);
+
+ Assert.That(exit, Is.EqualTo(1));
+ Assert.That(error.ToString(), Does.Contain("Invalid operation"));
+ }
+
+ [Test]
+ public void Store_WithMalformedJson_ReturnsExitOneAndError()
+ {
+ var error = new StringWriter();
+ var exit = protocol.Run("store", new StringReader("not json"), new StringWriter(), error, Password, configPath);
+
+ Assert.That(exit, Is.EqualTo(1));
+ Assert.That(error.ToString(), Does.Contain("Invalid store request"));
+ }
+
+ [Test]
+ public void Store_WithEmptyInput_ReturnsExitOne()
+ {
+ var exit = protocol.Run("store", new StringReader(""), new StringWriter(), new StringWriter(), Password, configPath);
+ Assert.That(exit, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void List_ReturnsEmptyJsonObjectAndExitZero()
+ {
+ var output = new StringWriter();
+ var exit = protocol.Run("list", new StringReader(""), output, new StringWriter(), Password, configPath);
+
+ Assert.That(exit, Is.EqualTo(0));
+ Assert.That(output.ToString().Trim(), Is.EqualTo("{}"));
+ }
+ }
+}
diff --git a/source/Calamari.Tests/Fixtures/Docker/DockerCredentialStoreFixture.cs b/source/Calamari.Tests/Fixtures/Docker/DockerCredentialStoreFixture.cs
new file mode 100644
index 0000000000..4822baabc5
--- /dev/null
+++ b/source/Calamari.Tests/Fixtures/Docker/DockerCredentialStoreFixture.cs
@@ -0,0 +1,78 @@
+using System.IO;
+using Calamari.DockerCredentialHelper;
+using NUnit.Framework;
+
+namespace Calamari.Tests.Fixtures.Docker
+{
+ [TestFixture]
+ public class DockerCredentialStoreFixture
+ {
+ string configPath;
+
+ [SetUp]
+ public void SetUp()
+ {
+ configPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(configPath);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(configPath))
+ Directory.Delete(configPath, true);
+ }
+
+ [Test]
+ public void StoreThenGet_RoundTripsCredentials()
+ {
+ var store = new DockerCredentialStore();
+ store.Store("https://index.docker.io/v1/", "alice", "s3cret", "password123", configPath);
+
+ var result = store.Get("https://index.docker.io/v1/", "password123", configPath);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result!.Username, Is.EqualTo("alice"));
+ Assert.That(result.Secret, Is.EqualTo("s3cret"));
+ }
+
+ [Test]
+ public void Get_WithWrongPassword_ReturnsNull()
+ {
+ var store = new DockerCredentialStore();
+ store.Store("https://example.com", "bob", "pw", "password123", configPath);
+
+ Assert.That(store.Get("https://example.com", "WrongPassword", configPath), Is.Null);
+ }
+
+ [Test]
+ public void Get_WhenNoCredentialStored_ReturnsNull()
+ {
+ var store = new DockerCredentialStore();
+ Assert.That(store.Get("https://example.com", "password123", configPath), Is.Null);
+ }
+
+ [Test]
+ public void Erase_RemovesStoredCredential()
+ {
+ var store = new DockerCredentialStore();
+ store.Store("https://example.com", "bob", "pw", "password123", configPath);
+
+ store.Erase("https://example.com", configPath);
+
+ Assert.That(store.Get("https://example.com", "password123", configPath), Is.Null);
+ }
+
+ [Test]
+ public void Get_WithCorruptCredentialFile_ReturnsNull()
+ {
+ var serverUrl = "https://example.com";
+ var credentialsDir = Path.Combine(configPath, "credentials");
+ Directory.CreateDirectory(credentialsDir);
+ File.WriteAllBytes(Path.Combine(credentialsDir, DockerCredentialStore.GetCredentialFileName(serverUrl)), new byte[] { 1, 2, 3, 4, 5 });
+
+ var store = new DockerCredentialStore();
+ Assert.That(store.Get(serverUrl, "password123", configPath), Is.Null);
+ }
+ }
+}
diff --git a/source/Calamari.Tests/Fixtures/Integration/Packages/DockerCredentialHelperFixture.cs b/source/Calamari.Tests/Fixtures/Integration/Packages/DockerCredentialHelperFixture.cs
new file mode 100644
index 0000000000..c06ea41d02
--- /dev/null
+++ b/source/Calamari.Tests/Fixtures/Integration/Packages/DockerCredentialHelperFixture.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Calamari.Common.Plumbing.Logging;
+using FluentAssertions;
+using NSubstitute;
+using NUnit.Framework;
+
+namespace Calamari.Tests.Fixtures.Integration.Packages
+{
+ [TestFixture]
+ public class DockerCredentialHelperFixture
+ {
+ string tempDirectory;
+ string dockerConfigPath;
+
+ [SetUp]
+ public void Setup()
+ {
+ tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ dockerConfigPath = Path.Combine(tempDirectory, "docker-config");
+ Directory.CreateDirectory(dockerConfigPath);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(tempDirectory))
+ {
+ Directory.Delete(tempDirectory, recursive: true);
+ }
+ }
+
+ [Test]
+ public void CreateDockerConfig_CreatesValidConfigFile()
+ {
+ // Arrange
+ var credHelpers = new Dictionary
+ {
+ ["index.docker.io"] = "octopus",
+ ["docker.io"] = "octopus",
+ ["myregistry.com"] = "octopus"
+ };
+ var credentialHelper = new Calamari.Integration.Packages.Download.DockerCredentialHelper(Substitute.For());
+
+ // Act
+ var configPath = credentialHelper.CreateDockerConfig(dockerConfigPath, credHelpers);
+
+ // Assert
+ File.Exists(configPath).Should().BeTrue();
+ configPath.Should().Be(Path.Combine(dockerConfigPath, "config.json"));
+
+ var configContent = File.ReadAllText(configPath);
+ configContent.Should().Contain("\"credHelpers\"");
+ configContent.Should().Contain("\"index.docker.io\": \"octopus\"");
+ configContent.Should().Contain("\"docker.io\": \"octopus\"");
+ configContent.Should().Contain("\"myregistry.com\": \"octopus\"");
+ }
+
+ [Test]
+ public void GetServerUrlForCredentialHelper_HandlesDockerHubCorrectly()
+ {
+ var dockerHubUri = new Uri("https://index.docker.io");
+
+ var result = Calamari.Integration.Packages.Download.DockerCredentialHelper.GetServerUrlForCredentialHelper(dockerHubUri, "index.docker.io");
+
+ result.Should().Be("https://index.docker.io/v1/");
+ }
+
+ [Test]
+ public void GetServerUrlForCredentialHelper_HandlesCustomRegistryCorrectly()
+ {
+ var customUri = new Uri("https://myregistry.com:8080");
+
+ var result = Calamari.Integration.Packages.Download.DockerCredentialHelper.GetServerUrlForCredentialHelper(customUri, "index.docker.io");
+
+ result.Should().Be("https://myregistry.com:8080");
+ }
+ }
+}
diff --git a/source/Calamari.Tests/Fixtures/Integration/Packages/DockerImagePackageDownloaderCredentialHelperFixture.cs b/source/Calamari.Tests/Fixtures/Integration/Packages/DockerImagePackageDownloaderCredentialHelperFixture.cs
new file mode 100644
index 0000000000..a636520ee5
--- /dev/null
+++ b/source/Calamari.Tests/Fixtures/Integration/Packages/DockerImagePackageDownloaderCredentialHelperFixture.cs
@@ -0,0 +1,262 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Calamari.Common.Commands;
+using Calamari.Common.Features.Processes;
+using Calamari.Common.Features.Scripting;
+using Calamari.Common.Features.Scripting.DotnetScript;
+using Calamari.Common.FeatureToggles;
+using Calamari.Common.Plumbing.FileSystem;
+using Calamari.Common.Plumbing.Logging;
+using Calamari.Common.Plumbing.Variables;
+using Calamari.Integration.Packages.Download;
+using Calamari.Testing;
+using Calamari.Testing.Helpers;
+using Calamari.Testing.Requirements;
+using FluentAssertions;
+using NUnit.Framework;
+using Octopus.Versioning.Semver;
+
+namespace Calamari.Tests.Fixtures.Integration.Packages
+{
+ [TestFixture]
+ public class DockerImagePackageDownloaderCredentialHelperFixture
+ {
+ static string dockerHubFeedUri;
+ static string dockerTestUsername;
+ static string dockerTestPassword;
+ static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource();
+ readonly CancellationToken cancellationToken = CancellationTokenSource.Token;
+ static readonly string Home = Path.GetTempPath();
+
+ [OneTimeSetUp]
+ public async Task TestFixtureSetUp()
+ {
+ dockerHubFeedUri = await ExternalVariables.Get(ExternalVariable.DockerHubOrgAccessUrl, cancellationToken);
+ dockerTestUsername = await ExternalVariables.Get(ExternalVariable.DockerHubOrgAccessUsername, cancellationToken);
+ dockerTestPassword = await ExternalVariables.Get(ExternalVariable.DockerHubOrgAccessToken, cancellationToken);
+ Environment.SetEnvironmentVariable("TentacleHome", Home);
+ }
+
+ [OneTimeTearDown]
+ public void TestFixtureTearDown()
+ {
+ Environment.SetEnvironmentVariable("TentacleHome", null);
+ }
+
+ [Test]
+ [RequiresDockerInstalled]
+ public void CredentialHelper_EnabledByDefault_UsesCredentialHelper()
+ {
+ // Arrange
+ var log = new InMemoryLog();
+ var variables = new CalamariVariables();
+ variables.Set(KnownVariables.EnabledFeatureToggles, OctopusFeatureToggles.KnownSlugs.UseDockerCredentialHelper);
+ var downloader = GetDownloader(log, variables);
+
+ // Act
+ var pkg = downloader.DownloadPackage("octopusdeploy/octo-prerelease",
+ new SemanticVersion("7.3.7-alpine"), "docker-feed",
+ new Uri(dockerHubFeedUri), dockerTestUsername, dockerTestPassword, true, 1,
+ TimeSpan.FromSeconds(10));
+
+ // Assert
+ pkg.Should().NotBeNull();
+ pkg.PackageId.Should().Be("octopusdeploy/octo-prerelease");
+
+ // Verify credential helper was configured
+ log.Messages.Should().Contain(m => m.FormattedMessage.Contains("Configured Docker credential helper"));
+ // "Cleaned up Docker credential files" is only logged when the helper's `store` actually
+ // created the credentials directory, so this proves the helper ran (not the fallback).
+ log.Messages.Should().Contain(m => m.FormattedMessage.Contains("Cleaned up Docker credential files"));
+
+ // The credential helper must have been used, NOT the plaintext fallback.
+ log.Messages.Should().NotContain(m => m.FormattedMessage.Contains("retrying without the credential helper"));
+
+ // Verify no unencrypted credential warnings in the log
+ log.Messages.Should().NotContain(m => m.FormattedMessage.Contains("Your password will be stored unencrypted in octo-docker-configs/config.json"));
+ }
+
+ [Test]
+ [RequiresDockerInstalled]
+ public void CredentialHelper_ExplicitlyDisabled_UsesFallbackLogin()
+ {
+ // Arrange
+ var log = new InMemoryLog();
+ var variables = new CalamariVariables();
+
+ var downloader = GetDownloader(log, variables);
+
+ // Act
+ var pkg = downloader.DownloadPackage("octopusdeploy/octo-prerelease",
+ new SemanticVersion("7.3.7-alpine"), "docker-feed",
+ new Uri(dockerHubFeedUri), dockerTestUsername, dockerTestPassword, true, 1,
+ TimeSpan.FromSeconds(10));
+
+ // Assert
+ pkg.Should().NotBeNull();
+ pkg.PackageId.Should().Be("octopusdeploy/octo-prerelease");
+
+ // Verify credential helper was NOT used
+ log.Messages.Should().NotContain(m => m.FormattedMessage.Contains("Configured Docker credential helper"));
+ log.Messages.Should().NotContain(m => m.FormattedMessage.Contains("Cleaned up Docker credential files"));
+ }
+
+ [Test]
+ [RequiresDockerInstalled]
+ public void CredentialHelper_WithoutCredentials_SkipsCredentialHelper()
+ {
+ // Arrange
+ var log = new InMemoryLog();
+ var variables = new CalamariVariables();
+ variables.Set(KnownVariables.EnabledFeatureToggles, OctopusFeatureToggles.KnownSlugs.UseDockerCredentialHelper);
+ var downloader = GetDownloader(log, variables);
+
+ // Act
+ var pkg = downloader.DownloadPackage("alpine",
+ new SemanticVersion("3.6.5"), "docker-feed",
+ new Uri(dockerHubFeedUri), null, null, true, 1,
+ TimeSpan.FromSeconds(10));
+
+ // Assert
+ pkg.Should().NotBeNull();
+ pkg.PackageId.Should().Be("alpine");
+
+ // Verify credential helper was NOT used when no credentials provided
+ log.Messages.Should().NotContain(m => m.FormattedMessage.Contains("Configured Docker credential helper"));
+ }
+
+ [Test]
+ [RequiresDockerInstalled]
+ public void CredentialHelper_CreatesCorrectDockerConfig_ForDockerHub()
+ {
+ // Arrange
+ var log = new InMemoryLog();
+ var variables = new CalamariVariables();
+ variables.Set(KnownVariables.EnabledFeatureToggles, OctopusFeatureToggles.KnownSlugs.UseDockerCredentialHelper);
+ var downloader = GetDownloader(log, variables);
+
+ // Act
+ var pkg = downloader.DownloadPackage("octopusdeploy/octo-prerelease",
+ new SemanticVersion("7.3.7-alpine"), "docker-feed",
+ new Uri(dockerHubFeedUri), dockerTestUsername, dockerTestPassword, true, 1,
+ TimeSpan.FromSeconds(10));
+
+ // Verify no unencrypted credential warnings in the log
+ log.Messages.Should().NotContain(m => m.FormattedMessage.Contains("Your password will be stored unencrypted in octo-docker-configs/config.json"));
+
+ // The Docker config should have been created and then cleaned up
+ log.Messages.Should().Contain(m => m.FormattedMessage.Contains("Configured Docker credential helper"));
+ log.Messages.Should().Contain(m => m.FormattedMessage.Contains("index.docker.io"));
+
+ pkg.Should().NotBeNull();
+ pkg.PackageId.Should().Be("octopusdeploy/octo-prerelease");
+ }
+
+ [Test]
+ [RequiresDockerInstalled]
+ public void CredentialHelper_WithCustomRegistry_CreatesCorrectConfig()
+ {
+ // Arrange - Using a public registry that doesn't require auth for this test
+ var log = new InMemoryLog();
+ var variables = new CalamariVariables();
+ variables.Set(KnownVariables.EnabledFeatureToggles, OctopusFeatureToggles.KnownSlugs.UseDockerCredentialHelper);
+ var downloader = GetDownloader(log, variables);
+
+ var customRegistryUri = new Uri("https://quay.io");
+
+ // Act - Download a public image to test config creation (even though auth isn't needed)
+ try
+ {
+ // This will attempt to set up credential helper but fall back gracefully
+ downloader.DownloadPackage("coreos/etcd",
+ new SemanticVersion("v3.5.0"), "docker-feed",
+ customRegistryUri, "dummyuser", "dummypass", true, 1,
+ TimeSpan.FromSeconds(10));
+ }
+ catch (CommandException)
+ {
+ // Expected - dummy credentials will fail, but we can still verify config setup
+ }
+
+ // Assert
+ log.Messages.Should().Contain(m => m.FormattedMessage.Contains("Failed to setup credential helper") ||
+ m.FormattedMessage.Contains("Configured Docker credential helper"));
+ }
+
+ [Test]
+ [RequiresDockerInstalled]
+ public void CredentialHelper_FailureFallback_ContinuesWithDirectLogin()
+ {
+ // Arrange
+ var log = new InMemoryLog();
+ var variables = new CalamariVariables();
+ variables.Set(KnownVariables.EnabledFeatureToggles, OctopusFeatureToggles.KnownSlugs.UseDockerCredentialHelper);
+
+ var downloader = GetDownloader(log, variables);
+
+ // Act
+ var pkg = downloader.DownloadPackage("octopusdeploy/octo-prerelease",
+ new SemanticVersion("7.3.7-alpine"), "docker-feed",
+ new Uri(dockerHubFeedUri), dockerTestUsername, dockerTestPassword, true, 1,
+ TimeSpan.FromSeconds(10));
+
+ // Assert
+ pkg.Should().NotBeNull();
+ pkg.PackageId.Should().Be("octopusdeploy/octo-prerelease");
+
+ // Should either succeed with credential helper or fall back gracefully
+ var hasCredentialHelperMessage = log.Messages.Any(m => m.FormattedMessage.Contains("Configured Docker credential helper"));
+ var hasFallbackMessage = log.Messages.Any(m => m.FormattedMessage.Contains("retrying without the credential helper"));
+
+ (hasCredentialHelperMessage || hasFallbackMessage).Should().BeTrue("Either credential helper should work or fallback should occur");
+ }
+
+ [Test]
+ [RequiresDockerInstalled]
+ public void CredentialHelper_MultipleRegistries_HandlesCorrectly()
+ {
+ // Arrange
+ var log = new InMemoryLog();
+ var variables = new CalamariVariables();
+ variables.Set(KnownVariables.EnabledFeatureToggles, OctopusFeatureToggles.KnownSlugs.UseDockerCredentialHelper);
+ var downloader = GetDownloader(log, variables);
+
+
+ // Act - Download from Docker Hub first
+ var pkg1 = downloader.DownloadPackage("octopusdeploy/octo-prerelease",
+ new SemanticVersion("7.3.7-alpine"), "docker-feed",
+ new Uri(dockerHubFeedUri), dockerTestUsername, dockerTestPassword, true, 1,
+ TimeSpan.FromSeconds(10));
+
+ // Then download from the same registry again (should reuse or recreate config)
+ var pkg2 = downloader.DownloadPackage("alpine",
+ new SemanticVersion("3.6.5"), "docker-feed",
+ new Uri(dockerHubFeedUri), null, null, true, 1,
+ TimeSpan.FromSeconds(10));
+
+ // Assert
+ pkg1.Should().NotBeNull();
+ pkg2.Should().NotBeNull();
+
+ // First download should use credential helper
+ log.Messages.Should().Contain(m => m.FormattedMessage.Contains("Configured Docker credential helper"));
+
+ // Both downloads should succeed
+ pkg1.PackageId.Should().Be("octopusdeploy/octo-prerelease");
+ pkg2.PackageId.Should().Be("alpine");
+ }
+
+ static DockerImagePackageDownloader GetDownloader(ILog log, IVariables variables)
+ {
+ return new DockerImagePackageDownloader(
+ new ScriptEngine(Enumerable.Empty(), log),
+ CalamariPhysicalFileSystem.GetPhysicalFileSystem(),
+ variables,
+ log,
+ new FeedLoginDetailsProviderFactory());
+ }
+ }
+}
diff --git a/source/Calamari.Tests/Fixtures/Integration/Packages/DockerImagePackageDownloaderFixture.cs b/source/Calamari.Tests/Fixtures/Integration/Packages/DockerImagePackageDownloaderFixture.cs
index 73f4660da7..64010b09a5 100644
--- a/source/Calamari.Tests/Fixtures/Integration/Packages/DockerImagePackageDownloaderFixture.cs
+++ b/source/Calamari.Tests/Fixtures/Integration/Packages/DockerImagePackageDownloaderFixture.cs
@@ -229,6 +229,37 @@ public void NotCachedNonDockerHubPackage_GeneratesImageNotCachedMessage()
Assert.True(log.Messages.Any(m => m.FormattedMessage.Contains($"The docker image '{imageFullName}:{tag}' may not be cached")));
}
+ [Test]
+ [RequiresDockerInstalled]
+ public void AfterCreatingDockerConfigFile_ShouldRemoveIt()
+ {
+ // Arrange
+ var log = new InMemoryLog();
+ var downloader = GetDownloader(log);
+
+ var dockerConfigPath = "./octo-docker-configs";
+ var configFilePath = Path.Combine(dockerConfigPath, "config.json");
+
+ // Ensure config directory doesn't exist before test
+ if (Directory.Exists(dockerConfigPath))
+ {
+ Directory.Delete(dockerConfigPath, true);
+ }
+
+ // Act
+ var pkg = downloader.DownloadPackage("octopustestaccount/octopetshop-productservice",
+ new SemanticVersion("13.0"), "docker-feed",
+ new Uri(dockerHubFeedUri), dockerTestUsername, dockerTestPassword, true, 1,
+ TimeSpan.FromSeconds(10));
+
+ // Assert
+ pkg.Should().NotBeNull();
+
+ // Verify that the config file was cleaned up (should not exist after download)
+ File.Exists(configFilePath).Should().BeFalse("Config file should be removed after use");
+ Directory.Exists(dockerConfigPath).Should().BeFalse("Config directory should be removed after use");
+ }
+
static void PreCacheImage(string packageId, string tag, string feedUri, string username, string password)
{
GetDownloader(new SilentLog()).DownloadPackage(packageId,
@@ -259,8 +290,7 @@ static DockerImagePackageDownloader GetDownloader()
static DockerImagePackageDownloader GetDownloader(ILog log)
{
- var runner = new CommandLineRunner(log, new CalamariVariables());
- return new DockerImagePackageDownloader(new ScriptEngine(Enumerable.Empty(), log), CalamariPhysicalFileSystem.GetPhysicalFileSystem(), runner, new CalamariVariables(), log, new FeedLoginDetailsProviderFactory());
+ return new DockerImagePackageDownloader(new ScriptEngine(Enumerable.Empty(), log), CalamariPhysicalFileSystem.GetPhysicalFileSystem(), new CalamariVariables(), log, new FeedLoginDetailsProviderFactory());
}
}
}
diff --git a/source/Calamari.sln b/source/Calamari.sln
index 08fb65fd37..c776abd646 100644
--- a/source/Calamari.sln
+++ b/source/Calamari.sln
@@ -86,6 +86,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.AzureWebApp.NetCor
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.Contracts", "Calamari.Contracts\Calamari.Contracts.csproj", "{13583496-C3D2-4ADE-9087-65583326C469}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.DockerCredentialHelper", "Calamari.DockerCredentialHelper\Calamari.DockerCredentialHelper.csproj", "{B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -226,6 +228,10 @@ Global
{13583496-C3D2-4ADE-9087-65583326C469}.Debug|Any CPU.Build.0 = Debug|Any CPU
{13583496-C3D2-4ADE-9087-65583326C469}.Release|Any CPU.ActiveCfg = Release|Any CPU
{13583496-C3D2-4ADE-9087-65583326C469}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE