mirror of
https://github.com/apple/container.git
synced 2026-08-26 18:36:44 +00:00
[builder]: enable ssh forwarding for container build (#1508)
This commit is contained in:
@@ -268,6 +268,7 @@ public struct Builder: Sendable {
|
||||
public let contentStore: ContentStore
|
||||
public let buildArgs: [String]
|
||||
public let secrets: [String: Data]
|
||||
public let ssh: String
|
||||
public let contextDir: String
|
||||
public let dockerfile: Data
|
||||
public let dockerignore: Data?
|
||||
@@ -289,6 +290,7 @@ public struct Builder: Sendable {
|
||||
contentStore: ContentStore,
|
||||
buildArgs: [String],
|
||||
secrets: [String: Data],
|
||||
ssh: String,
|
||||
contextDir: String,
|
||||
dockerfile: Data,
|
||||
dockerignore: Data?,
|
||||
@@ -309,6 +311,7 @@ public struct Builder: Sendable {
|
||||
self.contentStore = contentStore
|
||||
self.buildArgs = buildArgs
|
||||
self.secrets = secrets
|
||||
self.ssh = ssh
|
||||
self.contextDir = contextDir
|
||||
self.dockerfile = dockerfile
|
||||
self.dockerignore = dockerignore
|
||||
@@ -356,6 +359,9 @@ public struct Builder: Sendable {
|
||||
for (id, data) in config.secrets {
|
||||
metadata.addString(id + "=" + data.base64EncodedString(), forKey: "secrets")
|
||||
}
|
||||
if config.ssh == "default" {
|
||||
metadata.addString("default", forKey: "ssh")
|
||||
}
|
||||
for output in config.exports {
|
||||
metadata.addString(try output.stringValue, forKey: "outputs")
|
||||
}
|
||||
|
||||
@@ -125,6 +125,12 @@ extension Application {
|
||||
|
||||
var secrets: [String: SecretType] = [:]
|
||||
|
||||
@Option(
|
||||
name: .long,
|
||||
help: ArgumentHelp("Forward SSH agent authentication to the build (format: default)", valueName: "default")
|
||||
)
|
||||
var ssh: String = ""
|
||||
|
||||
@Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name"))
|
||||
var targetImageNames: [String] = {
|
||||
[UUID().uuidString.lowercased()]
|
||||
@@ -165,12 +171,26 @@ extension Application {
|
||||
progress.set(description: "Dialing builder")
|
||||
|
||||
let dnsNameservers = self.dns.nameservers
|
||||
let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory, dnsNameservers] group in
|
||||
|
||||
// Ensure the builder is started (or restarted) with the correct SSH configuration
|
||||
// before attempting to dial. This handles the case where the builder is already
|
||||
// running but was not started with SSH forwarding enabled.
|
||||
try await BuilderStart.start(
|
||||
cpus: cpus,
|
||||
memory: memory,
|
||||
log: log,
|
||||
ssh: ssh == "default",
|
||||
dnsNameservers: dnsNameservers,
|
||||
progressUpdate: progress.handler,
|
||||
containerSystemConfig: containerSystemConfig,
|
||||
)
|
||||
|
||||
let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory, dnsNameservers, ssh] group in
|
||||
defer {
|
||||
group.cancelAll()
|
||||
}
|
||||
|
||||
group.addTask { [vsockPort, cpus, memory, log, dnsNameservers] in
|
||||
group.addTask { [vsockPort, cpus, memory, log, dnsNameservers, ssh] in
|
||||
let client = ContainerClient()
|
||||
while true {
|
||||
do {
|
||||
@@ -192,6 +212,7 @@ extension Application {
|
||||
cpus: cpus,
|
||||
memory: memory,
|
||||
log: log,
|
||||
ssh: ssh == "default",
|
||||
dnsNameservers: dnsNameservers,
|
||||
progressUpdate: progress.handler,
|
||||
containerSystemConfig: containerSystemConfig,
|
||||
@@ -349,13 +370,15 @@ extension Application {
|
||||
}()
|
||||
group.addTask {
|
||||
[
|
||||
terminal, buildArg, secretsData, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL, log,
|
||||
terminal, buildArg, secretsData, ssh, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL,
|
||||
log
|
||||
] in
|
||||
let config = Builder.BuildConfig(
|
||||
buildID: buildID,
|
||||
contentStore: RemoteContentStoreClient(),
|
||||
buildArgs: buildArg,
|
||||
secrets: secretsData,
|
||||
ssh: ssh,
|
||||
contextDir: contextDir,
|
||||
dockerfile: buildFileData,
|
||||
dockerignore: ignoreFileData,
|
||||
@@ -510,6 +533,17 @@ extension Application {
|
||||
throw ValidationError("secret bad value \(parts[1])")
|
||||
}
|
||||
}
|
||||
|
||||
switch ssh {
|
||||
case "":
|
||||
break
|
||||
case "default" where ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil:
|
||||
break
|
||||
case "default":
|
||||
throw ValidationError("--ssh default requires SSH_AUTH_SOCK to be set")
|
||||
default:
|
||||
throw ValidationError("only --ssh default is currently supported")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ extension Application {
|
||||
cpus: Int64?,
|
||||
memory: String?,
|
||||
log: Logger,
|
||||
ssh: Bool = false,
|
||||
dnsNameservers: [String] = [],
|
||||
dnsDomain: String? = nil,
|
||||
dnsSearchDomains: [String] = [],
|
||||
@@ -150,6 +151,9 @@ extension Application {
|
||||
let imageChanged = existingImage != builderImage
|
||||
let cpuChanged = existingResources.cpus != resources.cpus
|
||||
let memChanged = existingResources.memoryInBytes != resources.memoryInBytes
|
||||
let sshForwarded = existingContainer.configuration.ssh
|
||||
let sshWanted = ssh && ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil
|
||||
let sshChanged = sshForwarded != sshWanted
|
||||
let dnsChanged = {
|
||||
if !dnsNameservers.isEmpty {
|
||||
return existingDNS?.nameservers != dnsNameservers
|
||||
@@ -168,7 +172,7 @@ extension Application {
|
||||
|
||||
switch existingContainer.status {
|
||||
case .running:
|
||||
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
|
||||
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged || sshChanged else {
|
||||
// If image, mem, cpu, env, and DNS are the same, continue using the existing builder
|
||||
return
|
||||
}
|
||||
@@ -178,11 +182,22 @@ extension Application {
|
||||
case .stopped:
|
||||
// If the builder is stopped and matches our requirements, start it
|
||||
// Otherwise, delete it and create a new one
|
||||
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
|
||||
try await startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil)
|
||||
return
|
||||
if imageChanged || cpuChanged || memChanged || envChanged || dnsChanged || sshChanged {
|
||||
try? await client.delete(id: existingContainer.id)
|
||||
} else {
|
||||
do {
|
||||
try await startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil)
|
||||
return
|
||||
} catch {
|
||||
log.warning(
|
||||
"failed to restart existing stopped BuildKit container, recreating it",
|
||||
metadata: [
|
||||
"id": "\(existingContainer.id)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
}
|
||||
try? await client.delete(id: existingContainer.id)
|
||||
}
|
||||
try await client.delete(id: existingContainer.id)
|
||||
case .stopping:
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
@@ -242,6 +257,7 @@ extension Application {
|
||||
|
||||
var config = ContainerConfiguration(id: Builder.builderContainerId, image: imageDesc, process: processConfig)
|
||||
config.resources = resources
|
||||
config.ssh = ssh && ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil
|
||||
config.labels = [
|
||||
ResourceLabelKeys.plugin: "builder",
|
||||
ResourceLabelKeys.role: ResourceRoleValues.builder,
|
||||
|
||||
@@ -230,6 +230,71 @@ struct TestCLIBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testBuildWithSSHDefaultForwarding() async throws {
|
||||
try await ContainerFixture.with { f in
|
||||
let socketDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: socketDir, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: socketDir)
|
||||
}
|
||||
|
||||
let socketPath = socketDir.appendingPathComponent("ssh-auth.sock").path
|
||||
|
||||
let serverFd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
precondition(serverFd >= 0, "socket() failed")
|
||||
defer {
|
||||
Darwin.close(serverFd)
|
||||
}
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
withUnsafeMutableBytes(of: &addr.sun_path) { bytes in
|
||||
socketPath.withCString { cStr in
|
||||
bytes.copyMemory(from: UnsafeRawBufferPointer(start: cStr, count: socketPath.utf8.count + 1))
|
||||
}
|
||||
}
|
||||
|
||||
let bindResult = withUnsafePointer(to: addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||
bind(serverFd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
precondition(bindResult == 0, "bind() failed: \(errno)")
|
||||
precondition(listen(serverFd, 5) == 0, "listen() failed")
|
||||
|
||||
let acceptThread = Thread {
|
||||
while true {
|
||||
let clientFd = accept(serverFd, nil, nil)
|
||||
if clientFd < 0 { break }
|
||||
Darwin.close(clientFd)
|
||||
}
|
||||
}
|
||||
acceptThread.start()
|
||||
|
||||
let dir = try f.createTempDir()
|
||||
try f.createContext(
|
||||
dir: dir,
|
||||
dockerfile: """
|
||||
FROM ghcr.io/linuxcontainers/alpine:3.20
|
||||
RUN --mount=type=ssh \\
|
||||
test -n "$SSH_AUTH_SOCK" && \\
|
||||
test -S "$SSH_AUTH_SOCK"
|
||||
""")
|
||||
|
||||
let image = "registry.local/ssh-default-forwarding:\(UUID().uuidString)"
|
||||
try f.run(
|
||||
[
|
||||
"build",
|
||||
"--ssh", "default",
|
||||
"-f", dir.appending("Dockerfile").string,
|
||||
"-t", image,
|
||||
dir.appending("context").string,
|
||||
], env: ["SSH_AUTH_SOCK": socketPath]
|
||||
).check()
|
||||
try f.assertImageBuilt(image)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testBuildDockerfileKeywords() async throws {
|
||||
try await ContainerFixture.with { f in
|
||||
let dir = try f.createTempDir()
|
||||
|
||||
@@ -157,6 +157,7 @@ container build [<options>] [<context-dir>]
|
||||
* `--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>])
|
||||
* `--ssh <default>`: Forward SSH agent authentication to the build. Only `--ssh default` is currently supported.
|
||||
* `-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)
|
||||
|
||||
Reference in New Issue
Block a user