[builder]: enable ssh forwarding for container build (#1508)

This commit is contained in:
Saehej Kang
2026-08-03 08:34:48 -07:00
committed by GitHub
parent 39f12cabed
commit e87d3a030b
5 changed files with 130 additions and 8 deletions
+6
View File
@@ -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")
}
+37 -3
View File
@@ -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()
+1
View File
@@ -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)