diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index 4ace2a53d..be1b21f01 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -264,6 +264,7 @@ public struct Builder: Sendable { public let buildID: String public let contentStore: ContentStore public let buildArgs: [String] + public let secrets: [String: Data] public let contextDir: String public let dockerfile: Data public let hiddenDockerDir: String? @@ -283,6 +284,7 @@ public struct Builder: Sendable { buildID: String, contentStore: ContentStore, buildArgs: [String], + secrets: [String: Data], contextDir: String, dockerfile: Data, hiddenDockerDir: String?, @@ -301,6 +303,7 @@ public struct Builder: Sendable { self.buildID = buildID self.contentStore = contentStore self.buildArgs = buildArgs + self.secrets = secrets self.contextDir = contextDir self.dockerfile = dockerfile self.hiddenDockerDir = hiddenDockerDir @@ -344,6 +347,9 @@ public struct Builder: Sendable { for buildArg in config.buildArgs { metadata.addString(buildArg, forKey: "build-args") } + for (id, data) in config.secrets { + metadata.addString(id + "=" + data.base64EncodedString(), forKey: "secrets") + } for output in config.exports { metadata.addString(try output.stringValue, forKey: "outputs") } diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index d55f280b3..4ed22bd94 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -46,6 +46,11 @@ extension Application { case tty } + enum SecretType: Decodable { + case data(Data) + case file(String) + } + @Option( name: .shortAndLong, help: ArgumentHelp("Add the architecture type to the build", valueName: "value"), @@ -115,6 +120,11 @@ extension Application { @Flag(name: .shortAndLong, help: "Suppress build output") var quiet: Bool = false + @Option(name: .long, help: ArgumentHelp("Set build-time secrets (format: id=[,env=|,src=])", valueName: "id=key,...")) + var secret: [String] = [] + + var secrets: [String: SecretType] = [:] + @Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) var targetImageNames: [String] = { [UUID().uuidString.lowercased()] @@ -258,6 +268,15 @@ extension Application { } } + let secretsData: [String: Data] = try self.secrets.mapValues { secret in + switch secret { + case .data(let data): + return data + case .file(let path): + return try Data(contentsOf: URL(fileURLWithPath: path)) + } + } + let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) let exportPath = systemHealth.appRoot .appendingPathComponent(Application.BuilderCommand.builderResourceDir) @@ -331,11 +350,12 @@ extension Application { } return results }() - group.addTask { [terminal, buildArg, contextDir, hiddenDockerDir, label, noCache, target, quiet, cacheIn, cacheOut, pull] in + group.addTask { [terminal, buildArg, secretsData, contextDir, hiddenDockerDir, label, noCache, target, quiet, cacheIn, cacheOut, pull] in let config = Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), buildArgs: buildArg, + secrets: secretsData, contextDir: contextDir, dockerfile: buildFileData, hiddenDockerDir: hiddenDockerDir, @@ -463,6 +483,32 @@ extension Application { dockerfile = defaultDockerfile break } + + // Parse --secret args + for secret in self.secret { + let parts = secret.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false) + guard parts[0].hasPrefix("id=") else { + throw ValidationError("secret must start with id= \(secret)") + } + let key = String(parts[0].dropFirst(3)) + guard !key.contains("=") else { + throw ValidationError("secret id cannot contain '=' \(key)") + } + if parts.count == 1 || parts[1].hasPrefix("env=") { + let env = parts.count == 1 ? key : String(parts[1].dropFirst(4)) + // Using getenv/strlen over processInfo.environment to support + // non-UTF-8 env var data. + guard let ptr = getenv(env) else { + throw ValidationError("secret env var doesn't exist \(env)") + } + self.secrets[key] = .data(Data(bytes: ptr, count: strlen(ptr))) + } else if parts[1].hasPrefix("src=") { + let path = String(parts[1].dropFirst(4)) + self.secrets[key] = .file(path) + } else { + throw ValidationError("secret bad value \(parts[1])") + } + } } } } diff --git a/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift b/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift index b8f71179b..752009535 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift @@ -65,6 +65,12 @@ class TestCLIBuildBase: CLITest { return tempDir } + func createTempFile(suffix: String, contents: Data) throws -> URL { + let tempFile = testDir.appendingPathComponent(UUID().uuidString + suffix) + try contents.write(to: tempFile, options: .atomic) + return tempFile + } + func createContext(tempDir: URL, dockerfile: String, context: [FileSystemEntry]? = nil) throws { let dockerfileBytes = dockerfile.data(using: .utf8)! try dockerfileBytes.write(to: tempDir.appendingPathComponent("Dockerfile"), options: .atomic) diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift index 24abc9a1d..c5c0a8732 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift @@ -163,6 +163,44 @@ extension TestCLIBuildBase { #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } + @Test func testBuildSecret() throws { + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN --mount=type=secret,id=ENV1 \ + --mount=type=secret,id=env2 \ + --mount=type=secret,id=env3 \ + test xyyzzz = "`cat /run/secrets/ENV1 /run/secrets/env2 /run/secrets/env3`" + RUN --mount=type=secret,id=file \ + awk 'BEGIN {for(i=0; i<17; i++) for(c=0; c<256; c++) printf("%c", c)}' > /tmp/foo && \ + cmp /tmp/foo /run/secrets/file && \ + rm /tmp/foo + RUN --mount=type=secret,id=empty \ + test \\! -e /run/secrets/file && \ + test -e /run/secrets/empty && \ + cmp /dev/null /run/secrets/empty + """ + try createContext(tempDir: tempDir, dockerfile: dockerfile) + setenv("ENV1", "x", 1) + setenv("ENV_VAR", "yy", 1) + setenv("env3", "zzz", 1) + let testData = Data((0..<17).flatMap { _ in Array(0...255) }) + let tempFile: URL = try createTempFile(suffix: " _f,i=l.e+ ", contents: testData) + let tempFile2: URL = try createTempFile(suffix: "file2", contents: Data()) + let imageName: String = "registry.local/secrets:\(UUID().uuidString)" + try self.build( + tag: imageName, tempDir: tempDir, + otherArgs: [ + "--secret", "id=ENV1", + "--secret", "id=env2,env=ENV_VAR", + "--secret", "id=env3,env=env3", + "--secret", "id=file,src=" + tempFile.path, + "--secret", "id=empty,src=" + tempFile2.path, + ]) + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + } + @Test func testBuildNetworkAccess() throws { let tempDir: URL = try createTempDir() let dockerfile: String = diff --git a/docs/command-reference.md b/docs/command-reference.md index 70e7db964..7aea2a329 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -144,6 +144,7 @@ container build [] [] * `--progress `: Progress type (format: auto|plain|tty) (default: auto) * `--pull`: Pull latest image * `-q, --quiet`: Suppress build output +* `--secret `: Set build-time secrets (format: id=[,env=|,src=]) * `-t, --tag `: Name for the built image (can be specified multiple times) * `--target `: Set the target build stage * `--vsock-port `: Builder shim vsock port (default: 8088)