Skip to content
Open
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 @@ -266,6 +266,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?
Expand All @@ -287,6 +288,7 @@ public struct Builder: Sendable {
contentStore: ContentStore,
buildArgs: [String],
secrets: [String: Data],
ssh: String,
contextDir: String,
dockerfile: Data,
dockerignore: Data?,
Expand All @@ -307,6 +309,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
Expand Down Expand Up @@ -354,6 +357,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")
}
Expand Down
40 changes: 37 additions & 3 deletions Sources/ContainerCommands/BuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down Expand Up @@ -167,12 +173,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 {
Expand All @@ -194,6 +214,7 @@ extension Application {
cpus: cpus,
memory: memory,
log: log,
ssh: ssh == "default",
dnsNameservers: dnsNameservers,
progressUpdate: progress.handler,
containerSystemConfig: containerSystemConfig,
Expand Down Expand Up @@ -337,13 +358,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,
Expand Down Expand Up @@ -498,6 +521,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")
}
}
}
}
9 changes: 7 additions & 2 deletions Sources/ContainerCommands/Builder/BuilderStart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ extension Application {
cpus: Int64?,
memory: String?,
log: Logger,
ssh: Bool = false,
dnsNameservers: [String] = [],
dnsDomain: String? = nil,
dnsSearchDomains: [String] = [],
Expand Down Expand Up @@ -152,6 +153,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
Expand All @@ -170,7 +174,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
}
Expand All @@ -180,7 +184,7 @@ 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 {
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged || sshChanged else {
try await startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil)
return
}
Expand Down Expand Up @@ -242,6 +246,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,
Expand Down
80 changes: 80 additions & 0 deletions Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1557,4 +1557,84 @@ extension TestCLIBuildBase {
try self.build(tag: imageName, tempDir: tempDir)
}
}

@Test func testBuildWithSSHDefaultForwarding() throws {
let tempDir: URL = try createTempDir()
defer {
try? FileManager.default.removeItem(at: tempDir)
}

// Create a temp dir and socket path for the simulated SSH agent.
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

// Create a listening Unix domain socket to act as a fake SSH agent.
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")

// Accept and immediately close connections in background to keep the socket alive.
let acceptThread = Thread {
while true {
let clientFd = accept(serverFd, nil, nil)
if clientFd < 0 { break }
Darwin.close(clientFd)
}
}
acceptThread.start()

let previousSSHAuthSock = getenv("SSH_AUTH_SOCK").map { String(cString: $0) }
setenv("SSH_AUTH_SOCK", socketPath, 1)
defer {
if let previousSSHAuthSock {
setenv("SSH_AUTH_SOCK", previousSSHAuthSock, 1)
} else {
unsetenv("SSH_AUTH_SOCK")
}
}

let dockerfile =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20
RUN --mount=type=ssh \
test -n "$SSH_AUTH_SOCK" && \
test -S "$SSH_AUTH_SOCK"
"""
let context: [FileSystemEntry] = [
.file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!))
]
try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context)

let contextDir = tempDir.appendingPathComponent("context")
let imageName = "registry.local/ssh-default-forwarding:\(UUID().uuidString)"
let args = ["build", "--ssh", "default", "-t", imageName, contextDir.path]
let response = try run(arguments: args)
if response.status != 0 {
throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)")
}
#expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)")
}
}
1 change: 1 addition & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,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)
Expand Down