diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index c43b5ea63..151ee033e 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -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") } diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 34ec4356f..c216bc26b 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -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") + } } } } diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index 5b65544a4..2267ff443 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -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,7 +182,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 } @@ -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, diff --git a/Tests/IntegrationTests/Build/TestCLIBuilder.swift b/Tests/IntegrationTests/Build/TestCLIBuilder.swift index 7fc250f10..5426f8711 100644 --- a/Tests/IntegrationTests/Build/TestCLIBuilder.swift +++ b/Tests/IntegrationTests/Build/TestCLIBuilder.swift @@ -230,6 +230,69 @@ 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.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() diff --git a/docs/command-reference.md b/docs/command-reference.md index c7d854806..a308a412c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -157,6 +157,7 @@ container build [] [] * `--pull`: Pull latest image * `-q, --quiet`: Suppress build output * `--secret `: Set build-time secrets (format: id=[,env=|,src=]) +* `--ssh `: Forward SSH agent authentication to the build. Only `--ssh default` is currently supported. * `-t, --tag `: Name for the built image (can be specified multiple times) * `--target `: Set the target build stage * `--vsock-port `: Builder shim vsock port (default: 8088)