Skip to content
Merged
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 applecontainer-bridge/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ let package = Package(
name: "ACBridge",
dependencies: [
.product(name: "ContainerAPIClient", package: "container"),
// PR-G2: BuildKit gRPC build flow.
.product(name: "ContainerBuild", package: "container"),
// ContainerImagesService exposes RemoteContentStoreClient
// (BuildKit's ContentStore implementation backed by the
// local images service). Required by Builder.BuildConfig.
.product(name: "ContainerImagesService", package: "container"),
],
path: "Sources/ACBridge"
),
Expand Down
62 changes: 57 additions & 5 deletions applecontainer-bridge/Sources/ACBridge/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,67 @@ func encodeOKNull() -> String {
}

// encodeErr serializes any Error into the failure envelope, escaping
// quotes and newlines so the result is always valid JSON.
// quotes and newlines so the result is always valid JSON. If the
// error is a BridgeCodedError, its `code` is included so the Go side
// can drive typed error mapping without depending on message text.
func encodeErr(_ error: Error) -> String {
let msg = String(describing: error)
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
if let coded = error as? BridgeCodedError {
return encodeErrWithCode(coded.code, message: coded.message)
}
let msg = jsonEscape(String(describing: error))
return "{\"ok\":false,\"err\":\"\(msg)\"}"
}

// encodeErrWithCode emits the failure envelope with a machine-readable
// `code` field alongside the human-readable `err`. The Go side keys
// typed errors off `code`; `err` is retained for diagnostics and
// log-friendliness.
func encodeErrWithCode(_ code: String, message: String) -> String {
let codeEsc = jsonEscape(code)
let msgEsc = jsonEscape(message)
return "{\"ok\":false,\"code\":\"\(codeEsc)\",\"err\":\"\(msgEsc)\"}"
}

func encodeErrWithCode(_ code: String, error: Error) -> String {
return encodeErrWithCode(code, message: String(describing: error))
}

// jsonEscape produces a JSON-string-safe rendering of an arbitrary
// Swift string. Per RFC 7159 §7, every control character in
// U+0000–U+001F must be escaped; well-known shorthand forms are
// used where they exist, the rest fall back to \u00XX. Without this,
// an error message containing e.g. a tab or carriage return would
// emit invalid JSON and break Go-side envelope decoding.
private func jsonEscape(_ s: String) -> String {
var out = ""
out.reserveCapacity(s.unicodeScalars.count)
for scalar in s.unicodeScalars {
switch scalar.value {
case 0x22: out += "\\\""
case 0x5C: out += "\\\\"
case 0x08: out += "\\b"
case 0x09: out += "\\t"
case 0x0A: out += "\\n"
case 0x0C: out += "\\f"
case 0x0D: out += "\\r"
case 0x00...0x1F:
out += String(format: "\\u%04X", scalar.value)
default:
out.unicodeScalars.append(scalar)
}
}
return out
}

// BridgeCodedError attaches a stable `code` to an error so the Go
// side can route on it without parsing free-form message text. Throw
// this from any bridge handler where the caller benefits from a typed
// error (e.g. BUILDER_UNAVAILABLE).
struct BridgeCodedError: Error {
let code: String
let message: String
}

// readCString safely converts a possibly-null C string pointer into a
// Swift String, returning nil for null pointers so each export can
// short-circuit with a deterministic error envelope.
Expand Down
256 changes: 256 additions & 0 deletions applecontainer-bridge/Sources/ACBridge/build.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import ContainerAPIClient
import ContainerBuild
import ContainerImagesServiceClient
import ContainerizationError
import ContainerizationOCI
import Foundation
import Logging
import NIOCore
import NIOPosix

// PR-G2: full BuildKit gRPC integration. Dials Apple's buildkit
// container over vsock, constructs a Builder, runs the build to an
// OCI tarball export, then loads + unpacks + tags it via the images
// service.
//
// Scope cuts intentionally not addressed in PR-G2:
// - Auto-start of the buildkit container. BuilderStart.start is
// module-internal to ContainerCommands, so we'd be reimplementing
// the bring-up logic ourselves. We surface a clean
// BuilderUnavailableError instead and rely on the user running
// `container builder start` once per machine.
// - Progress event streaming. BuildConfig accepts a Terminal? for
// output; passing nil routes progress to FileHandle.standardError,
// which the Go process inherits. A future PR can substitute a
// pipe + line-parsing for typed BuildEvent emission.
// - Multi-platform builds. We accept a single spec.platform string;
// anything else is dropped.
//
// Builder vsock port (matches the CLI's BuildCommand.swift default).
private let buildkitVsockPort: UInt32 = 8088
private let buildkitContainerID = "buildkit"

