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
87 changes: 87 additions & 0 deletions applecontainer-bridge/Sources/ACBridge/Helpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Foundation

let bridgeBuildVersion = "0.1.0"
let applePinnedVersion = "0.12.3"

// runSync runs an async closure on a Task and blocks the calling cgo
// thread on a DispatchSemaphore until it completes, with a hard wait
// timeout to prevent a misbehaving Task from hanging the caller
// forever. Every export that wraps an apple/container async call goes
// through this so the cgo-thread-blocking shape stays consistent.
//
// The closure must catch its own errors and encode them into the
// returned JSON envelope; runSync only handles the wait-timeout case
// itself.
//
// timeoutSeconds is the upper bound on the inner async work. The
// semaphore is given a `timeoutSeconds + 2` slack so the inner op's
// own timeout fires first and produces a typed error rather than the
// generic `bridge-timeout` fallback.
func runSync(timeoutSeconds: Int, _ op: @Sendable @escaping () async -> String) -> UnsafePointer<CChar>? {
let sem = DispatchSemaphore(value: 0)
nonisolated(unsafe) var json = "{\"ok\":false,\"err\":\"unset\"}"
Task {
defer { sem.signal() }
json = await op()
}
let result = sem.wait(timeout: .now() + .seconds(timeoutSeconds + 2))
if result == .timedOut {
return UnsafePointer(strdup("{\"ok\":false,\"err\":\"bridge-timeout\"}"))
}
return UnsafePointer(strdup(json))
}

// bridgeEncoder configures every payload going to Go with ISO8601
// dates so Go's encoding/json time.Time decoder accepts them. Apple's
// default (secondsSince2001Jan1) ships dates as numbers, which would
// force the Go side to special-case every Date field.
private let bridgeEncoder: JSONEncoder = {
let enc = JSONEncoder()
enc.dateEncodingStrategy = .iso8601
return enc
}()

// encodeOK wraps an Encodable payload in the canonical envelope:
// { "ok": true, "data": <payload> }
func encodeOK<T: Encodable>(_ value: T) -> String {
do {
let data = try bridgeEncoder.encode(value)
guard let inner = String(data: data, encoding: .utf8) else {
return "{\"ok\":false,\"err\":\"utf8 encoding failed\"}"
}
return "{\"ok\":true,\"data\":\(inner)}"
} catch {
return encodeErr(error)
}
}

// encodeOKNull is the success-with-no-payload form: callers like
// "find by label" use it to signal "looked, found nothing" distinct
// from an actual error.
func encodeOKNull() -> String {
return "{\"ok\":true,\"data\":null}"
}

// encodeErr serializes any Error into the failure envelope, escaping
// quotes and newlines so the result is always valid JSON.
func encodeErr(_ error: Error) -> String {
let msg = String(describing: error)
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
return "{\"ok\":false,\"err\":\"\(msg)\"}"
}

// 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.
func readCString(_ p: UnsafePointer<CChar>?) -> String? {
guard let p else { return nil }
return String(cString: p)
}

// dupNullArgErr is a one-liner for the "caller passed null where a
// non-null C string was required" case.
func dupNullArgErr(_ argName: String) -> UnsafePointer<CChar>? {
UnsafePointer(strdup("{\"ok\":false,\"err\":\"null \(argName)\"}"))
}
58 changes: 19 additions & 39 deletions applecontainer-bridge/Sources/ACBridge/bridge.swift
Original file line number Diff line number Diff line change
@@ -1,48 +1,33 @@
import ContainerAPIClient
import Foundation

private let bridgeVersion = "0.1.0"
private let applePinnedVersion = "0.12.3"
// ===== ac_version =====================================================

@_cdecl("ac_version")
public func ac_version() -> UnsafePointer<CChar>? {
let s = "ACBridge/\(bridgeVersion) apple-container/\(applePinnedVersion)"
let s = "ACBridge/\(bridgeBuildVersion) apple-container/\(applePinnedVersion)"
return UnsafePointer(strdup(s))
}

// ===== ac_ping ========================================================

