mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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:
@@ -264,6 +264,7 @@ public struct Builder: Sendable {
|
|||||||
public let buildID: String
|
public let buildID: String
|
||||||
public let contentStore: ContentStore
|
public let contentStore: ContentStore
|
||||||
public let buildArgs: [String]
|
public let buildArgs: [String]
|
||||||
|
public let secrets: [String: Data]
|
||||||
public let contextDir: String
|
public let contextDir: String
|
||||||
public let dockerfile: Data
|
public let dockerfile: Data
|
||||||
public let hiddenDockerDir: String?
|
public let hiddenDockerDir: String?
|
||||||
@@ -283,6 +284,7 @@ public struct Builder: Sendable {
|
|||||||
buildID: String,
|
buildID: String,
|
||||||
contentStore: ContentStore,
|
contentStore: ContentStore,
|
||||||
buildArgs: [String],
|
buildArgs: [String],
|
||||||
|
secrets: [String: Data],
|
||||||
contextDir: String,
|
contextDir: String,
|
||||||
dockerfile: Data,
|
dockerfile: Data,
|
||||||
hiddenDockerDir: String?,
|
hiddenDockerDir: String?,
|
||||||
@@ -301,6 +303,7 @@ public struct Builder: Sendable {
|
|||||||
self.buildID = buildID
|
self.buildID = buildID
|
||||||
self.contentStore = contentStore
|
self.contentStore = contentStore
|
||||||
self.buildArgs = buildArgs
|
self.buildArgs = buildArgs
|
||||||
|
self.secrets = secrets
|
||||||
self.contextDir = contextDir
|
self.contextDir = contextDir
|
||||||
self.dockerfile = dockerfile
|
self.dockerfile = dockerfile
|
||||||
self.hiddenDockerDir = hiddenDockerDir
|
self.hiddenDockerDir = hiddenDockerDir
|
||||||
@@ -344,6 +347,9 @@ public struct Builder: Sendable {
|
|||||||
for buildArg in config.buildArgs {
|
for buildArg in config.buildArgs {
|
||||||
metadata.addString(buildArg, forKey: "build-args")
|
metadata.addString(buildArg, forKey: "build-args")
|
||||||
}
|
}
|
||||||
|
for (id, data) in config.secrets {
|
||||||
|
metadata.addString(id + "=" + data.base64EncodedString(), forKey: "secrets")
|
||||||
|
}
|
||||||
for output in config.exports {
|
for output in config.exports {
|
||||||
metadata.addString(try output.stringValue, forKey: "outputs")
|
metadata.addString(try output.stringValue, forKey: "outputs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ extension Application {
|
|||||||
case tty
|
case tty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SecretType: Decodable {
|
||||||
|
case data(Data)
|
||||||
|
case file(String)
|
||||||
|
}
|
||||||
|
|
||||||
@Option(
|
@Option(
|
||||||
name: .shortAndLong,
|
name: .shortAndLong,
|
||||||
help: ArgumentHelp("Add the architecture type to the build", valueName: "value"),
|
help: ArgumentHelp("Add the architecture type to the build", valueName: "value"),
|
||||||
@@ -115,6 +120,11 @@ extension Application {
|
|||||||
@Flag(name: .shortAndLong, help: "Suppress build output")
|
@Flag(name: .shortAndLong, help: "Suppress build output")
|
||||||
var quiet: Bool = false
|
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"))
|
@Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name"))
|
||||||
var targetImageNames: [String] = {
|
var targetImageNames: [String] = {
|
||||||
[UUID().uuidString.lowercased()]
|
[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 systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
|
||||||
let exportPath = systemHealth.appRoot
|
let exportPath = systemHealth.appRoot
|
||||||
.appendingPathComponent(Application.BuilderCommand.builderResourceDir)
|
.appendingPathComponent(Application.BuilderCommand.builderResourceDir)
|
||||||
@@ -331,11 +350,12 @@ extension Application {
|
|||||||
}
|
}
|
||||||
return results
|
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(
|
let config = Builder.BuildConfig(
|
||||||
buildID: buildID,
|
buildID: buildID,
|
||||||
contentStore: RemoteContentStoreClient(),
|
contentStore: RemoteContentStoreClient(),
|
||||||
buildArgs: buildArg,
|
buildArgs: buildArg,
|
||||||
|
secrets: secretsData,
|
||||||
contextDir: contextDir,
|
contextDir: contextDir,
|
||||||
dockerfile: buildFileData,
|
dockerfile: buildFileData,
|
||||||
hiddenDockerDir: hiddenDockerDir,
|
hiddenDockerDir: hiddenDockerDir,
|
||||||
@@ -463,6 +483,32 @@ extension Application {
|
|||||||
dockerfile = defaultDockerfile
|
dockerfile = defaultDockerfile
|
||||||
break
|
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
|
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 {
|
func createContext(tempDir: URL, dockerfile: String, context: [FileSystemEntry]? = nil) throws {
|
||||||
let dockerfileBytes = dockerfile.data(using: .utf8)!
|
let dockerfileBytes = dockerfile.data(using: .utf8)!
|
||||||
try dockerfileBytes.write(to: tempDir.appendingPathComponent("Dockerfile"), options: .atomic)
|
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)")
|
#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 {
|
@Test func testBuildNetworkAccess() throws {
|
||||||
let tempDir: URL = try createTempDir()
|
let tempDir: URL = try createTempDir()
|
||||||
let dockerfile: String =
|
let dockerfile: String =
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ container build [<options>] [<context-dir>]
|
|||||||
* `--progress <type>`: Progress type (format: auto|plain|tty) (default: auto)
|
* `--progress <type>`: Progress type (format: auto|plain|tty) (default: auto)
|
||||||
* `--pull`: Pull latest image
|
* `--pull`: Pull latest image
|
||||||
* `-q, --quiet`: Suppress build output
|
* `-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)
|
* `-t, --tag <name>`: Name for the built image (can be specified multiple times)
|
||||||
* `--target <stage>`: Set the target build stage
|
* `--target <stage>`: Set the target build stage
|
||||||
* `--vsock-port <port>`: Builder shim vsock port (default: 8088)
|
* `--vsock-port <port>`: Builder shim vsock port (default: 8088)
|
||||||
|
|||||||
Reference in New Issue
Block a user