Add support for build secrets (#1300)

docker-compatible `--secret id=key,...` arg for `container build`, that
works with Dockerfiles with `RUN --mount=type=secret`

Requires https://github.com/apple/container-builder-shim/pull/69

## Type of Change
- [ ] Bug fix
- [x] New feature  
- [ ] Breaking change
- [ ] Documentation update

## Motivation and Context
Adds support for Dockerfiles that use build secrets (e.g. `RUN
--mount=type=secret ...`)

## Testing
- [x] Tested locally
- [x] Added/updated tests
- [x] Added/updated docs
This commit is contained in:
Ryan Goulden
2026-03-16 17:04:24 -07:00
committed by GitHub
parent ce171ac3ee
commit a00ec5c1f7
5 changed files with 98 additions and 1 deletions
+6
View File
@@ -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")
}
+47 -1
View File
@@ -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=<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()]
@@ -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=<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])")
}
}
}
}
}
@@ -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)
@@ -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 =
+1
View File
@@ -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)