Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/ContainerBuild/Builder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -283,6 +284,7 @@ public struct Builder: Sendable {
buildID: String,
contentStore: ContentStore,
buildArgs: [String],
secrets: [String: Data],
contextDir: String,
dockerfile: Data,
hiddenDockerDir: String?,
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
48 changes: 47 additions & 1 deletion Sources/ContainerCommands/BuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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=<key>[,env=<ENV_VAR>|,src=<local/path>])", 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()]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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=<key> \(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])")
}
}
}
}
}
6 changes: 6 additions & 0 deletions Tests/CLITests/Subcommands/Build/CLIBuildBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ container build [<options>] [<context-dir>]
* `--progress <type>`: Progress type (format: auto|plain|tty) (default: auto)
* `--pull`: Pull latest image
* `-q, --quiet`: Suppress build output
* `--secret <id=key,...>`: Set build-time secrets (format: id=<key>[,env=<ENV_VAR>|,src=<local/path>])
* `-t, --tag <name>`: Name for the built image (can be specified multiple times)
* `--target <stage>`: Set the target build stage
* `--vsock-port <port>`: Builder shim vsock port (default: 8088)
Expand Down
Loading