From e2cd051916fe379d803d1c52e9674aacb6a2eb19 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Mon, 3 Feb 2025 18:40:39 +0100 Subject: [PATCH 01/18] main: Add DOCKER_AUTH_CONFIG support Allow for a docker-credential-helper to be set as DOCKER_AUTH_CONFIG environment variable. Takes precedence over the config.json file. --- .../DockerConfigCredentialsProvider.swift | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift index 6be901f3..342a44d5 100644 --- a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift +++ b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift @@ -1,22 +1,71 @@ import Foundation class DockerConfigCredentialsProvider: CredentialsProvider { + + // MARK: - Dependencies + private let fileManager: FileManaging + private let processInfo: ProcessInformation + + // MARK: - Init + convenience init() { + self.init(fileManager: FileManager.default, processInfo: ProcessInfo.processInfo) + } + + init(fileManager: FileManaging, processInfo: ProcessInformation) { + self.fileManager = fileManager + self.processInfo = processInfo + } + + // MARK: - CredentialsProvider func retrieve(host: String) throws -> (String, String)? { - let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json") - if !FileManager.default.fileExists(atPath: dockerConfigURL.path) { + let configFromEnvironment = try? self.configFromEnvironment() + let configFromFileSystem = try? self.configFromFileSystem() + + guard configFromEnvironment ?? configFromFileSystem != nil else { return nil } - let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL)) - - if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() { - return credentialsFromAuth + + if let config = configFromEnvironment { + if let credentials = config.auths?[host]?.decodeCredentials() { + return credentials + } + + if let helperProgram = try config.findCredHelper(host: host) { + return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) + } } - if let helperProgram = try config.findCredHelper(host: host) { - return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) + + if let config = configFromFileSystem { + if let credentials = config.auths?[host]?.decodeCredentials() { + return credentials + } + + if let helperProgram = try config.findCredHelper(host: host) { + return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) + } } return nil } + + // MARK: - Private + private func configFromEnvironment() throws -> DockerConfig? { + guard let configJson = processInfo.environment["DOCKER_AUTH_CONFIG"]?.data(using: .utf8) else { + return nil + } + + return try JSONDecoder().decode(DockerConfig.self, from: configJson) + } + + private func configFromFileSystem() throws -> DockerConfig? { + let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json") + + if !fileManager.fileExists(atPath: dockerConfigURL.path) { + return nil + } + + return try JSONDecoder().decode(DockerConfig.self, from: fileManager.data(contentsOf: dockerConfigURL)) + } private func executeHelper(binaryName: String, host: String) throws -> (String, String)? { guard let executableURL = resolveBinaryPath(binaryName) else { From b259a76d2f9586c26e62563d0b7224ef5aa8ed14 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Mon, 3 Feb 2025 18:41:07 +0100 Subject: [PATCH 02/18] docker-auth-config-env-support: DOCKER_AUTH_CONFIG tests --- Sources/tart/Utils.swift | 33 +++ ...DockerConfigCredentialsProviderTests.swift | 193 ++++++++++++++++++ Tests/TartTests/Util/MockError.swift | 12 ++ 3 files changed, 238 insertions(+) create mode 100644 Tests/TartTests/DockerConfigCredentialsProviderTests.swift create mode 100644 Tests/TartTests/Util/MockError.swift diff --git a/Sources/tart/Utils.swift b/Sources/tart/Utils.swift index ddb54bbd..6c637be3 100644 --- a/Sources/tart/Utils.swift +++ b/Sources/tart/Utils.swift @@ -22,3 +22,36 @@ func resolveBinaryPath(_ name: String) -> URL? { return nil } + +// MARK: - Protocols for system interfaces to make them mockable + +// MARK: ProcessInfo +/// A protocol resembling required interfaces of `ProcessInfo`. +protocol ProcessInformation { + /// The variable names (keys) and their values in the environment from which the process was launched. + var environment: [String : String] { get } +} + +extension ProcessInfo: ProcessInformation { } + +// MARK: FileManager +protocol FileManaging { + /// Returns a Boolean value that indicates whether a file or directory exists at a specified path. + func fileExists(atPath path: String) -> Bool + + /// Returns the Data contents of the file at the given `url`. Uses `Data(contentsOf:options:)`. + func data(contentsOf url: URL, options: Data.ReadingOptions) throws -> Data +} + +extension FileManaging { + /// Returns the Data contents of the file at the given `url`. Uses `Data(contentsOf:options:)`. + func data(contentsOf url: URL) throws -> Data { + return try data(contentsOf: url, options: []) + } +} + +extension FileManager: FileManaging { + func data(contentsOf url: URL, options: Data.ReadingOptions) throws -> Data { + return try Data(contentsOf: url, options: options) + } +} diff --git a/Tests/TartTests/DockerConfigCredentialsProviderTests.swift b/Tests/TartTests/DockerConfigCredentialsProviderTests.swift new file mode 100644 index 00000000..bdf34ac1 --- /dev/null +++ b/Tests/TartTests/DockerConfigCredentialsProviderTests.swift @@ -0,0 +1,193 @@ +// +// Test.swift +// Tart +// +// Created by Holloh, Niklas on 03.02.25. +// + +import Foundation +import Testing +@testable import tart + +struct DockerConfigCredentialsProviderTests { + + // MARK: - Static & Test data + // credential `hello:world` + private static let exampleComDockerConfigJsonString = "{\"auths\": {\"example.com\": {\"auth\": \"aGVsbG86d29ybGQ=\"}}}" + // credential `pepperoni:pizza + private static let exampleComAlternateDockerConfigJsonString = "{\"auths\": {\"example.com\": {\"auth\": \"cGVwcGVyb25pOnBpenph\"}}}" + // credential `pepperoni:pizza` + private static let coffeeComDockerConfigJsonString = "{\"auths\": {\"coffee.com\": {\"auth\": \"cGVwcGVyb25pOnBpenph\"}}}" + + private class ProcessInfoMock: ProcessInformation { + var environment: [String : String] + + init(environment: [String : String] = [:]) { + self.environment = environment + } + + static let noConfig = ProcessInfoMock() + static let invalidConfig = ProcessInfoMock(environment: ["DOCKER_AUTH_CONFIG": "invalid-json"]) + static let exampleComConfig = ProcessInfoMock(environment: ["DOCKER_AUTH_CONFIG": exampleComDockerConfigJsonString]) + static let exampleComAlternateConfig = ProcessInfoMock(environment: ["DOCKER_AUTH_CONFIG": exampleComDockerConfigJsonString]) + static let coffeeComConfig = ProcessInfoMock(environment: ["DOCKER_AUTH_CONFIG": coffeeComDockerConfigJsonString]) + } + + private class FileManagerMock: FileManaging { + var fileExistsHandler: ((String) -> Bool)? + func fileExists(atPath path: String) -> Bool { + return fileExistsHandler!(path) + } + + var dataHandler: ((URL, Data.ReadingOptions) throws -> Data)? + func data(contentsOf url: URL, options: Data.ReadingOptions) throws -> Data { + guard let dataHandler else { + throw MockError.mockNotConfigured + } + return try dataHandler(url, options) + } + + func configPresent(_ isPresent: Bool = true) -> Self { + fileExistsHandler = { _ in isPresent } + return self + } + + func configContent(_ content: String) -> Self { + dataHandler = { _, _ in Data(content.utf8) } + return self + } + } + + // MARK: - Tests + + @Test func testNilIfNotConfigured() async throws { + // given + let processInfo = ProcessInfoMock.noConfig + let fileManager = FileManagerMock() + .configPresent(false) + let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) + + // when + let resultOptional = try provider.retrieve(host: "example.com") + + // then + #expect(resultOptional == nil) + } + + @Test func testFileSystemConfigIfEnvironmentEmpty() async throws { + // given + let processInfo = ProcessInfoMock.noConfig + let fileManager = FileManagerMock() + .configPresent() + .configContent(Self.coffeeComDockerConfigJsonString) + let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) + + // when + let resultOptional = try provider.retrieve(host: "coffee.com") + + // then + let result = try #require(resultOptional) + #expect(result.0 == "pepperoni") + #expect(result.1 == "pizza") + } + + @Test func testEnvironmentConfigIfFileSystemNil() async throws { + // given + let processInfo = ProcessInfoMock.exampleComConfig + let fileManager = FileManagerMock() + .configPresent(false) + let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) + + // when + let resultOptional = try provider.retrieve(host: "example.com") + + // then + let result = try #require(resultOptional) + #expect(result.0 == "hello") + #expect(result.1 == "world") + } + + @Test func testFileSystemConfigIfEnvironmentInvalid() async throws { + // given + let processInfo = ProcessInfoMock.invalidConfig + let fileManager = FileManagerMock() + .configPresent() + .configContent(Self.coffeeComDockerConfigJsonString) + let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) + + // when + let resultOptional = try provider.retrieve(host: "coffee.com") + + // then + let result = try #require(resultOptional) + #expect(result.0 == "pepperoni") + #expect(result.1 == "pizza") + } + + @Test func testEnvironmentConfigIfFileSystemInvalid() async throws { + // given + let processInfo = ProcessInfoMock.exampleComConfig + let fileManager = FileManagerMock() + .configPresent() + .configContent("invalid-json") + let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) + + // when + let resultOptional = try provider.retrieve(host: "example.com") + + // then + let result = try #require(resultOptional) + #expect(result.0 == "hello") + #expect(result.1 == "world") + } + + @Test func testNilIfAllConfigurationsInvalid() async throws { + // given + let processInfo = ProcessInfoMock.invalidConfig + let fileManager = FileManagerMock() + .configPresent() + .configContent("invalid-json") + let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) + + // when + let resultOptional = try provider.retrieve(host: "coffee.com") + + // then + #expect(resultOptional == nil) + } + + @Test func testLookupInBothSources() async throws { + // given + let processInfo = ProcessInfoMock.exampleComConfig + let fileManager = FileManagerMock() + .configPresent() + .configContent(Self.coffeeComDockerConfigJsonString) + let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) + + // when + let resultOptional = try provider.retrieve(host: "coffee.com") + + // then + let result = try #require(resultOptional) + #expect(result.0 == "pepperoni") + #expect(result.1 == "pizza") + } + + @Test func testEnvironmentPrecedenceIfDuplicateHost() async throws { + // given + let processInfo = ProcessInfoMock.exampleComConfig + let fileManager = FileManagerMock() + .configPresent() + .configContent(Self.exampleComAlternateDockerConfigJsonString) + let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) + + // when + let resultOptional = try provider.retrieve(host: "example.com") + + // then + let result = try #require(resultOptional) + #expect(result.0 == "hello") + #expect(result.1 == "world") + } + +} diff --git a/Tests/TartTests/Util/MockError.swift b/Tests/TartTests/Util/MockError.swift new file mode 100644 index 00000000..754cc039 --- /dev/null +++ b/Tests/TartTests/Util/MockError.swift @@ -0,0 +1,12 @@ +// +// MockError.swift +// Tart +// +// Created by Holloh, Niklas on 03.02.25. +// + +import Foundation + +enum MockError: Error { + case mockNotConfigured +} From 95a5a659f3380f1d056bd8933fd3b04a65c1e837 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Mon, 3 Feb 2025 18:41:21 +0100 Subject: [PATCH 03/18] docker-auth-config-env-support: Add DOCKER_AUTH_CONFIG documentation --- docs/integrations/vm-management.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/integrations/vm-management.md b/docs/integrations/vm-management.md index 4d8202f0..32f88d20 100644 --- a/docs/integrations/vm-management.md +++ b/docs/integrations/vm-management.md @@ -112,11 +112,16 @@ tart login acme.io If you login to your registry with OAuth, you may need to create an access token to use as the password. Credentials are securely stored in Keychain. -In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers) -if defined in `~/.docker/config.json`. - -Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization -for all registries which might useful for integrating with your CI's secret management. +In addition, Tart supports Docker credential helpers via the `DOCKER_AUTH_CONFIG` environment variable or as a file +in `~/.docker/config.json`. While using a JSON file is often more convenient for persistent management, the environment +variable provides greater flexibility, allowing multiple Tart instances to run with different credential helper +configurations—useful in CI/CD environments. The DOCKER_AUTH_CONFIG environment variable takes precedence over the +`config.json` file: if credentials for `example.com` exist in both, the environment variable’s values will be used. +However, if `example.com` is not found in `DOCKER_AUTH_CONFIG`, Tart will fall back to the values in the `config.json`. + +Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override any authorization +for all registries which might useful for integrating with your CI's secret management. No additional lookup for a host-specific authorization +with docker-credential-helpers is performed if these environment variables are set. ### Pushing a Local Image From 37231fedad2861529ac7290de9ea6dd082e2c1fb Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Mon, 3 Feb 2025 18:41:26 +0100 Subject: [PATCH 04/18] docker-auth-config-env-support: Typo fix --- .../{DirecotryShareTests.swift => DirectoryShareTests.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Tests/TartTests/{DirecotryShareTests.swift => DirectoryShareTests.swift} (100%) diff --git a/Tests/TartTests/DirecotryShareTests.swift b/Tests/TartTests/DirectoryShareTests.swift similarity index 100% rename from Tests/TartTests/DirecotryShareTests.swift rename to Tests/TartTests/DirectoryShareTests.swift From ee7c337fbc9f7118c7eb1dd5425fd452a4de0378 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Mon, 3 Feb 2025 18:46:39 +0100 Subject: [PATCH 05/18] docker-auth-config-env-support: SwiftFormat --- .../DockerConfigCredentialsProvider.swift | 28 +++++++++---------- Sources/tart/Utils.swift | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift index 342a44d5..3902a9a4 100644 --- a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift +++ b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift @@ -1,45 +1,45 @@ import Foundation class DockerConfigCredentialsProvider: CredentialsProvider { - + // MARK: - Dependencies private let fileManager: FileManaging private let processInfo: ProcessInformation - + // MARK: - Init convenience init() { self.init(fileManager: FileManager.default, processInfo: ProcessInfo.processInfo) } - + init(fileManager: FileManaging, processInfo: ProcessInformation) { self.fileManager = fileManager self.processInfo = processInfo } - + // MARK: - CredentialsProvider func retrieve(host: String) throws -> (String, String)? { let configFromEnvironment = try? self.configFromEnvironment() let configFromFileSystem = try? self.configFromFileSystem() - + guard configFromEnvironment ?? configFromFileSystem != nil else { return nil } - + if let config = configFromEnvironment { if let credentials = config.auths?[host]?.decodeCredentials() { return credentials } - + if let helperProgram = try config.findCredHelper(host: host) { return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) } } - + if let config = configFromFileSystem { if let credentials = config.auths?[host]?.decodeCredentials() { return credentials } - + if let helperProgram = try config.findCredHelper(host: host) { return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) } @@ -47,23 +47,23 @@ class DockerConfigCredentialsProvider: CredentialsProvider { return nil } - + // MARK: - Private private func configFromEnvironment() throws -> DockerConfig? { guard let configJson = processInfo.environment["DOCKER_AUTH_CONFIG"]?.data(using: .utf8) else { return nil } - + return try JSONDecoder().decode(DockerConfig.self, from: configJson) } - + private func configFromFileSystem() throws -> DockerConfig? { let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json") - + if !fileManager.fileExists(atPath: dockerConfigURL.path) { return nil } - + return try JSONDecoder().decode(DockerConfig.self, from: fileManager.data(contentsOf: dockerConfigURL)) } diff --git a/Sources/tart/Utils.swift b/Sources/tart/Utils.swift index 6c637be3..27ce30d1 100644 --- a/Sources/tart/Utils.swift +++ b/Sources/tart/Utils.swift @@ -38,7 +38,7 @@ extension ProcessInfo: ProcessInformation { } protocol FileManaging { /// Returns a Boolean value that indicates whether a file or directory exists at a specified path. func fileExists(atPath path: String) -> Bool - + /// Returns the Data contents of the file at the given `url`. Uses `Data(contentsOf:options:)`. func data(contentsOf url: URL, options: Data.ReadingOptions) throws -> Data } From e8705fb01bcc239453cb7a79e9cd817e62be5277 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Mon, 3 Feb 2025 19:23:13 +0100 Subject: [PATCH 06/18] docker-auth-config-env-support: Rename DOCKER_AUTH_CONFIG to TART_DOCKER_AUTH_CONFIG --- .../Credentials/DockerConfigCredentialsProvider.swift | 2 +- .../TartTests/DockerConfigCredentialsProviderTests.swift | 8 ++++---- docs/integrations/vm-management.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift index 3902a9a4..e01df8de 100644 --- a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift +++ b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift @@ -50,7 +50,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider { // MARK: - Private private func configFromEnvironment() throws -> DockerConfig? { - guard let configJson = processInfo.environment["DOCKER_AUTH_CONFIG"]?.data(using: .utf8) else { + guard let configJson = processInfo.environment["TART_DOCKER_AUTH_CONFIG"]?.data(using: .utf8) else { return nil } diff --git a/Tests/TartTests/DockerConfigCredentialsProviderTests.swift b/Tests/TartTests/DockerConfigCredentialsProviderTests.swift index bdf34ac1..12bd730f 100644 --- a/Tests/TartTests/DockerConfigCredentialsProviderTests.swift +++ b/Tests/TartTests/DockerConfigCredentialsProviderTests.swift @@ -27,10 +27,10 @@ struct DockerConfigCredentialsProviderTests { } static let noConfig = ProcessInfoMock() - static let invalidConfig = ProcessInfoMock(environment: ["DOCKER_AUTH_CONFIG": "invalid-json"]) - static let exampleComConfig = ProcessInfoMock(environment: ["DOCKER_AUTH_CONFIG": exampleComDockerConfigJsonString]) - static let exampleComAlternateConfig = ProcessInfoMock(environment: ["DOCKER_AUTH_CONFIG": exampleComDockerConfigJsonString]) - static let coffeeComConfig = ProcessInfoMock(environment: ["DOCKER_AUTH_CONFIG": coffeeComDockerConfigJsonString]) + static let invalidConfig = ProcessInfoMock(environment: ["TART_DOCKER_AUTH_CONFIG": "invalid-json"]) + static let exampleComConfig = ProcessInfoMock(environment: ["TART_DOCKER_AUTH_CONFIG": exampleComDockerConfigJsonString]) + static let exampleComAlternateConfig = ProcessInfoMock(environment: ["TART_DOCKER_AUTH_CONFIG": exampleComDockerConfigJsonString]) + static let coffeeComConfig = ProcessInfoMock(environment: ["TART_DOCKER_AUTH_CONFIG": coffeeComDockerConfigJsonString]) } private class FileManagerMock: FileManaging { diff --git a/docs/integrations/vm-management.md b/docs/integrations/vm-management.md index 32f88d20..31b8704d 100644 --- a/docs/integrations/vm-management.md +++ b/docs/integrations/vm-management.md @@ -112,12 +112,12 @@ tart login acme.io If you login to your registry with OAuth, you may need to create an access token to use as the password. Credentials are securely stored in Keychain. -In addition, Tart supports Docker credential helpers via the `DOCKER_AUTH_CONFIG` environment variable or as a file +In addition, Tart supports Docker credential helpers via the `TART_DOCKER_AUTH_CONFIG` environment variable or as a file in `~/.docker/config.json`. While using a JSON file is often more convenient for persistent management, the environment variable provides greater flexibility, allowing multiple Tart instances to run with different credential helper -configurations—useful in CI/CD environments. The DOCKER_AUTH_CONFIG environment variable takes precedence over the +configurations—useful in CI/CD environments. The TART_DOCKER_AUTH_CONFIG environment variable takes precedence over the `config.json` file: if credentials for `example.com` exist in both, the environment variable’s values will be used. -However, if `example.com` is not found in `DOCKER_AUTH_CONFIG`, Tart will fall back to the values in the `config.json`. +However, if `example.com` is not found in `TART_DOCKER_AUTH_CONFIG`, Tart will fall back to the values in the `config.json`. Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override any authorization for all registries which might useful for integrating with your CI's secret management. No additional lookup for a host-specific authorization From 6b9434455aceaf18cd267181d77c4fae5de82b89 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Tue, 4 Feb 2025 18:14:21 +0100 Subject: [PATCH 07/18] docker-auth-config-env-support: Remove unit tests --- ...DockerConfigCredentialsProviderTests.swift | 193 ------------------ 1 file changed, 193 deletions(-) delete mode 100644 Tests/TartTests/DockerConfigCredentialsProviderTests.swift diff --git a/Tests/TartTests/DockerConfigCredentialsProviderTests.swift b/Tests/TartTests/DockerConfigCredentialsProviderTests.swift deleted file mode 100644 index 12bd730f..00000000 --- a/Tests/TartTests/DockerConfigCredentialsProviderTests.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// Test.swift -// Tart -// -// Created by Holloh, Niklas on 03.02.25. -// - -import Foundation -import Testing -@testable import tart - -struct DockerConfigCredentialsProviderTests { - - // MARK: - Static & Test data - // credential `hello:world` - private static let exampleComDockerConfigJsonString = "{\"auths\": {\"example.com\": {\"auth\": \"aGVsbG86d29ybGQ=\"}}}" - // credential `pepperoni:pizza - private static let exampleComAlternateDockerConfigJsonString = "{\"auths\": {\"example.com\": {\"auth\": \"cGVwcGVyb25pOnBpenph\"}}}" - // credential `pepperoni:pizza` - private static let coffeeComDockerConfigJsonString = "{\"auths\": {\"coffee.com\": {\"auth\": \"cGVwcGVyb25pOnBpenph\"}}}" - - private class ProcessInfoMock: ProcessInformation { - var environment: [String : String] - - init(environment: [String : String] = [:]) { - self.environment = environment - } - - static let noConfig = ProcessInfoMock() - static let invalidConfig = ProcessInfoMock(environment: ["TART_DOCKER_AUTH_CONFIG": "invalid-json"]) - static let exampleComConfig = ProcessInfoMock(environment: ["TART_DOCKER_AUTH_CONFIG": exampleComDockerConfigJsonString]) - static let exampleComAlternateConfig = ProcessInfoMock(environment: ["TART_DOCKER_AUTH_CONFIG": exampleComDockerConfigJsonString]) - static let coffeeComConfig = ProcessInfoMock(environment: ["TART_DOCKER_AUTH_CONFIG": coffeeComDockerConfigJsonString]) - } - - private class FileManagerMock: FileManaging { - var fileExistsHandler: ((String) -> Bool)? - func fileExists(atPath path: String) -> Bool { - return fileExistsHandler!(path) - } - - var dataHandler: ((URL, Data.ReadingOptions) throws -> Data)? - func data(contentsOf url: URL, options: Data.ReadingOptions) throws -> Data { - guard let dataHandler else { - throw MockError.mockNotConfigured - } - return try dataHandler(url, options) - } - - func configPresent(_ isPresent: Bool = true) -> Self { - fileExistsHandler = { _ in isPresent } - return self - } - - func configContent(_ content: String) -> Self { - dataHandler = { _, _ in Data(content.utf8) } - return self - } - } - - // MARK: - Tests - - @Test func testNilIfNotConfigured() async throws { - // given - let processInfo = ProcessInfoMock.noConfig - let fileManager = FileManagerMock() - .configPresent(false) - let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) - - // when - let resultOptional = try provider.retrieve(host: "example.com") - - // then - #expect(resultOptional == nil) - } - - @Test func testFileSystemConfigIfEnvironmentEmpty() async throws { - // given - let processInfo = ProcessInfoMock.noConfig - let fileManager = FileManagerMock() - .configPresent() - .configContent(Self.coffeeComDockerConfigJsonString) - let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) - - // when - let resultOptional = try provider.retrieve(host: "coffee.com") - - // then - let result = try #require(resultOptional) - #expect(result.0 == "pepperoni") - #expect(result.1 == "pizza") - } - - @Test func testEnvironmentConfigIfFileSystemNil() async throws { - // given - let processInfo = ProcessInfoMock.exampleComConfig - let fileManager = FileManagerMock() - .configPresent(false) - let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) - - // when - let resultOptional = try provider.retrieve(host: "example.com") - - // then - let result = try #require(resultOptional) - #expect(result.0 == "hello") - #expect(result.1 == "world") - } - - @Test func testFileSystemConfigIfEnvironmentInvalid() async throws { - // given - let processInfo = ProcessInfoMock.invalidConfig - let fileManager = FileManagerMock() - .configPresent() - .configContent(Self.coffeeComDockerConfigJsonString) - let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) - - // when - let resultOptional = try provider.retrieve(host: "coffee.com") - - // then - let result = try #require(resultOptional) - #expect(result.0 == "pepperoni") - #expect(result.1 == "pizza") - } - - @Test func testEnvironmentConfigIfFileSystemInvalid() async throws { - // given - let processInfo = ProcessInfoMock.exampleComConfig - let fileManager = FileManagerMock() - .configPresent() - .configContent("invalid-json") - let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) - - // when - let resultOptional = try provider.retrieve(host: "example.com") - - // then - let result = try #require(resultOptional) - #expect(result.0 == "hello") - #expect(result.1 == "world") - } - - @Test func testNilIfAllConfigurationsInvalid() async throws { - // given - let processInfo = ProcessInfoMock.invalidConfig - let fileManager = FileManagerMock() - .configPresent() - .configContent("invalid-json") - let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) - - // when - let resultOptional = try provider.retrieve(host: "coffee.com") - - // then - #expect(resultOptional == nil) - } - - @Test func testLookupInBothSources() async throws { - // given - let processInfo = ProcessInfoMock.exampleComConfig - let fileManager = FileManagerMock() - .configPresent() - .configContent(Self.coffeeComDockerConfigJsonString) - let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) - - // when - let resultOptional = try provider.retrieve(host: "coffee.com") - - // then - let result = try #require(resultOptional) - #expect(result.0 == "pepperoni") - #expect(result.1 == "pizza") - } - - @Test func testEnvironmentPrecedenceIfDuplicateHost() async throws { - // given - let processInfo = ProcessInfoMock.exampleComConfig - let fileManager = FileManagerMock() - .configPresent() - .configContent(Self.exampleComAlternateDockerConfigJsonString) - let provider = DockerConfigCredentialsProvider(fileManager: fileManager, processInfo: processInfo) - - // when - let resultOptional = try provider.retrieve(host: "example.com") - - // then - let result = try #require(resultOptional) - #expect(result.0 == "hello") - #expect(result.1 == "world") - } - -} From cc11f28fe378eaaadd4c18911d92d90a5bc86629 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Tue, 4 Feb 2025 18:15:06 +0100 Subject: [PATCH 08/18] docker-auth-config-env-support: Switch to exclusive environment precedence based on PR comments --- .../DockerConfigCredentialsProvider.swift | 56 +++++-------------- 1 file changed, 15 insertions(+), 41 deletions(-) diff --git a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift index e01df8de..65e271d1 100644 --- a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift +++ b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift @@ -2,55 +2,29 @@ import Foundation class DockerConfigCredentialsProvider: CredentialsProvider { - // MARK: - Dependencies - private let fileManager: FileManaging - private let processInfo: ProcessInformation - - // MARK: - Init - convenience init() { - self.init(fileManager: FileManager.default, processInfo: ProcessInfo.processInfo) - } - - init(fileManager: FileManaging, processInfo: ProcessInformation) { - self.fileManager = fileManager - self.processInfo = processInfo - } - - // MARK: - CredentialsProvider func retrieve(host: String) throws -> (String, String)? { - let configFromEnvironment = try? self.configFromEnvironment() - let configFromFileSystem = try? self.configFromFileSystem() - - guard configFromEnvironment ?? configFromFileSystem != nil else { + guard let config = try configFromEnvironment() ?? (try configFromFileSystem()) else { return nil } + + return try retrieveCredentials(for: host, from: config) + } - if let config = configFromEnvironment { - if let credentials = config.auths?[host]?.decodeCredentials() { - return credentials - } - - if let helperProgram = try config.findCredHelper(host: host) { - return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) - } + // MARK: - Private + private func retrieveCredentials(for host: String, from config: DockerConfig) throws -> (String, String)? { + if let credentials = config.auths?[host]?.decodeCredentials() { + return credentials } - if let config = configFromFileSystem { - if let credentials = config.auths?[host]?.decodeCredentials() { - return credentials - } - - if let helperProgram = try config.findCredHelper(host: host) { - return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) - } + if let helperProgram = try config.findCredHelper(host: host) { + return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) } - + return nil } - - // MARK: - Private + private func configFromEnvironment() throws -> DockerConfig? { - guard let configJson = processInfo.environment["TART_DOCKER_AUTH_CONFIG"]?.data(using: .utf8) else { + guard let configJson = ProcessInfo.processInfo.environment["TART_DOCKER_AUTH_CONFIG"]?.data(using: .utf8) else { return nil } @@ -60,11 +34,11 @@ class DockerConfigCredentialsProvider: CredentialsProvider { private func configFromFileSystem() throws -> DockerConfig? { let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json") - if !fileManager.fileExists(atPath: dockerConfigURL.path) { + if !FileManager.default.fileExists(atPath: dockerConfigURL.path) { return nil } - return try JSONDecoder().decode(DockerConfig.self, from: fileManager.data(contentsOf: dockerConfigURL)) + return try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL)) } private func executeHelper(binaryName: String, host: String) throws -> (String, String)? { From 44e76f101b75709f1b58dcb2cc0f417b349f684a Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Tue, 4 Feb 2025 18:15:45 +0100 Subject: [PATCH 09/18] docker-auth-config-env-support: Remove now obsolete system protocols --- Sources/tart/Utils.swift | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/Sources/tart/Utils.swift b/Sources/tart/Utils.swift index 27ce30d1..ddb54bbd 100644 --- a/Sources/tart/Utils.swift +++ b/Sources/tart/Utils.swift @@ -22,36 +22,3 @@ func resolveBinaryPath(_ name: String) -> URL? { return nil } - -// MARK: - Protocols for system interfaces to make them mockable - -// MARK: ProcessInfo -/// A protocol resembling required interfaces of `ProcessInfo`. -protocol ProcessInformation { - /// The variable names (keys) and their values in the environment from which the process was launched. - var environment: [String : String] { get } -} - -extension ProcessInfo: ProcessInformation { } - -// MARK: FileManager -protocol FileManaging { - /// Returns a Boolean value that indicates whether a file or directory exists at a specified path. - func fileExists(atPath path: String) -> Bool - - /// Returns the Data contents of the file at the given `url`. Uses `Data(contentsOf:options:)`. - func data(contentsOf url: URL, options: Data.ReadingOptions) throws -> Data -} - -extension FileManaging { - /// Returns the Data contents of the file at the given `url`. Uses `Data(contentsOf:options:)`. - func data(contentsOf url: URL) throws -> Data { - return try data(contentsOf: url, options: []) - } -} - -extension FileManager: FileManaging { - func data(contentsOf url: URL, options: Data.ReadingOptions) throws -> Data { - return try Data(contentsOf: url, options: options) - } -} From 08d8cc575e131ae619bd857b9c5596ebb6a7211f Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Tue, 4 Feb 2025 18:35:14 +0100 Subject: [PATCH 10/18] docker-auth-config-env-support: Attempting docker registry configuration --- integration-tests/docker_registry.py | 38 +++++++++++++++++++++++++++- integration-tests/tart.py | 9 ++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/integration-tests/docker_registry.py b/integration-tests/docker_registry.py index 50b91258..7dfa7a8d 100644 --- a/integration-tests/docker_registry.py +++ b/integration-tests/docker_registry.py @@ -1,4 +1,6 @@ import requests +import tempfile +import subprocess from testcontainers.core.waiting_utils import wait_container_is_ready from testcontainers.core.container import DockerContainer @@ -7,9 +9,43 @@ class DockerRegistry(DockerContainer): _default_exposed_port = 5000 - def __init__(self): + def __init__(self, credentials: tuple[str, str] = None): + """ + Initializes the DockerRegistry container. + + :param credentials: A tuple (username, password). If None, starts the registry without authentication. + """ super().__init__("registry:2") self.with_exposed_ports(self._default_exposed_port) + self.credentials = credentials + + if credentials: + self._configure_basic_auth(credentials) + + def _configure_basic_auth(self, credentials: tuple[str, str]): + username, password = credentials + + # Set required environment variables for basic auth + self.with_env("REGISTRY_AUTH", "htpasswd") + self.with_env("REGISTRY_AUTH_HTPASSWD_PATH", "/auth/htpasswd") + self.with_env("REGISTRY_AUTH_HTPASSWD_REALM", "Registry Realm") + + # Generate and mount the htpasswd file + htpasswd_path = self._generate_htpasswd(username, password) + self.with_volume_mapping(htpasswd_path, "/auth/htpasswd") + + def _generate_htpasswd(self, username: str, password: str) -> str: + temp_file = tempfile.NamedTemporaryFile(delete=False) + temp_file.close() # Close to allow subprocess to write to it + + # Use htpasswd command to create a bcrypt-hashed password file + subprocess.run( + ["htpasswd", "-Bbn", username, password], + stdout=open(temp_file.name, "w"), + check=True + ) + + return temp_file.name @wait_container_is_ready(requests.exceptions.ConnectionError) def remote_name(self, for_vm: str): diff --git a/integration-tests/tart.py b/integration-tests/tart.py index 752b33a6..295bcf98 100644 --- a/integration-tests/tart.py +++ b/integration-tests/tart.py @@ -21,11 +21,12 @@ def __exit__(self, exc_type, exc_val, exc_tb): def home(self) -> str: return self.tmp_dir.name - def run(self, args): - env = os.environ.copy() - env.update({"TART_HOME": self.tmp_dir.name}) + def run(self, args, env = {}): + environ = os.environ.copy() + environ.update(env) + environ.update({"TART_HOME": self.tmp_dir.name}) - completed_process = subprocess.run(["tart"] + args, env=env, capture_output=True) + completed_process = subprocess.run(["tart"] + args, env=environ, capture_output=True) completed_process.check_returncode() From a8be41fdc1d430ef524f82bec8db9ddb73d4f9bf Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Tue, 4 Feb 2025 18:43:14 +0100 Subject: [PATCH 11/18] docker-auth-config-env-support: SwiftFormat --- .../tart/Credentials/DockerConfigCredentialsProvider.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift index 65e271d1..f988f617 100644 --- a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift +++ b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift @@ -6,7 +6,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider { guard let config = try configFromEnvironment() ?? (try configFromFileSystem()) else { return nil } - + return try retrieveCredentials(for: host, from: config) } @@ -19,10 +19,10 @@ class DockerConfigCredentialsProvider: CredentialsProvider { if let helperProgram = try config.findCredHelper(host: host) { return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) } - + return nil } - + private func configFromEnvironment() throws -> DockerConfig? { guard let configJson = ProcessInfo.processInfo.environment["TART_DOCKER_AUTH_CONFIG"]?.data(using: .utf8) else { return nil From 46de7a1d12642896b33b170910c934817234e186 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Thu, 13 Feb 2025 13:25:51 +0100 Subject: [PATCH 12/18] docker-auth-config-env-support: Change to TART_DOCKER_CONFIG to accept file paths --- .../DockerConfigCredentialsProvider.swift | 55 ++++++++++++------- Sources/tart/Utils.swift | 10 ++++ docs/integrations/vm-management.md | 13 +++-- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift index f988f617..037464d0 100644 --- a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift +++ b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift @@ -3,14 +3,47 @@ import Foundation class DockerConfigCredentialsProvider: CredentialsProvider { func retrieve(host: String) throws -> (String, String)? { - guard let config = try configFromEnvironment() ?? (try configFromFileSystem()) else { + guard let configFileURL = try configFileURL else { return nil } - + + let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: configFileURL)) return try retrieveCredentials(for: host, from: config) } // MARK: - Private + private var configFileURLFromEnvironment: URL? { + get throws { + guard let configPathFromEnvironment = ProcessInfo.processInfo.environment["TART_DOCKER_CONFIG"] else { + return nil + } + + let url = URL(filePath: configPathFromEnvironment) + + guard FileManager.default.fileExists(atPath: configPathFromEnvironment) else { + throw NSError.fileNotFoundError(url: url) + } + + return url + } + } + + private var dockerConfigFileURL: URL? { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json") + + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + + return url + } + + private var configFileURL: URL? { + get throws { + return try configFileURLFromEnvironment ?? dockerConfigFileURL + } + } + private func retrieveCredentials(for host: String, from config: DockerConfig) throws -> (String, String)? { if let credentials = config.auths?[host]?.decodeCredentials() { return credentials @@ -23,24 +56,6 @@ class DockerConfigCredentialsProvider: CredentialsProvider { return nil } - private func configFromEnvironment() throws -> DockerConfig? { - guard let configJson = ProcessInfo.processInfo.environment["TART_DOCKER_AUTH_CONFIG"]?.data(using: .utf8) else { - return nil - } - - return try JSONDecoder().decode(DockerConfig.self, from: configJson) - } - - private func configFromFileSystem() throws -> DockerConfig? { - let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json") - - if !FileManager.default.fileExists(atPath: dockerConfigURL.path) { - return nil - } - - return try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL)) - } - private func executeHelper(binaryName: String, host: String) throws -> (String, String)? { guard let executableURL = resolveBinaryPath(binaryName) else { throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH") diff --git a/Sources/tart/Utils.swift b/Sources/tart/Utils.swift index ddb54bbd..01c47a3d 100644 --- a/Sources/tart/Utils.swift +++ b/Sources/tart/Utils.swift @@ -6,6 +6,16 @@ extension Collection { } } +extension NSError { + static func fileNotFoundError(url: URL) -> NSError { + return NSError( + domain: NSCocoaErrorDomain, + code: NSFileReadNoSuchFileError, + userInfo: [NSURLErrorKey: url] + ) + } +} + func resolveBinaryPath(_ name: String) -> URL? { guard let path = ProcessInfo.processInfo.environment["PATH"] else { return nil diff --git a/docs/integrations/vm-management.md b/docs/integrations/vm-management.md index 31b8704d..39295d48 100644 --- a/docs/integrations/vm-management.md +++ b/docs/integrations/vm-management.md @@ -112,12 +112,13 @@ tart login acme.io If you login to your registry with OAuth, you may need to create an access token to use as the password. Credentials are securely stored in Keychain. -In addition, Tart supports Docker credential helpers via the `TART_DOCKER_AUTH_CONFIG` environment variable or as a file -in `~/.docker/config.json`. While using a JSON file is often more convenient for persistent management, the environment -variable provides greater flexibility, allowing multiple Tart instances to run with different credential helper -configurations—useful in CI/CD environments. The TART_DOCKER_AUTH_CONFIG environment variable takes precedence over the -`config.json` file: if credentials for `example.com` exist in both, the environment variable’s values will be used. -However, if `example.com` is not found in `TART_DOCKER_AUTH_CONFIG`, Tart will fall back to the values in the `config.json`. +In addition, Tart supports Docker credential helpers via the `TART_DOCKER_CONFIG` environment variable or as a file +in `~/.docker/config.json`. If `TART_DOCKER_CONFIG` is set, Tart will attempt to load the credential configuration +from the specified file path. If the file cannot be found or the path is invalid, an error is thrown. If the variable +is not set, Tart will default to using `~/.docker/config.json`. + +Using `TART_DOCKER_CONFIG` provides greater flexibility, allowing multiple Tart instances to run with different +credential helper configurations—useful in CI/CD environments. Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override any authorization for all registries which might useful for integrating with your CI's secret management. No additional lookup for a host-specific authorization From 6631cd4f74b9e5b6ac10cf8d361b0fabbeb3e242 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Fri, 14 Feb 2025 11:22:26 +0100 Subject: [PATCH 13/18] docker-auth-config-env-support: added OCI authentication integration tests --- integration-tests/conftest.py | 15 +++++ integration-tests/docker_registry.py | 8 +++ integration-tests/tart.py | 7 ++- integration-tests/test_oci.py | 85 ++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/integration-tests/conftest.py b/integration-tests/conftest.py index f85518b0..81a48e5f 100644 --- a/integration-tests/conftest.py +++ b/integration-tests/conftest.py @@ -14,3 +14,18 @@ def tart(): def docker_registry(): with DockerRegistry() as docker_registry: yield docker_registry + +@pytest.fixture(scope="function") +def docker_registry_authenticated(request): + """ + Provides an authenticated Docker registry where username/password + can be passed dynamically via the test case. + + Usage: + - Add `docker_registry_authenticated` as a test argument. + - Pass `request.param = (username, password)` from the test. + """ + credentials = request.param if hasattr(request, "param") else ("testuser", "testpassword") + + with DockerRegistry(credentials=credentials) as docker_registry: + yield docker_registry \ No newline at end of file diff --git a/integration-tests/docker_registry.py b/integration-tests/docker_registry.py index 7dfa7a8d..c2e1839b 100644 --- a/integration-tests/docker_registry.py +++ b/integration-tests/docker_registry.py @@ -54,3 +54,11 @@ def remote_name(self, for_vm: str): requests.get(f"http://127.0.0.1:{exposed_port}/v2/") return f"127.0.0.1:{exposed_port}/tart/{for_vm}:latest" + + @wait_container_is_ready(requests.exceptions.ConnectionError) + def remote_host(self): + exposed_port = self.get_exposed_port(self._default_exposed_port) + + requests.get(f"http://127.0.0.1:{exposed_port}/v2/") + + return f"127.0.0.1:{exposed_port}" diff --git a/integration-tests/tart.py b/integration-tests/tart.py index 295bcf98..b3c34064 100644 --- a/integration-tests/tart.py +++ b/integration-tests/tart.py @@ -21,16 +21,17 @@ def __exit__(self, exc_type, exc_val, exc_tb): def home(self) -> str: return self.tmp_dir.name - def run(self, args, env = {}): + def run(self, args, env = {}, raise_on_nonzero_returncode=True): environ = os.environ.copy() environ.update(env) environ.update({"TART_HOME": self.tmp_dir.name}) completed_process = subprocess.run(["tart"] + args, env=environ, capture_output=True) - completed_process.check_returncode() + if raise_on_nonzero_returncode: + completed_process.check_returncode - return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8") + return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8"), completed_process.returncode def run_async(self, args) -> subprocess.Popen: env = os.environ.copy() diff --git a/integration-tests/test_oci.py b/integration-tests/test_oci.py index 4489b35b..d9da1359 100644 --- a/integration-tests/test_oci.py +++ b/integration-tests/test_oci.py @@ -1,3 +1,5 @@ +import base64 +import json import os import tempfile import timeit @@ -28,6 +30,72 @@ def test_pull_speed(self, tart, vm_with_random_disk, docker_registry): actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start) assert actual_speed_per_second > minimal_speed_per_second + + @pytest.mark.dependency() + @pytest.mark.parametrize("docker_registry_authenticated", [("user1", "pass1")], indirect=True) + def test_authenticated_push_from_env_config(self, tart, vm_with_random_disk, docker_registry_authenticated): + with tempfile.NamedTemporaryFile(delete=False) as tf: + tf.write(_docker_credentials_store(docker_registry_authenticated.remote_host(), "user1", "pass1")) + tf.close() + tart.run(["push", "--insecure", vm_with_random_disk, docker_registry_authenticated.remote_name(vm_with_random_disk)], env = { "TART_DOCKER_CONFIG": tf.name }) + + @pytest.mark.dependency() + @pytest.mark.parametrize("docker_registry_authenticated", [("user1", "pass1")], indirect=True) + def test_authenticated_push_from_docker_config(self, tart, vm_with_random_disk, docker_registry_authenticated): + with tempfile.NamedTemporaryFile(delete=False) as tf: + tf.write(_docker_credentials_store(docker_registry_authenticated.remote_host(), "user1", "pass1")) + tf.close() + if not os.path.exists(os.path.expanduser("~/.docker")): + os.mkdir(os.path.expanduser("~/.docker")) + os.rename(tf.name, os.path.expanduser("~/.docker/config.json")) + + tart.run(["push", "--insecure", vm_with_random_disk, docker_registry_authenticated.remote_name(vm_with_random_disk)]) + + @pytest.mark.dependency() + @pytest.mark.parametrize("docker_registry_authenticated", [("user1", "pass1")], indirect=True) + def test_authenticated_push_env_path_precedence(self, tart, vm_with_random_disk, docker_registry_authenticated): + with tempfile.NamedTemporaryFile(delete=False) as tf: + tf.write(_docker_credentials_store(docker_registry_authenticated.remote_host(), "user1", "pass1")) + tf.close() + + with tempfile.NamedTemporaryFile(delete=False) as tf2: + tf2.write(_docker_credentials_store(docker_registry_authenticated.remote_host(), "notuser", "notpassword")) + tf2.close() + if not os.path.exists(os.path.expanduser("~/.docker")): + os.mkdir(os.path.expanduser("~/.docker")) + os.rename(tf2.name, os.path.expanduser("~/.docker/config.json")) + + tart.run(["push", "--insecure", vm_with_random_disk, docker_registry_authenticated.remote_name(vm_with_random_disk)], env = { "TART_DOCKER_CONFIG": tf.name }) + + @pytest.mark.dependency() + @pytest.mark.parametrize("docker_registry_authenticated", [("user1", "pass1")], indirect=True) + def test_authenticated_push_env_credentials_precedence(self, tart, vm_with_random_disk, docker_registry_authenticated): + with tempfile.NamedTemporaryFile(delete=False) as tf: + tf.write(_docker_credentials_store(docker_registry_authenticated.remote_host(), "notuser", "notpassword")) + tf.close() + + env = { + "TART_REGISTRY_USERNAME": "user1", + "TART_REGISTRY_PASSWORD": "pass1", + "TART_DOCKER_CONFIG": tf.name + } + tart.run(["push", "--insecure", vm_with_random_disk, docker_registry_authenticated.remote_name(vm_with_random_disk)], env) + + @pytest.mark.dependency() + @pytest.mark.parametrize("docker_registry_authenticated", [("user1", "pass1")], indirect=True) + def test_authenticated_push_invalid_env_path_error(self, tart, vm_with_random_disk, docker_registry_authenticated): + env = { "TART_DOCKER_CONFIG": "/temp/this-file-does-not-exist" } + + _, stderr, returncode = tart.run( + ["push", "--insecure", vm_with_random_disk, docker_registry_authenticated.remote_name(vm_with_random_disk)], + env, + raise_on_nonzero_returncode=False + ) + + expected_error = 'The file “this-file-does-not-exist” couldn’t be opened because there is no such file.' + + assert returncode == 1, f"Tart should fail with exit code 1 but failed with {returncode}." + assert expected_error in stderr, f"Expected error '{expected_error}' not found in stderr: {stderr}" @staticmethod def _calculate_speed_per_second(amount_transferred, time_taken): @@ -53,3 +121,20 @@ def vm_with_random_disk(tart): yield vm_name tart.run(["delete", vm_name]) + +def _docker_credentials_store(host, user, password): + # Encode "username:password" in Base64 + auth_string = f"{user}:{password}" + auth_b64 = base64.b64encode(auth_string.encode()).decode() + + # Create JSON structure + docker_auth = { + "auths": { + host: { + "auth": auth_b64 + } + } + } + + # Convert dictionary to JSON + return json.dumps(docker_auth).encode() From 1078be8f2726c6c23aa1a3c2a2e3537069732176 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Fri, 14 Feb 2025 11:28:07 +0100 Subject: [PATCH 14/18] docker-auth-config-env-support: SwiftFormat fixes --- .../DockerConfigCredentialsProvider.swift | 18 +++++++++--------- Sources/tart/Utils.swift | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift index 037464d0..9557d2d0 100644 --- a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift +++ b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift @@ -6,7 +6,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider { guard let configFileURL = try configFileURL else { return nil } - + let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: configFileURL)) return try retrieveCredentials(for: host, from: config) } @@ -17,33 +17,33 @@ class DockerConfigCredentialsProvider: CredentialsProvider { guard let configPathFromEnvironment = ProcessInfo.processInfo.environment["TART_DOCKER_CONFIG"] else { return nil } - + let url = URL(filePath: configPathFromEnvironment) - + guard FileManager.default.fileExists(atPath: configPathFromEnvironment) else { throw NSError.fileNotFoundError(url: url) } - + return url } } - + private var dockerConfigFileURL: URL? { let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json") - + guard FileManager.default.fileExists(atPath: url.path) else { return nil } - + return url } - + private var configFileURL: URL? { get throws { return try configFileURLFromEnvironment ?? dockerConfigFileURL } } - + private func retrieveCredentials(for host: String, from config: DockerConfig) throws -> (String, String)? { if let credentials = config.auths?[host]?.decodeCredentials() { return credentials diff --git a/Sources/tart/Utils.swift b/Sources/tart/Utils.swift index 01c47a3d..ffb0f9ed 100644 --- a/Sources/tart/Utils.swift +++ b/Sources/tart/Utils.swift @@ -8,11 +8,11 @@ extension Collection { extension NSError { static func fileNotFoundError(url: URL) -> NSError { - return NSError( - domain: NSCocoaErrorDomain, - code: NSFileReadNoSuchFileError, - userInfo: [NSURLErrorKey: url] - ) + return NSError( + domain: NSCocoaErrorDomain, + code: NSFileReadNoSuchFileError, + userInfo: [NSURLErrorKey: url] + ) } } From 53fc395e1ae597af7c3a3dd36cf4d8e233b3e0fe Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Fri, 14 Feb 2025 11:33:46 +0100 Subject: [PATCH 15/18] docker-auth-config-env-support: Remove obsolete MockError --- Tests/TartTests/Util/MockError.swift | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 Tests/TartTests/Util/MockError.swift diff --git a/Tests/TartTests/Util/MockError.swift b/Tests/TartTests/Util/MockError.swift deleted file mode 100644 index 754cc039..00000000 --- a/Tests/TartTests/Util/MockError.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// MockError.swift -// Tart -// -// Created by Holloh, Niklas on 03.02.25. -// - -import Foundation - -enum MockError: Error { - case mockNotConfigured -} From 5f238b5f50190836349516dccc0105dc7c47ed96 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Fri, 14 Feb 2025 11:46:44 +0100 Subject: [PATCH 16/18] docker-auth-config-env-support: Fix integration test unpack --- integration-tests/test_clone.py | 2 +- integration-tests/test_create.py | 4 ++-- integration-tests/test_delete.py | 4 ++-- integration-tests/test_rename.py | 2 +- integration-tests/test_run.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/integration-tests/test_clone.py b/integration-tests/test_clone.py index 62ad9d41..ca3fcdef 100644 --- a/integration-tests/test_clone.py +++ b/integration-tests/test_clone.py @@ -6,5 +6,5 @@ def test_clone(tart): tart.run(["clone", "debian", "ubuntu"]) # Ensure that we have now 2 VMs - stdout, _, = tart.run(["list", "--source", "local", "--quiet"]) + stdout, _, _ = tart.run(["list", "--source", "local", "--quiet"]) assert stdout == "debian\nubuntu\n" diff --git a/integration-tests/test_create.py b/integration-tests/test_create.py index 866b7b63..cbccf37d 100644 --- a/integration-tests/test_create.py +++ b/integration-tests/test_create.py @@ -3,7 +3,7 @@ def test_create_macos(tart): tart.run(["create", "--from-ipsw", "latest", "macos-vm"]) # Ensure that the VM was created - stdout, _ = tart.run(["list", "--source", "local", "--quiet"]) + stdout, _, _ = tart.run(["list", "--source", "local", "--quiet"]) assert stdout == "macos-vm\n" @@ -12,5 +12,5 @@ def test_create_linux(tart): tart.run(["create", "--linux", "linux-vm"]) # Ensure that the VM was created - stdout, _ = tart.run(["list", "--source", "local", "--quiet"]) + stdout, _, _ = tart.run(["list", "--source", "local", "--quiet"]) assert stdout == "linux-vm\n" diff --git a/integration-tests/test_delete.py b/integration-tests/test_delete.py index 1a8ccb33..8b7b99ae 100644 --- a/integration-tests/test_delete.py +++ b/integration-tests/test_delete.py @@ -3,12 +3,12 @@ def test_delete(tart): tart.run(["create", "--linux", "debian"]) # Ensure that the VM exists - stdout, _, = tart.run(["list", "--source", "local", "--quiet"]) + stdout, _, _ = tart.run(["list", "--source", "local", "--quiet"]) assert stdout == "debian\n" # Delete the VM tart.run(["delete", "debian"]) # Ensure that the VM was removed - stdout, _, = tart.run(["list", "--source", "local", "--quiet"]) + stdout, _, _ = tart.run(["list", "--source", "local", "--quiet"]) assert stdout == "" diff --git a/integration-tests/test_rename.py b/integration-tests/test_rename.py index 1ce8ffda..8dc3a9e5 100644 --- a/integration-tests/test_rename.py +++ b/integration-tests/test_rename.py @@ -6,5 +6,5 @@ def test_rename(tart): tart.run(["rename", "debian", "ubuntu"]) # Ensure that the VM is now named "ubuntu" - stdout, _, = tart.run(["list", "--source", "local", "--quiet"]) + stdout, _, _ = tart.run(["list", "--source", "local", "--quiet"]) assert stdout == "ubuntu\n" diff --git a/integration-tests/test_run.py b/integration-tests/test_run.py index f7d7b75a..ac621289 100644 --- a/integration-tests/test_run.py +++ b/integration-tests/test_run.py @@ -15,7 +15,7 @@ def test_run(tart, run_opts): tart_run_process = tart.run_async(["run", vm_name] + run_opts) # Obtain the VM's IP - stdout, _ = tart.run(["ip", vm_name, "--wait", "120"]) + stdout, _, _ = tart.run(["ip", vm_name, "--wait", "120"]) ip = stdout.strip() # Connect to the VM over SSH and shutdown it @@ -29,4 +29,4 @@ def test_run(tart, run_opts): assert tart_run_process.returncode == 0 # Delete the VM - _, _ = tart.run(["delete", vm_name]) + _, _, _ = tart.run(["delete", vm_name]) From 1a5dbed484d74a51637a60be02cf7bef6803e10c Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Fri, 14 Feb 2025 12:06:50 +0100 Subject: [PATCH 17/18] docker-auth-config-env-support: Switch from htpasswd to bcrypt --- integration-tests/docker_registry.py | 14 +++++--------- integration-tests/requirements.txt | 1 + 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/integration-tests/docker_registry.py b/integration-tests/docker_registry.py index c2e1839b..d62966d4 100644 --- a/integration-tests/docker_registry.py +++ b/integration-tests/docker_registry.py @@ -1,6 +1,7 @@ import requests import tempfile import subprocess +import bcrypt from testcontainers.core.waiting_utils import wait_container_is_ready from testcontainers.core.container import DockerContainer @@ -36,15 +37,10 @@ def _configure_basic_auth(self, credentials: tuple[str, str]): def _generate_htpasswd(self, username: str, password: str) -> str: temp_file = tempfile.NamedTemporaryFile(delete=False) - temp_file.close() # Close to allow subprocess to write to it - - # Use htpasswd command to create a bcrypt-hashed password file - subprocess.run( - ["htpasswd", "-Bbn", username, password], - stdout=open(temp_file.name, "w"), - check=True - ) - + hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + htpasswd_entry = f"{username}:{hashed}" + temp_file.write(htpasswd_entry.encode('utf-8')) + temp_file.close() return temp_file.name @wait_container_is_ready(requests.exceptions.ConnectionError) diff --git a/integration-tests/requirements.txt b/integration-tests/requirements.txt index 6695cd72..e731b504 100644 --- a/integration-tests/requirements.txt +++ b/integration-tests/requirements.txt @@ -4,3 +4,4 @@ requests == 2.31.0 # work around https://github.com/psf/requests/issues/6707 bitmath pytest-dependency paramiko +bcrypt \ No newline at end of file From 1f7c44e08fbe08928f6d3633d32b5b0749d5a989 Mon Sep 17 00:00:00 2001 From: Niklas Holloh Date: Fri, 14 Feb 2025 12:30:58 +0100 Subject: [PATCH 18/18] docker-auth-config-env-support: Improved file not found error message --- .../tart/Credentials/DockerConfigCredentialsProvider.swift | 2 +- Sources/tart/Utils.swift | 4 ++-- integration-tests/test_oci.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift index 9557d2d0..f8168856 100644 --- a/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift +++ b/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift @@ -21,7 +21,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider { let url = URL(filePath: configPathFromEnvironment) guard FileManager.default.fileExists(atPath: configPathFromEnvironment) else { - throw NSError.fileNotFoundError(url: url) + throw NSError.fileNotFoundError(url: url, message: "Registry authentication failed. Could not find docker configuration at '\(configPathFromEnvironment)'.") } return url diff --git a/Sources/tart/Utils.swift b/Sources/tart/Utils.swift index ffb0f9ed..33548450 100644 --- a/Sources/tart/Utils.swift +++ b/Sources/tart/Utils.swift @@ -7,11 +7,11 @@ extension Collection { } extension NSError { - static func fileNotFoundError(url: URL) -> NSError { + static func fileNotFoundError(url: URL, message: String = "") -> NSError { return NSError( domain: NSCocoaErrorDomain, code: NSFileReadNoSuchFileError, - userInfo: [NSURLErrorKey: url] + userInfo: [NSURLErrorKey: url, NSFilePathErrorKey: url.path, NSLocalizedFailureErrorKey: message] ) } } diff --git a/integration-tests/test_oci.py b/integration-tests/test_oci.py index d9da1359..e62cadff 100644 --- a/integration-tests/test_oci.py +++ b/integration-tests/test_oci.py @@ -92,7 +92,7 @@ def test_authenticated_push_invalid_env_path_error(self, tart, vm_with_random_di raise_on_nonzero_returncode=False ) - expected_error = 'The file “this-file-does-not-exist” couldn’t be opened because there is no such file.' + expected_error = f"Registry authentication failed. Could not find docker configuration at '/temp/this-file-does-not-exist'." assert returncode == 1, f"Tart should fail with exit code 1 but failed with {returncode}." assert expected_error in stderr, f"Expected error '{expected_error}' not found in stderr: {stderr}"