diff --git a/.gitignore b/.gitignore index 755cfbb..a76569e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ go.work.sum # Internal design docs (not yet public) PRD.md design/ + +# M6 apple-container spike (private until M6 starts / repo flips public) +examples/applecontainer-spike/ diff --git a/Makefile b/Makefile index 50e750e..a8437f4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all test test-integration lint fmt vet tidy clean tools +.PHONY: all test test-integration lint fmt vet tidy clean tools bridge bridge-clean GO ?= go GOLANGCI_LINT ?= golangci-lint @@ -32,3 +32,24 @@ tidy: clean: $(GO) clean -testcache + +# bridge builds libACBridge.dylib via SwiftPM. Required before any Go +# code that imports runtime/applecontainer can link on darwin/arm64. +# darwin/arm64 only; on other platforms runtime/applecontainer's stub +# file builds without the dylib so this target should not be invoked. +# Guarded with a uname check rather than left unconditional: invoking +# `make bridge` on linux/amd64 prints a clear skip message instead of +# failing with "swift: command not found". +bridge: + @if [ "$$(uname -s)" = "Darwin" ] && [ "$$(uname -m)" = "arm64" ]; then \ + cd applecontainer-bridge && swift build -c release; \ + else \ + echo "bridge: skipped (requires darwin/arm64)"; \ + fi + +bridge-clean: + @if [ "$$(uname -s)" = "Darwin" ] && [ "$$(uname -m)" = "arm64" ]; then \ + cd applecontainer-bridge && swift package clean && rm -rf .build; \ + else \ + echo "bridge-clean: skipped (requires darwin/arm64)"; \ + fi diff --git a/applecontainer-bridge/.gitignore b/applecontainer-bridge/.gitignore new file mode 100644 index 0000000..bb39a0b --- /dev/null +++ b/applecontainer-bridge/.gitignore @@ -0,0 +1,2 @@ +.build/ +Package.resolved diff --git a/applecontainer-bridge/Package.swift b/applecontainer-bridge/Package.swift new file mode 100644 index 0000000..974fcf1 --- /dev/null +++ b/applecontainer-bridge/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "ACBridge", + platforms: [.macOS("15.0")], + products: [ + .library(name: "ACBridge", type: .dynamic, targets: ["ACBridge"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/container.git", exact: "0.12.3"), + ], + targets: [ + .target( + name: "ACBridge", + dependencies: [ + .product(name: "ContainerAPIClient", package: "container"), + ], + path: "Sources/ACBridge" + ), + ] +) diff --git a/applecontainer-bridge/Sources/ACBridge/bridge.swift b/applecontainer-bridge/Sources/ACBridge/bridge.swift new file mode 100644 index 0000000..8461497 --- /dev/null +++ b/applecontainer-bridge/Sources/ACBridge/bridge.swift @@ -0,0 +1,72 @@ +import ContainerAPIClient +import Foundation + +private let bridgeVersion = "0.1.0" +private let applePinnedVersion = "0.12.3" + +@_cdecl("ac_version") +public func ac_version() -> UnsafePointer? { + let s = "ACBridge/\(bridgeVersion) apple-container/\(applePinnedVersion)" + return UnsafePointer(strdup(s)) +} + +@_cdecl("ac_ping") +public func ac_ping(_ timeoutSeconds: Int32) -> UnsafePointer? { + 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() } + do { + let h = try await ClientHealthCheck.ping(timeout: timeout) + json = encodePingOK(h) + } catch { + json = encodePingErr(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 { + let payload: [String: Any] = [ + "ok": true, + "apiServerVersion": h.apiServerVersion, + "apiServerBuild": h.apiServerBuild, + "apiServerCommit": h.apiServerCommit, + "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 s +} diff --git a/applecontainer-bridge/include/ac_bridge.h b/applecontainer-bridge/include/ac_bridge.h new file mode 100644 index 0000000..5179a09 --- /dev/null +++ b/applecontainer-bridge/include/ac_bridge.h @@ -0,0 +1,12 @@ +#ifndef AC_BRIDGE_H +#define AC_BRIDGE_H + +#include + +const char* ac_version(void); + +const char* ac_ping(int32_t timeout_seconds); + +void ac_free(void* p); + +#endif diff --git a/runtime/applecontainer/doc.go b/runtime/applecontainer/doc.go new file mode 100644 index 0000000..47259c5 --- /dev/null +++ b/runtime/applecontainer/doc.go @@ -0,0 +1,21 @@ +// Package applecontainer is an Apple `container` implementation of +// runtime.Runtime targeting macOS 15+ on arm64. +// +// The runtime is a thin cgo wrapper around libACBridge.dylib, a Swift +// dynamic library that imports apple/container's ContainerAPIClient and +// speaks XPC to the system container-apiserver daemon. The daemon is +// installed via `brew install container` and started with +// `container system start`. New returns a *runtime.DaemonUnavailableError +// if the daemon is not reachable. +// +// Only darwin/arm64 builds compile against the bridge. Other platforms +// link a stub that returns "platform unsupported" from every constructor; +// the package itself is importable from any build so callers can keep +// platform-agnostic wiring. +// +// PR-A scope: New + Ping only. Every other Runtime method returns +// runtime.ErrNotImplemented and is filled in by PR-B onward (see +// design/status.md M6). +// +// See design/runtime-applecontainer.md for the full architecture. +package applecontainer diff --git a/runtime/applecontainer/runtime_darwin_arm64.go b/runtime/applecontainer/runtime_darwin_arm64.go new file mode 100644 index 0000000..21b219c --- /dev/null +++ b/runtime/applecontainer/runtime_darwin_arm64.go @@ -0,0 +1,183 @@ +//go:build darwin && arm64 + +// PR-A links the Swift bridge at build time via cgo LDFLAGS pointing at +// applecontainer-bridge/.build/.../release. Run `make bridge` before +// `go build ./runtime/applecontainer/...` (or any consumer). The +// embed-and-dlopen distribution path decided in +// design/runtime-applecontainer.md §13.4 lands in a follow-up PR. +package applecontainer + +/* +#cgo CFLAGS: -I${SRCDIR}/../../applecontainer-bridge/include +#cgo LDFLAGS: -L${SRCDIR}/../../applecontainer-bridge/.build/arm64-apple-macosx/release -lACBridge -Wl,-rpath,${SRCDIR}/../../applecontainer-bridge/.build/arm64-apple-macosx/release + +#include +#include "ac_bridge.h" +*/ +import "C" + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "time" + "unsafe" + + "github.com/crunchloop/devcontainer/runtime" +) + +// Runtime is the apple-container implementation of runtime.Runtime. +// +// PR-A surface: New + Ping. All other methods return +// runtime.ErrNotImplemented until PR-B onward. +type Runtime struct { + bridgeVersion string +} + +// Compile-time assertion: *Runtime satisfies runtime.Runtime. Renaming +// or adding interface methods upstream breaks the build here, surfacing +// the gap immediately. +var _ runtime.Runtime = (*Runtime)(nil) + +// Options configure New. +type Options struct { + // PingTimeoutSeconds bounds the daemon-health probe in New. Zero + // uses the bridge default (5s). + PingTimeoutSeconds int +} + +// PingResult is the parsed result of a daemon health-check probe. +type PingResult struct { + APIServerVersion string `json:"apiServerVersion"` + APIServerBuild string `json:"apiServerBuild"` + APIServerCommit string `json:"apiServerCommit"` + AppRoot string `json:"appRoot"` + InstallRoot string `json:"installRoot"` +} + +// New constructs an apple-container runtime. The constructor probes +// the daemon via ClientHealthCheck.ping and returns a +// *runtime.DaemonUnavailableError if the daemon is not reachable. +func New(ctx context.Context, opts Options) (*Runtime, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + r := &Runtime{bridgeVersion: bridgeVersion()} + if _, err := r.Ping(ctx, opts.PingTimeoutSeconds); err != nil { + return nil, err + } + return r, nil +} + +// Ping probes the daemon. Returns a *runtime.DaemonUnavailableError if +// the apiserver is unreachable (daemon not started, version skew, EUID +// mismatch). The timeoutSeconds argument bounds the underlying Swift +// `ClientHealthCheck.ping` call; <=0 uses the bridge default (5s). +// +// Exposed as a method (not just an internal helper) so callers can +// re-probe a live runtime — useful for long-running consumers that +// want to detect a daemon restart. +func (r *Runtime) Ping(ctx context.Context, timeoutSeconds int) (*PingResult, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + // Respect ctx.Deadline() by clamping timeoutSeconds to the + // remaining time. The bridge call itself is synchronous from Go's + // perspective, so we can't cancel it mid-flight — bounding the + // argument is the next-best contract. + effective := timeoutSeconds + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, ctx.Err() + } + deadlineSec := int(math.Ceil(remaining.Seconds())) + if effective <= 0 || deadlineSec < effective { + effective = deadlineSec + } + } + cstr := C.ac_ping(C.int32_t(effective)) + if cstr == nil { + return nil, &runtime.DaemonUnavailableError{Err: errors.New("bridge returned nil")} + } + raw := C.GoString(cstr) + C.ac_free(unsafe.Pointer(cstr)) + + var payload struct { + OK bool `json:"ok"` + Err string `json:"err"` + PingResult + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, fmt.Errorf("applecontainer: bridge returned invalid ping response %q: %w", raw, err) + } + if !payload.OK { + return nil, &runtime.DaemonUnavailableError{Err: errors.New(payload.Err)} + } + return &payload.PingResult, nil +} + +// BridgeVersion returns the version string baked into libACBridge.dylib. +// Useful for diagnostics and confirming the linked bridge matches what +// the test suite expects. +func (r *Runtime) BridgeVersion() string { + return r.bridgeVersion +} + +func bridgeVersion() string { + cstr := C.ac_version() + if cstr == nil { + return "" + } + defer C.ac_free(unsafe.Pointer(cstr)) + return C.GoString(cstr) +} + +// ---- runtime.Runtime stubs (filled in PR-B onward) ------------------- + +func (*Runtime) BuildImage(context.Context, runtime.BuildSpec, chan<- runtime.BuildEvent) (runtime.ImageRef, error) { + return runtime.ImageRef{}, runtime.ErrNotImplemented +} + +func (*Runtime) PullImage(context.Context, string, chan<- runtime.BuildEvent) (runtime.ImageRef, error) { + return runtime.ImageRef{}, runtime.ErrNotImplemented +} + +func (*Runtime) RunContainer(context.Context, runtime.RunSpec) (*runtime.Container, error) { + return nil, runtime.ErrNotImplemented +} + +func (*Runtime) StartContainer(context.Context, string) error { + return runtime.ErrNotImplemented +} + +func (*Runtime) StopContainer(context.Context, string, runtime.StopOptions) error { + return runtime.ErrNotImplemented +} + +func (*Runtime) RemoveContainer(context.Context, string, runtime.RemoveOptions) error { + return runtime.ErrNotImplemented +} + +func (*Runtime) ExecContainer(context.Context, string, runtime.ExecOptions) (runtime.ExecResult, error) { + return runtime.ExecResult{}, runtime.ErrNotImplemented +} + +func (*Runtime) InspectContainer(context.Context, string) (*runtime.ContainerDetails, error) { + return nil, runtime.ErrNotImplemented +} + +func (*Runtime) InspectImage(context.Context, string) (*runtime.ImageDetails, error) { + return nil, runtime.ErrNotImplemented +} + +func (*Runtime) ContainerLogs(context.Context, string, io.Writer, bool) error { + return runtime.ErrNotImplemented +} + +func (*Runtime) FindContainerByLabel(context.Context, string, string) (*runtime.Container, error) { + return nil, runtime.ErrNotImplemented +} diff --git a/runtime/applecontainer/runtime_darwin_arm64_test.go b/runtime/applecontainer/runtime_darwin_arm64_test.go new file mode 100644 index 0000000..81e0abb --- /dev/null +++ b/runtime/applecontainer/runtime_darwin_arm64_test.go @@ -0,0 +1,60 @@ +//go:build darwin && arm64 + +package applecontainer + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/crunchloop/devcontainer/runtime" +) + +// TestPing_DaemonRunning round-trips a real ClientHealthCheck.ping +// through the Swift bridge to the system container-apiserver. Requires +// `container system start` to have been run on the host. Skips if the +// daemon is not reachable. +func TestPing_DaemonRunning(t *testing.T) { + ctx := context.Background() + rt, err := New(ctx, Options{PingTimeoutSeconds: 3}) + if err != nil { + var unavail *runtime.DaemonUnavailableError + if errors.As(err, &unavail) { + t.Skipf("daemon not reachable (run `container system start`): %v", err) + } + t.Fatalf("New: %v", err) + } + + if got := rt.BridgeVersion(); !strings.HasPrefix(got, "ACBridge/") { + t.Errorf("bridge version: want prefix %q, got %q", "ACBridge/", got) + } + + res, err := rt.Ping(ctx, 3) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if res.APIServerVersion == "" { + t.Errorf("Ping: empty apiServerVersion (got %+v)", res) + } + if res.InstallRoot == "" { + t.Errorf("Ping: empty installRoot (got %+v)", res) + } +} + +// TestNew_NoDaemon_ReturnsTypedError verifies the typed error path by +// invoking Ping with a 0-second timeout against the bridge — this +// either succeeds (daemon is up) or returns a typed +// DaemonUnavailableError. Both outcomes are acceptable; we just don't +// want an untyped error to leak through. +func TestNew_TypedErrorOnFailure(t *testing.T) { + ctx := context.Background() + _, err := New(ctx, Options{PingTimeoutSeconds: 0}) + if err == nil { + return + } + var unavail *runtime.DaemonUnavailableError + if !errors.As(err, &unavail) { + t.Fatalf("expected *runtime.DaemonUnavailableError, got %T: %v", err, err) + } +} diff --git a/runtime/applecontainer/runtime_unsupported.go b/runtime/applecontainer/runtime_unsupported.go new file mode 100644 index 0000000..c803364 --- /dev/null +++ b/runtime/applecontainer/runtime_unsupported.go @@ -0,0 +1,33 @@ +//go:build !(darwin && arm64) + +package applecontainer + +import ( + "context" + "errors" +) + +// Runtime is the apple-container backend handle. On non-darwin/arm64 +// platforms it is unconstructable — New always returns an error. +type Runtime struct{} + +// Options configure New. +type Options struct { + // PingTimeoutSeconds bounds the daemon-health probe in New. Zero + // uses the bridge default (5s). + PingTimeoutSeconds int +} + +// PingResult is the parsed result of a daemon health-check probe. +type PingResult struct { + APIServerVersion string `json:"apiServerVersion"` + APIServerBuild string `json:"apiServerBuild"` + APIServerCommit string `json:"apiServerCommit"` + AppRoot string `json:"appRoot"` + InstallRoot string `json:"installRoot"` +} + +// New always returns an unsupported-platform error off darwin/arm64. +func New(_ context.Context, _ Options) (*Runtime, error) { + return nil, errors.New("applecontainer: only supported on darwin/arm64") +}