@_cdecl("ac_ping")
public func ac_ping(_ timeoutSeconds: Int32) -> UnsafePointer<CChar>? {
let seconds = Int(timeoutSeconds <= 0 ? 5 : timeoutSeconds)
let timeout: Duration = .seconds(seconds)
let sem = DispatchSemaphore(value: 0)
nonisolated(unsafe) var json = "{\"ok\":false,\"err\":\"unset\"}"

Task {
defer { sem.signal() }
return runSync(timeoutSeconds: seconds) {
do {
let h = try await ClientHealthCheck.ping(timeout: timeout)
json = encodePingOK(h)
let h = try await ClientHealthCheck.ping(timeout: .seconds(seconds))
return encodePingOK(h)
} catch {
json = encodePingErr(error)
return encodeErr(error)
}
}
// ClientHealthCheck.ping already enforces its own Duration timeout,
// but guard the semaphore wait as belt-and-suspenders: if the Task
// never signals (cancellation race, runtime hang), we return a
// deterministic error instead of blocking the cgo caller forever.
let waitResult = sem.wait(timeout: .now() + .seconds(seconds + 2))
if waitResult == .timedOut {
return UnsafePointer(strdup("{\"ok\":false,\"err\":\"bridge-timeout\"}"))
}
return UnsafePointer(strdup(json))
}

@_cdecl("ac_free")
public func ac_free(_ p: UnsafeMutableRawPointer?) {
free(p)
}

private func encodePingOK(_ h: SystemHealth) -> String {
// ac_ping predates the style guide's canonical {ok, data} envelope
// and ships SystemHealth's fields at the top level. Kept as-is for
// PR-A stability; new exports use encodeOK(...) instead.
let payload: [String: Any] = [
"ok": true,
"apiServerVersion": h.apiServerVersion,
Expand All @@ -51,22 +36,17 @@ private func encodePingOK(_ h: SystemHealth) -> String {
"appRoot": h.appRoot.path,
"installRoot": h.installRoot.path,
]
return jsonString(payload, fallback: "{\"ok\":true}")
}

private func encodePingErr(_ error: Error) -> String {
let payload: [String: Any] = [
"ok": false,
"err": String(describing: error),
]
return jsonString(payload, fallback: "{\"ok\":false,\"err\":\"encode-failed\"}")
}

private func jsonString(_ payload: [String: Any], fallback: String) -> String {
guard let data = try? JSONSerialization.data(withJSONObject: payload),
let s = String(data: data, encoding: .utf8)
else {
return fallback
return "{\"ok\":true}"
}
return s
}

// ===== ac_free ========================================================

@_cdecl("ac_free")
public func ac_free(_ p: UnsafeMutableRawPointer?) {
free(p)
}
100 changes: 100 additions & 0 deletions applecontainer-bridge/Sources/ACBridge/inspect.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import ContainerAPIClient
import ContainerizationOCI
import ContainerResource
import Foundation

// PR-B default for inspect/list-style sync calls — kept tight because
// these XPC round-trips are local and fast. Bumps in future PRs can
// expose a timeout argument if needed.
private let inspectTimeoutSeconds = 10

// ===== ac_inspect_container ==========================================

@_cdecl("ac_inspect_container")
public func ac_inspect_container(_ idPtr: UnsafePointer<CChar>?) -> UnsafePointer<CChar>? {
guard let id = readCString(idPtr) else { return dupNullArgErr("id") }
return runSync(timeoutSeconds: inspectTimeoutSeconds) {
do {
let snap = try await ContainerClient().get(id: id)
return encodeOK(snap)
} catch {
return encodeErr(error)
}
}
}

// ===== ac_inspect_image ==============================================

// ImageInspectPayload is the projection we return for an image
// inspect — flattened from the OCI Image + ImageConfig so the Go side
// gets a single object to unmarshal. Kept narrow (only the fields the
// Runtime interface needs); add more as later PRs require them.
//
// `env` and `labels` are non-optional with empty-collection defaults
// so the Go side always sees a non-nil map / slice. The engine's
// `devcontainer.metadata` fast path looks up a key on `labels`;
// guaranteeing a non-nil map keeps callers from having to special-
// case nil at every read site.
private struct ImageInspectPayload: Encodable {
let reference: String
let digest: String
let architecture: String?
let os: String?
let user: String?
let env: [String]
let labels: [String: String]
}

@_cdecl("ac_inspect_image")
public func ac_inspect_image(_ refPtr: UnsafePointer<CChar>?) -> UnsafePointer<CChar>? {
guard let ref = readCString(refPtr) else { return dupNullArgErr("reference") }
return runSync(timeoutSeconds: inspectTimeoutSeconds) {
do {
let img = try await ClientImage.get(reference: ref)
let ociImage: ContainerizationOCI.Image = try await img.config(for: .current)
let payload = ImageInspectPayload(
reference: img.description.reference,
digest: img.description.digest,
architecture: ociImage.architecture,
os: ociImage.os,
user: ociImage.config?.user,
env: ociImage.config?.env ?? [],
labels: ociImage.config?.labels ?? [:]
)
return encodeOK(payload)
} catch {
return encodeErr(error)
}
}
}

// ===== ac_find_container_by_label ====================================

@_cdecl("ac_find_container_by_label")
public func ac_find_container_by_label(
_ keyPtr: UnsafePointer<CChar>?,
_ valuePtr: UnsafePointer<CChar>?
) -> UnsafePointer<CChar>? {
guard let key = readCString(keyPtr) else { return dupNullArgErr("key") }
guard let value = readCString(valuePtr) else { return dupNullArgErr("value") }
return runSync(timeoutSeconds: inspectTimeoutSeconds) {
do {
let all = try await ContainerClient().list()
let matches = all.filter { $0.configuration.labels[key] == value }
// Most-recently-started wins, matching the contract on
// runtime.FindContainerByLabel. startedDate is optional in
// Apple's snapshot; nil sorts to the bottom.
let pick = matches.max { lhs, rhs in
let l = lhs.startedDate ?? Date.distantPast
let r = rhs.startedDate ?? Date.distantPast
return l < r
}
if let pick {
return encodeOK(pick)
}
return encodeOKNull()
} catch {
return encodeErr(error)
}
}
}
Loading