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