private let buildTimeoutSeconds = 30 * 60 // 30 min for a cold cache

private struct BuildSpecJSON: Decodable {
var contextPath: String
// Dockerfile is the in-context relative path, NOT absolute. The
// engine resolves it relative to contextPath before sending.
var dockerfile: String?
var tag: String?
var args: [String: String]?
var target: String?
var cacheFrom: [String]?
var noCache: Bool?
var platform: String?
}

private struct BuildResult: Encodable {
let reference: String
let digest: String
}

// ac_build_probe is preserved from PR-G — used by the Go side to
// short-circuit with a typed BuilderUnavailableError before paying
// the cost of marshaling a BuildSpec.
@_cdecl("ac_build_probe")
public func ac_build_probe() -> UnsafePointer<CChar>? {
return runSync(timeoutSeconds: 5) {
do {
let snap = try await ContainerClient().get(id: buildkitContainerID)
if snap.status == .running {
return "{\"ok\":true}"
}
return encodeErrWithCode(
"BUILDER_UNAVAILABLE",
message: "builder container exists but status is \(snap.status)"
)
} catch {
return encodeErrWithCode("BUILDER_UNAVAILABLE", error: error)
}
}
}

@_cdecl("ac_build")
public func ac_build(_ specPtr: UnsafePointer<CChar>?) -> UnsafePointer<CChar>? {
guard let specStr = readCString(specPtr) else { return dupNullArgErr("spec") }
return runSync(timeoutSeconds: buildTimeoutSeconds) {
do {
guard let data = specStr.data(using: .utf8) else {
return "{\"ok\":false,\"err\":\"spec not utf8\"}"
}
let spec = try JSONDecoder().decode(BuildSpecJSON.self, from: data)
let result = try await runBuild(spec: spec)
return encodeOK(result)
} catch {
return encodeErr(error)
}
}
}

// runBuild is the actual build flow. Split out so the @_cdecl wrapper
// stays small and the resource cleanup (event loop, file handle,
// temp directory) is structured as a single function with defers.
private func runBuild(spec: BuildSpecJSON) async throws -> BuildResult {
let client = ContainerClient()

// Resolve dockerfile contents. Defaults to "Dockerfile" in the
// context if not specified — matches Docker / OCI BuildKit
// conventions.
let dockerfileRel = (spec.dockerfile ?? "Dockerfile")
let dockerfileURL = URL(fileURLWithPath: spec.contextPath).appendingPathComponent(dockerfileRel)
let dockerfileData = try Data(contentsOf: dockerfileURL)

// Dockerignore is optional; absent file is fine.
let dockerignoreURL = URL(fileURLWithPath: spec.contextPath).appendingPathComponent(".dockerignore")
let dockerignoreData = try? Data(contentsOf: dockerignoreURL)

// Dial buildkit. If the container isn't running, surface a clean
// typed error path via the message (Go side maps to
// BuilderUnavailableError).
let socketHandle: FileHandle
do {
socketHandle = try await client.dial(id: buildkitContainerID, port: buildkitVsockPort)
} catch {
throw BridgeCodedError(
code: "BUILDER_UNAVAILABLE",
message: "builder not running (run `container builder start`): \(error)"
)
}

let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
// Shutdown is handled explicitly before each return path below
// (after the build completes). Even with synchronous-on-completion
// shutdown, the gRPC client's internal graceful-shutdown has
// straggler work that occasionally produces "Cannot schedule
// tasks on an EventLoop that has already shut down" warnings.
// These are upstream SwiftNIO deprecation warnings rather than
// functional failures; a polish PR can pin a sequencing fix once
// grpc-swift exposes the right hook.

var logger = Logger(label: "applecontainer-bridge.build")
logger.logLevel = .warning

// Builder construction + info probe must both clean up the
// event loop on failure, otherwise we leak NIO threads. Surface
// either failure as BUILDER_UNAVAILABLE so the Go layer maps it
// to the typed error (same code as the dial failure above).
let builder: Builder
do {
builder = try Builder(socket: socketHandle, group: eventLoopGroup, logger: logger)
// Verify the builder responds. The CLI does this same probe
// right after construction.
_ = try await builder.info()
} catch {
try? await eventLoopGroup.shutdownGracefully()
throw BridgeCodedError(
code: "BUILDER_UNAVAILABLE",
message: "builder not reachable (run `container builder start`): \(error)"
)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Export destination MUST live under the apiserver's appRoot at
// <appRoot>/builder/<buildID>/, which the apiserver mounts into
// the buildkit VM as /var/lib/container-builder-shim/exports/<buildID>/.
// Anywhere else fails with "no such file or directory" inside
// the VM. Source of truth: Application.BuilderCommand.builderResourceDir
// in apple/container's ContainerCommands/Builder/Builder.swift.
let buildID = UUID().uuidString
let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
let exportDir = systemHealth.appRoot
.appendingPathComponent("builder")
.appendingPathComponent(buildID)
try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: exportDir) }

let tarURL = exportDir.appendingPathComponent("out.tar")
let exports: [Builder.BuildExport] = [
Builder.BuildExport(
type: "oci",
destination: tarURL,
additionalFields: [:],
rawValue: "type=oci,dest=\(tarURL.path)"
)
]

// Parse the single-platform string (e.g. "linux/arm64") if given,
// else default to .current. BuildKit hangs indefinitely if
// platforms is empty — the CLI guards against this by always
// resolving a platform from CLI/env/host defaults.
let platforms = try parsePlatformsWithDefault(spec.platform)

// Build args: BuildKit expects ["KEY=value", ...] form.
let buildArgs: [String] = (spec.args ?? [:]).map { "\($0.key)=\($0.value)" }

let tag = spec.tag ?? ""
let tags: [String] = tag.isEmpty ? [] : [tag]

// Matches the CLI's `progress=plain` path: terminal nil, quiet
// false. `quiet=true` stalls the Solve request before it leaves
// the client (observed empirically); plain progress + nil
// terminal makes BuildKit fall back to its internal logging
// (which goes to the builder VM's stderr — invisible to us).
let config = Builder.BuildConfig(
buildID: buildID,
contentStore: RemoteContentStoreClient(),
buildArgs: buildArgs,
secrets: [:],
contextDir: spec.contextPath,
dockerfile: dockerfileData,
dockerignore: dockerignoreData,
labels: [],
noCache: spec.noCache ?? false,
platforms: platforms,
terminal: nil,
tags: tags,
target: spec.target ?? "",
quiet: false,
exports: exports,
cacheIn: spec.cacheFrom ?? [],
cacheOut: [],
pull: false
)

do {
try await builder.build(config)
} catch {
try? await eventLoopGroup.shutdownGracefully()
throw error
}
// Shut down the gRPC event loop now that the build session is
// complete. Synchronous-on-completion (not deferred) so the
// gRPC client's graceful shutdown sees the loop alive long
// enough to finish without scheduling errors.
try? await eventLoopGroup.shutdownGracefully()

// Build done — load the OCI tarball into the local content store,
// unpack, and tag. Mirrors the CLI's post-build "Unpacking built
// image" pass.
let loadResult = try await ClientImage.load(from: tarURL.path, force: false)
if !loadResult.rejectedMembers.isEmpty {
throw ContainerizationError(
.internalError,
message: "build archive contained rejected members: \(loadResult.rejectedMembers)"
)
}
guard let firstImage = loadResult.images.first else {
throw ContainerizationError(.internalError, message: "build produced no images")
}
try await firstImage.unpack(platform: nil, progressUpdate: nil)
var tagged: ClientImage = firstImage
for tagName in tags {
tagged = try await firstImage.tag(new: tagName)
}

return BuildResult(
reference: tags.first ?? tagged.description.reference,
digest: tagged.description.digest
)
}

private func parsePlatformsWithDefault(_ s: String?) throws -> [ContainerizationOCI.Platform] {
if let s, !s.isEmpty {
return [try ContainerizationOCI.Platform(from: s)]
}
return [.current]
}
Loading