From a61738ed804495d4730a1eb9fa9117ec5440436f Mon Sep 17 00:00:00 2001 From: bilby91 <2201079+bilby91@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:26:41 +0000 Subject: [PATCH 1/2] feat!: remove the Apple Containers backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend was darwin/arm64-only and reached Apple's `container` stack through a cgo shim over a Swift package, so it could never be built or exercised on Linux CI. Its CI jobs were removed in #121 and README has documented Docker as the only backend since #122, leaving ~3.7k lines of Go, C and Swift that nothing in CI compiles and nothing in the project claims to support. Removed: runtime/applecontainer (incl. shim.c / shim.h) applecontainer-bridge (SwiftPM package + ac_bridge.h) cmd/devcontainer/runtime_applecontainer_{darwin_arm64,other}.go test/integration/applecontainer_*_test.go (8 suites) design/runtime-applecontainer.md Makefile: the bridge / bridge-clean targets and the test / test-integration dependency on them The CLI keeps --runtime as the seam for wiring a future backend, but it now accepts only `docker`; `--runtime applecontainer` is refused with `unknown runtime "applecontainer" (want docker)`. Comment-only elsewhere: doc comments describing backend-agnostic behavior no longer cite Apple as the divergent implementation, and compose/plan_test.go's all-false capability fixture is renamed from appleCaps to limitedCaps (it was always the zero value, not an Apple-specific profile). Deliberately left in place: the Capabilities gating machinery. Every field's only false case was Apple, so the refusal branches in Plan.Validate and the /etc/hosts fallback behind ServiceNameDNS are now unreachable — but their Apple provenance comments are the record of why each flag exists, and whether Capabilities() stays on the Runtime interface is a separate decision. design/compose-native.md keeps its Apple sections for the same reason design/README.md gives: those records document the state of the world when written. Co-Authored-By: Claude Opus 5 --- .dap/review/engineering.md | 11 +- .devcontainer/README.md | 7 +- .gitignore | 3 - CHANGELOG.md | 17 + Makefile | 32 +- applecontainer-bridge/.gitignore | 1 - applecontainer-bridge/Package.resolved | 293 ------------ applecontainer-bridge/Package.swift | 28 -- .../Sources/ACBridge/Helpers.swift | 139 ------ .../Sources/ACBridge/bridge.swift | 52 --- .../Sources/ACBridge/build.swift | 256 ----------- .../Sources/ACBridge/exec.swift | 212 --------- .../Sources/ACBridge/inspect.swift | 100 ---- .../Sources/ACBridge/lifecycle.swift | 403 ---------------- .../Sources/ACBridge/list.swift | 97 ---- .../Sources/ACBridge/logs.swift | 47 -- .../Sources/ACBridge/networks.swift | 116 ----- .../Sources/ACBridge/pull.swift | 40 -- .../Sources/ACBridge/volumes.swift | 80 ---- applecontainer-bridge/include/ac_bridge.h | 364 --------------- cmd/devcontainer/root.go | 6 +- .../runtime_applecontainer_darwin_arm64.go | 19 - .../runtime_applecontainer_other.go | 14 - compose/errors.go | 6 +- compose/orchestrator.go | 7 +- compose/plan.go | 6 +- compose/plan_test.go | 26 +- design/README.md | 3 +- design/runtime-applecontainer.md | 402 ---------------- engine.go | 2 +- runtime/applecontainer/build_darwin_arm64.go | 156 ------- .../applecontainer/build_darwin_arm64_test.go | 157 ------- .../compose_primitives_darwin_arm64.go | 322 ------------- runtime/applecontainer/doc.go | 21 - runtime/applecontainer/embed_darwin_arm64.go | 98 ---- runtime/applecontainer/envelope_test.go | 121 ----- runtime/applecontainer/exec_darwin_arm64.go | 349 -------------- .../applecontainer/exec_darwin_arm64_test.go | 189 -------- .../applecontainer/inspect_darwin_arm64.go | 435 ------------------ .../inspect_darwin_arm64_test.go | 184 -------- .../applecontainer/lifecycle_darwin_arm64.go | 292 ------------ .../lifecycle_darwin_arm64_test.go | 266 ----------- runtime/applecontainer/logs_darwin_arm64.go | 128 ------ .../applecontainer/logs_darwin_arm64_test.go | 191 -------- runtime/applecontainer/pull_darwin_arm64.go | 77 ---- .../applecontainer/pull_darwin_arm64_test.go | 105 ----- .../applecontainer/runtime_darwin_arm64.go | 152 ------ .../runtime_darwin_arm64_test.go | 60 --- runtime/applecontainer/runtime_unsupported.go | 33 -- runtime/applecontainer/shim.c | 251 ---------- runtime/applecontainer/shim.h | 73 --- runtime/compose_primitives.go | 12 +- runtime/errors.go | 4 +- runtime/runtime.go | 33 +- .../applecontainer_build_source_test.go | 123 ----- .../applecontainer_compose_native_test.go | 150 ------ .../applecontainer_features_test.go | 107 ----- .../applecontainer_image_metadata_test.go | 156 ------- .../applecontainer_image_source_test.go | 340 -------------- .../applecontainer_shutdown_action_test.go | 94 ---- .../applecontainer_uid_reconcile_test.go | 119 ----- .../applecontainer_userenvprobe_test.go | 159 ------- 62 files changed, 72 insertions(+), 7674 deletions(-) delete mode 100644 applecontainer-bridge/.gitignore delete mode 100644 applecontainer-bridge/Package.resolved delete mode 100644 applecontainer-bridge/Package.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/Helpers.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/bridge.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/build.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/exec.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/inspect.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/lifecycle.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/list.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/logs.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/networks.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/pull.swift delete mode 100644 applecontainer-bridge/Sources/ACBridge/volumes.swift delete mode 100644 applecontainer-bridge/include/ac_bridge.h delete mode 100644 cmd/devcontainer/runtime_applecontainer_darwin_arm64.go delete mode 100644 cmd/devcontainer/runtime_applecontainer_other.go delete mode 100644 design/runtime-applecontainer.md delete mode 100644 runtime/applecontainer/build_darwin_arm64.go delete mode 100644 runtime/applecontainer/build_darwin_arm64_test.go delete mode 100644 runtime/applecontainer/compose_primitives_darwin_arm64.go delete mode 100644 runtime/applecontainer/doc.go delete mode 100644 runtime/applecontainer/embed_darwin_arm64.go delete mode 100644 runtime/applecontainer/envelope_test.go delete mode 100644 runtime/applecontainer/exec_darwin_arm64.go delete mode 100644 runtime/applecontainer/exec_darwin_arm64_test.go delete mode 100644 runtime/applecontainer/inspect_darwin_arm64.go delete mode 100644 runtime/applecontainer/inspect_darwin_arm64_test.go delete mode 100644 runtime/applecontainer/lifecycle_darwin_arm64.go delete mode 100644 runtime/applecontainer/lifecycle_darwin_arm64_test.go delete mode 100644 runtime/applecontainer/logs_darwin_arm64.go delete mode 100644 runtime/applecontainer/logs_darwin_arm64_test.go delete mode 100644 runtime/applecontainer/pull_darwin_arm64.go delete mode 100644 runtime/applecontainer/pull_darwin_arm64_test.go delete mode 100644 runtime/applecontainer/runtime_darwin_arm64.go delete mode 100644 runtime/applecontainer/runtime_darwin_arm64_test.go delete mode 100644 runtime/applecontainer/runtime_unsupported.go delete mode 100644 runtime/applecontainer/shim.c delete mode 100644 runtime/applecontainer/shim.h delete mode 100644 test/integration/applecontainer_build_source_test.go delete mode 100644 test/integration/applecontainer_compose_native_test.go delete mode 100644 test/integration/applecontainer_features_test.go delete mode 100644 test/integration/applecontainer_image_metadata_test.go delete mode 100644 test/integration/applecontainer_image_source_test.go delete mode 100644 test/integration/applecontainer_shutdown_action_test.go delete mode 100644 test/integration/applecontainer_uid_reconcile_test.go delete mode 100644 test/integration/applecontainer_userenvprobe_test.go diff --git a/.dap/review/engineering.md b/.dap/review/engineering.md index 860cb85..068b1d6 100644 --- a/.dap/review/engineering.md +++ b/.dap/review/engineering.md @@ -38,7 +38,7 @@ and is never read at the boundary produces a devcontainer that silently ignores devcontainers — the daemon came up unprivileged and its entrypoint never ran (#103). - The inverse counts too: a value read at the boundary that no parsing path can ever set. -## R2. Path parity — native, shell-out, and each backend +## R2. Path parity — native, shell-out, and the backend boundary Refines `D1`. This repository implements the same behaviour more than once by design. @@ -46,10 +46,10 @@ Refines `D1`. This repository implements the same behaviour more than once by de (`docker compose`). A fix, guard, or flag added to one and not the other is a finding — name the sibling call site and say what it does instead. Both paths carried the same recreate bug (#71, #72) and the same entrypoint gap (#103). -- **runtime** has `docker` and `applecontainer` backends behind one interface. - A change to shared orchestration must state what each backend does with it; a change - inside one backend must say whether the other needs the same. Apple diverges from - Docker in ways that have already broken workspaces (below). +- **runtime** has one backend today — `docker` — behind the `runtime.Runtime` + interface. Shared orchestration (engine, compose) must reach it through that + interface; a diff that leaks Docker-specific behaviour into shared code is a + finding, because the interface is what keeps a second backend possible. - A capability flag on `Capabilities()` (`ServiceNameDNS`, for instance) is the legitimate way to encode divergence. A silent assumption that all backends behave like Docker is not. @@ -150,4 +150,3 @@ Do not file these here: documented `Known limitations` in the CHANGELOG. Absence of a non-goal is not a defect. - Dependency version bumps with no code change, beyond an actual incompatibility you can point at in the diff. -- The Swift bridge under `applecontainer-bridge/` unless the diff touches it. diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 6af7dbe..c4634f2 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -39,16 +39,11 @@ The prebuild image provides everything the Linux CI jobs need: `docker` / `docker compose` from inside the container. - **GitHub CLI** and `make`. -> The Apple `container` backend (`runtime/applecontainer`) is darwin/arm64-only -> and cannot be built inside this Linux container — exactly as on the Linux CI -> jobs, where `make bridge` is a no-op. Use a native macOS checkout for that -> backend. - ## Common tasks ```bash make lint # golangci-lint run ./... -make test # go test -race ./... (bridge is a no-op on Linux) +make test # go test -race ./... make test-integration # docker-backed integration suite ``` diff --git a/.gitignore b/.gitignore index fe50773..652855c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,3 @@ go.work.sum # work-in-progress drafts, milestone trackers, etc. PRD.md design/private/ - -# Local applecontainer working directory (not committed). -examples/applecontainer-spike/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f674ed8..ec311ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- **BREAKING — the Apple Containers backend is removed.** `runtime/applecontainer` + and the `applecontainer-bridge` Swift package (reached through a cgo shim) are + deleted, along with the `--runtime applecontainer` CLI value: `--runtime` now + accepts `docker` only, and any other value is refused with + `unknown runtime %q (want docker)`. The backend was darwin/arm64-only, could not + be built or exercised on Linux CI. Its CI jobs were already removed earlier in + this same unreleased line, which is also when Docker became the only documented + backend. Also gone: + the `bridge` / `bridge-clean` Makefile targets (and the `test` / + `test-integration` dependency on them, so neither target shells out to `swift` + any more) and the eight `test/integration/applecontainer_*_test.go` suites. +- The design record `design/runtime-applecontainer.md` is deleted with the code it + described, and its row removed from the `design/` index. It remains readable in + git history at tag `v0.4.3`. `design/compose-native.md` keeps its Apple sections: + per `design/README.md` those records document the state of the world when + written, and the probe results and rejected alternatives in them are still the + reasoning behind the compose orchestrator's shape. - **BREAKING — checkpoint/restore is gone.** The feature only ever worked on Podman (docker's restore is broken upstream on containerd-integrated engines), and the Podman backend existed to diff --git a/Makefile b/Makefile index a043681..4e232b2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all test test-integration lint fmt vet tidy clean tools bridge bridge-clean +.PHONY: all test test-integration lint fmt vet tidy clean tools GO ?= go GOLANGCI_LINT ?= golangci-lint @@ -12,13 +12,10 @@ all: lint test tools: $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) -# test depends on bridge so the embedded dylib is present on -# darwin/arm64 (go:embed fails the build if the file is missing). On -# other platforms bridge is a no-op so this dependency is free. -test: bridge +test: $(GO) test -race -count=1 ./... -test-integration: bridge +test-integration: $(GO) test -race -count=1 -tags=integration -timeout=10m ./test/integration/... lint: @@ -36,26 +33,3 @@ tidy: clean: $(GO) clean -testcache -# bridge builds libACBridge.dylib via SwiftPM and copies it into -# runtime/applecontainer/embed/ where go:embed picks it up. Required -# before any Go build that imports runtime/applecontainer on -# darwin/arm64. On other platforms it's a no-op so this target can be -# unconditionally listed as a dependency by `test` / `test-integration` -# without burdening Linux CI. -bridge: - @if [ "$$(uname -s)" = "Darwin" ] && [ "$$(uname -m)" = "arm64" ]; then \ - cd applecontainer-bridge && swift build -c release && \ - mkdir -p ../runtime/applecontainer/embed && \ - cp .build/arm64-apple-macosx/release/libACBridge.dylib \ - ../runtime/applecontainer/embed/libACBridge.dylib; \ - 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) && \ - rm -f runtime/applecontainer/embed/libACBridge.dylib; \ - else \ - echo "bridge-clean: skipped (requires darwin/arm64)"; \ - fi diff --git a/applecontainer-bridge/.gitignore b/applecontainer-bridge/.gitignore deleted file mode 100644 index 30bcfa4..0000000 --- a/applecontainer-bridge/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.build/ diff --git a/applecontainer-bridge/Package.resolved b/applecontainer-bridge/Package.resolved deleted file mode 100644 index e8afb9a..0000000 --- a/applecontainer-bridge/Package.resolved +++ /dev/null @@ -1,293 +0,0 @@ -{ - "pins" : [ - { - "identity" : "async-http-client", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/async-http-client.git", - "state" : { - "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", - "version" : "1.33.1" - } - }, - { - "identity" : "container", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/container.git", - "state" : { - "revision" : "f9899013fd43dd058fdf89709eed0b0861bfd931", - "version" : "0.12.3" - } - }, - { - "identity" : "containerization", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/containerization.git", - "state" : { - "revision" : "f1ee6f8b737ab8dffbd620bfa47283f6f0bc1822", - "version" : "0.31.0" - } - }, - { - "identity" : "grpc-swift-2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-swift-2.git", - "state" : { - "revision" : "21fe69ab7ce0e87ac089534733c52f037e74a3eb", - "version" : "2.4.1" - } - }, - { - "identity" : "grpc-swift-nio-transport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", - "state" : { - "revision" : "f62a09000685b5b86ee383b63e042f286b1a5422", - "version" : "2.7.0" - } - }, - { - "identity" : "grpc-swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-swift-protobuf.git", - "state" : { - "revision" : "8723cf856dc23d9c2fad4d874e7b9ed3254acf03", - "version" : "2.3.0" - } - }, - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms.git", - "state" : { - "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", - "version" : "1.7.1" - } - }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", - "version" : "1.7.0" - } - }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", - "version" : "1.1.3" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-certificates", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-certificates.git", - "state" : { - "revision" : "bde8ca32a096825dfce37467137c903418c1893d", - "version" : "1.19.1" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "03cc312c2c933ed87abace34044a5dff7a3117c1", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-configuration", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-configuration.git", - "state" : { - "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" - } - }, - { - "identity" : "swift-distributed-tracing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-distributed-tracing.git", - "state" : { - "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", - "version" : "1.4.1" - } - }, - { - "identity" : "swift-http-structured-headers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-structured-headers.git", - "state" : { - "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", - "version" : "1.7.0" - } - }, - { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types.git", - "state" : { - "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", - "version" : "1.5.1" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", - "version" : "1.12.0" - } - }, - { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", - "version" : "2.99.0" - } - }, - { - "identity" : "swift-nio-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-extras.git", - "state" : { - "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", - "version" : "1.34.0" - } - }, - { - "identity" : "swift-nio-http2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-http2.git", - "state" : { - "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", - "version" : "1.43.0" - } - }, - { - "identity" : "swift-nio-ssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl.git", - "state" : { - "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", - "version" : "2.37.0" - } - }, - { - "identity" : "swift-nio-transport-services", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-transport-services.git", - "state" : { - "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", - "version" : "1.28.0" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "81558271e243f8f47dfe8e9fdd55f3c2b5413f68", - "version" : "1.37.0" - } - }, - { - "identity" : "swift-service-context", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context.git", - "state" : { - "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-service-lifecycle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle.git", - "state" : { - "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", - "version" : "2.11.0" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", - "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" - } - }, - { - "identity" : "swift-toml", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattt/swift-toml.git", - "state" : { - "revision" : "827506c90475e82d5a7f191f950fb3025cbdc0d6", - "version" : "2.0.0" - } - }, - { - "identity" : "yams", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/Yams.git", - "state" : { - "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", - "version" : "6.2.1" - } - }, - { - "identity" : "zstd", - "kind" : "remoteSourceControl", - "location" : "https://github.com/facebook/zstd.git", - "state" : { - "revision" : "f8745da6ff1ad1e7bab384bd1f9d742439278e99", - "version" : "1.5.7" - } - } - ], - "version" : 2 -} diff --git a/applecontainer-bridge/Package.swift b/applecontainer-bridge/Package.swift deleted file mode 100644 index 319abf6..0000000 --- a/applecontainer-bridge/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// 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"), - // 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" - ), - ] -) diff --git a/applecontainer-bridge/Sources/ACBridge/Helpers.swift b/applecontainer-bridge/Sources/ACBridge/Helpers.swift deleted file mode 100644 index d566c93..0000000 --- a/applecontainer-bridge/Sources/ACBridge/Helpers.swift +++ /dev/null @@ -1,139 +0,0 @@ -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? { - 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": } -func encodeOK(_ 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. 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 { - 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. -func readCString(_ p: UnsafePointer?) -> 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? { - UnsafePointer(strdup("{\"ok\":false,\"err\":\"null \(argName)\"}")) -} diff --git a/applecontainer-bridge/Sources/ACBridge/bridge.swift b/applecontainer-bridge/Sources/ACBridge/bridge.swift deleted file mode 100644 index 1fafd20..0000000 --- a/applecontainer-bridge/Sources/ACBridge/bridge.swift +++ /dev/null @@ -1,52 +0,0 @@ -import ContainerAPIClient -import Foundation - -// ===== ac_version ===================================================== - -@_cdecl("ac_version") -public func ac_version() -> UnsafePointer? { - let s = "ACBridge/\(bridgeBuildVersion) apple-container/\(applePinnedVersion)" - return UnsafePointer(strdup(s)) -} - -// ===== ac_ping ======================================================== - -@_cdecl("ac_ping") -public func ac_ping(_ timeoutSeconds: Int32) -> UnsafePointer? { - let seconds = Int(timeoutSeconds <= 0 ? 5 : timeoutSeconds) - return runSync(timeoutSeconds: seconds) { - do { - let h = try await ClientHealthCheck.ping(timeout: .seconds(seconds)) - return encodePingOK(h) - } catch { - return encodeErr(error) - } - } -} - -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, - "apiServerBuild": h.apiServerBuild, - "apiServerCommit": h.apiServerCommit, - "appRoot": h.appRoot.path, - "installRoot": h.installRoot.path, - ] - guard let data = try? JSONSerialization.data(withJSONObject: payload), - let s = String(data: data, encoding: .utf8) - else { - return "{\"ok\":true}" - } - return s -} - -// ===== ac_free ======================================================== - -@_cdecl("ac_free") -public func ac_free(_ p: UnsafeMutableRawPointer?) { - free(p) -} diff --git a/applecontainer-bridge/Sources/ACBridge/build.swift b/applecontainer-bridge/Sources/ACBridge/build.swift deleted file mode 100644 index 6d85a57..0000000 --- a/applecontainer-bridge/Sources/ACBridge/build.swift +++ /dev/null @@ -1,256 +0,0 @@ -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? { - 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?) -> UnsafePointer? { - 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)" - ) - } - - // Export destination MUST live under the apiserver's appRoot at - // /builder//, which the apiserver mounts into - // the buildkit VM as /var/lib/container-builder-shim/exports//. - // 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] -} diff --git a/applecontainer-bridge/Sources/ACBridge/exec.swift b/applecontainer-bridge/Sources/ACBridge/exec.swift deleted file mode 100644 index 9681e4a..0000000 --- a/applecontainer-bridge/Sources/ACBridge/exec.swift +++ /dev/null @@ -1,212 +0,0 @@ -import ContainerAPIClient -import ContainerResource -import Containerization -import Darwin -import Foundation - -// Handle table for in-flight exec processes. Each handle owns: -// - the ClientProcess for signaling/waiting -// - the FileHandles wrapping the caller-supplied stdio fds, kept -// alive so they aren't released while the apiserver-side dup is -// still active. -// -// Thread safety: NSLock around a dictionary. The synchronous @_cdecl -// entry points all touch the table once, so contention is negligible. -private final class ExecRegistry: @unchecked Sendable { - struct Entry { - let process: any ClientProcess - let stdio: [FileHandle?] - } - - private let lock = NSLock() - private var entries: [UInt64: Entry] = [:] - private var next: UInt64 = 1 - - func register(_ entry: Entry) -> UInt64 { - lock.lock() - defer { lock.unlock() } - let h = next - next &+= 1 - if next == 0 { next = 1 } // skip the sentinel value - entries[h] = entry - return h - } - - func get(_ h: UInt64) -> Entry? { - lock.lock() - defer { lock.unlock() } - return entries[h] - } - - func remove(_ h: UInt64) { - lock.lock() - defer { lock.unlock() } - entries.removeValue(forKey: h) - } -} - -private let execRegistry = ExecRegistry() - -// ExecOptsJSON is the wire shape from runtime.ExecOptions. -// -// stdio fds are passed as separate int arguments rather than nested -// in this JSON so the C ABI for ac_exec_start stays straightforward. -// A fd value of -1 means "no pipe" (e.g. nil stdin, or stderr in TTY -// mode where Apple merges into stdout). -private struct ExecOptsJSON: Decodable { - var cmd: [String] - var env: [String]? - var user: String? - var workingDir: String? - var tty: Bool? -} - -private struct ExecStartResult: Encodable { - let handle: UInt64 -} - -private struct ExecWaitResult: Encodable { - let exitCode: Int32 -} - -// Sync timeouts. exec_start should be fast (XPC round-trip). -// exec_wait blocks for the process duration, which is unbounded by -// design — the caller chooses the timeout per call. -private let execStartTimeoutSeconds = 30 - -// ===== ac_exec_start ================================================= - -@_cdecl("ac_exec_start") -public func ac_exec_start( - _ idPtr: UnsafePointer?, - _ optsPtr: UnsafePointer?, - _ stdinReadFd: Int32, - _ stdoutWriteFd: Int32, - _ stderrWriteFd: Int32 -) -> UnsafePointer? { - guard let containerId = readCString(idPtr) else { return dupNullArgErr("id") } - guard let optsStr = readCString(optsPtr) else { return dupNullArgErr("opts") } - - return runSync(timeoutSeconds: execStartTimeoutSeconds) { - do { - guard let optsData = optsStr.data(using: .utf8) else { - return "{\"ok\":false,\"err\":\"opts not utf8\"}" - } - let opts = try JSONDecoder().decode(ExecOptsJSON.self, from: optsData) - guard !opts.cmd.isEmpty else { - return "{\"ok\":false,\"err\":\"empty cmd\"}" - } - - let client = ContainerClient() - let snap = try await client.get(id: containerId) - - // Start from the init process config (inherits PATH, etc.) - // and override what the caller specified, matching the - // CLI's exec command (ContainerExec.swift). - var cfg = snap.configuration.initProcess - cfg.executable = opts.cmd[0] - cfg.arguments = Array(opts.cmd.dropFirst()) - cfg.terminal = opts.tty ?? false - if let e = opts.env, !e.isEmpty { - cfg.environment = e - } - if let cwd = opts.workingDir, !cwd.isEmpty { - cfg.workingDirectory = cwd - } - if let user = opts.user, !user.isEmpty { - if let parsed = parseUserForExec(user) { - cfg.user = parsed - } - } - - // Wrap caller-supplied fds in FileHandles. closeOnDealloc: - // false keeps fd lifetime in the caller's hands; XPC - // dup's the fd when serializing so the apiserver gets its - // own. After createProcess returns, the caller is free to - // close its end. - let stdio: [FileHandle?] = [ - fileHandleOrNil(stdinReadFd), - fileHandleOrNil(stdoutWriteFd), - fileHandleOrNil(stderrWriteFd), - ] - - let processId = "exec-" + UUID().uuidString.prefix(8) - let process = try await client.createProcess( - containerId: containerId, - processId: String(processId), - configuration: cfg, - stdio: stdio - ) - try await process.start() - - let handle = execRegistry.register(.init(process: process, stdio: stdio)) - return encodeOK(ExecStartResult(handle: handle)) - } catch { - return encodeErr(error) - } - } -} - -// ===== ac_exec_wait ================================================== - -@_cdecl("ac_exec_wait") -public func ac_exec_wait(_ handle: UInt64, _ timeoutSeconds: Int32) -> UnsafePointer? { - guard let entry = execRegistry.get(handle) else { - return UnsafePointer(strdup("{\"ok\":false,\"err\":\"unknown exec handle\"}")) - } - // timeoutSeconds <= 0 → effectively unbounded (Int32.max). The - // bridge's runSync still bounds the underlying wait via its hard - // cap, but for exec we want it large so legitimate long-running - // processes don't trip a bridge-side timeout. - let timeout = timeoutSeconds > 0 ? Int(timeoutSeconds) : Int(Int32.max / 1000) - return runSync(timeoutSeconds: timeout) { - do { - let code = try await entry.process.wait() - return encodeOK(ExecWaitResult(exitCode: code)) - } catch { - return encodeErr(error) - } - } -} - -// ===== ac_exec_signal ================================================ - -@_cdecl("ac_exec_signal") -public func ac_exec_signal(_ handle: UInt64, _ signal: Int32) -> UnsafePointer? { - guard let entry = execRegistry.get(handle) else { - return UnsafePointer(strdup("{\"ok\":false,\"err\":\"unknown exec handle\"}")) - } - return runSync(timeoutSeconds: 5) { - do { - try await entry.process.kill(signal) - return "{\"ok\":true}" - } catch { - return encodeErr(error) - } - } -} - -// ===== ac_exec_release =============================================== - -@_cdecl("ac_exec_release") -public func ac_exec_release(_ handle: UInt64) { - execRegistry.remove(handle) -} - -// ---- helpers -------------------------------------------------------- - -private func fileHandleOrNil(_ fd: Int32) -> FileHandle? { - if fd < 0 { return nil } - return FileHandle(fileDescriptor: fd, closeOnDealloc: false) -} - -private func parseUserForExec(_ s: String) -> ProcessConfiguration.User? { - let parts = s.split(separator: ":") - if parts.count == 2, - let uid = UInt32(parts[0]), - let gid = UInt32(parts[1]) - { - return .id(uid: uid, gid: gid) - } - return .raw(userString: s) -} diff --git a/applecontainer-bridge/Sources/ACBridge/inspect.swift b/applecontainer-bridge/Sources/ACBridge/inspect.swift deleted file mode 100644 index 5cdbf28..0000000 --- a/applecontainer-bridge/Sources/ACBridge/inspect.swift +++ /dev/null @@ -1,100 +0,0 @@ -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?) -> UnsafePointer? { - 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?) -> UnsafePointer? { - 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?, - _ valuePtr: UnsafePointer? -) -> UnsafePointer? { - 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) - } - } -} diff --git a/applecontainer-bridge/Sources/ACBridge/lifecycle.swift b/applecontainer-bridge/Sources/ACBridge/lifecycle.swift deleted file mode 100644 index f15a743..0000000 --- a/applecontainer-bridge/Sources/ACBridge/lifecycle.swift +++ /dev/null @@ -1,403 +0,0 @@ -import ContainerAPIClient -import ContainerResource -import Containerization -import ContainerizationOCI -import Foundation - -// Run-spec JSON wire shape — Go side marshals runtime.RunSpec into -// this. Apple-side fields we don't model yet (publishedPorts, -// resources, dns) get defaulted by ContainerConfiguration's -// initializers. Engine-level concepts we intentionally drop on this -// backend (RunArgs, Privileged, SecurityOpt) are documented in -// design/runtime-applecontainer.md §8. -private struct RunSpecJSON: Decodable { - var image: String - var id: String - var cmd: [String]? - var entrypoint: [String]? - var user: String? - var workingDir: String? - var env: [String]? - var labels: [String: String]? - var mounts: [MountJSON]? - // Network IDs the container should be attached to. Empty / nil - // means "no explicit attachment" — apple's apiserver auto-joins - // the built-in default network when the field is unset. The - // compose orchestrator passes _default here so its - // services land on the project network it created via - // NetworkClient.create. - var networks: [String]? - var initProcess: Bool? - var capAdd: [String]? - var overrideCommand: Bool? - // Hard memory limit for the per-container VM, in bytes. Zero or - // absent leaves apple's default (1 GiB on 0.12.x) in place. - var memoryBytes: Int64? - // CPU limit in nano-units (1_000_000_000 = 1 CPU). Apple's - // apiserver takes an integer CPU count, so the bridge rounds up - // to the next whole CPU. Zero or absent leaves apple's default (4) - // in place. - var nanoCPUs: Int64? -} - -private struct MountJSON: Decodable { - // type ∈ {"bind", "tmpfs", "volume"}; anything else returns an error. - var type: String - var source: String? - var target: String - var readOnly: Bool? - // ignored fields on this backend (Propagation, etc.) are not modeled. -} - -// Run-result wire shape — the Go side decodes this into runtime.Container. -private struct RunResult: Encodable { - var id: String -} - -// Default for sync lifecycle calls. Container creation can take a few -// seconds for kernel/init-image fetches; 60s is generous enough for -// cold first runs without leaving the cgo caller blocked indefinitely. -private let lifecycleTimeoutSeconds = 60 - -// ===== ac_run ======================================================== - -@_cdecl("ac_run") -public func ac_run(_ specPtr: UnsafePointer?) -> UnsafePointer? { - guard let specStr = readCString(specPtr) else { return dupNullArgErr("spec") } - return runSync(timeoutSeconds: lifecycleTimeoutSeconds) { - do { - guard let specData = specStr.data(using: .utf8) else { - return "{\"ok\":false,\"err\":\"spec not utf8\"}" - } - let spec = try JSONDecoder().decode(RunSpecJSON.self, from: specData) - try await runContainer(spec: spec) - return encodeOK(RunResult(id: spec.id)) - } catch { - return encodeErr(error) - } - } -} - -private func runContainer(spec: RunSpecJSON) async throws { - guard !spec.id.isEmpty else { - throw BridgeError.invalidArgument("RunSpec.id is required") - } - guard !spec.image.isEmpty else { - throw BridgeError.invalidArgument("RunSpec.image is required") - } - - // The image must already be in the local content store. Pull is - // PR-F's job; here we assume the caller has done it. - // Resolve the platform on the cached image first, then re-fetch - // for that platform so the daemon has the correct snapshot - // staged. apple/container's containerConfigFromFlags uses - // ClientImage.fetch (not get) for exactly this reason: get - // returns the index entry but doesn't ensure a per-platform - // snapshot is present, and ContainerClient().create rejects - // missing snapshots as "does not support required platforms". - // Resolve the image's platform. For multi-arch images, prefer - // the host's `.current`; for single-arch images (commonly - // amd64-only when the publisher only builds on x86 CI), fall - // back to whatever the image actually carries. Then stage the - // per-platform snapshot the apiserver requires before create — - // mirrors apple/container CLI's containerConfigFromFlags path. - let img = try await ClientImage.get(reference: spec.image) - let platform = try await resolvePlatform(for: img) - try await img.getCreateSnapshot(platform: platform, progressUpdate: nil) - let imageConfig = try await img.config(for: platform).config - - let process = try buildProcessConfiguration(spec: spec, imageConfig: imageConfig) - - var cfg = ContainerConfiguration(id: spec.id, image: img.description, process: process) - cfg.platform = platform - cfg.labels = spec.labels ?? [:] - var resolvedMounts: [Filesystem] = [] - for m in spec.mounts ?? [] { - resolvedMounts.append(try await toFilesystem(m)) - } - cfg.mounts = resolvedMounts - cfg.capAdd = spec.capAdd ?? [] - cfg.useInit = spec.initProcess ?? false - // Resource limits. Apply only when caller specified a value; - // leave apple's Resources defaults (4 cpus / 1 GiB) untouched - // otherwise. Negative inputs are clamped out at the bridge - // boundary; the Go side rejects them earlier too. - if let mem = spec.memoryBytes, mem > 0 { - cfg.resources.memoryInBytes = UInt64(mem) - } - if let nano = spec.nanoCPUs, nano > 0 { - // Round up to the next whole CPU. NanoCPUs of 1_500_000_000 - // (1.5 cpus) → cpus = 2. Apple's apiserver doesn't model - // fractional CPU shares; callers expressing a fractional - // limit get the next whole CPU rather than a silent floor. - let cpus = Int((nano + 999_999_999) / 1_000_000_000) - if cpus > 0 { - cfg.resources.cpus = cpus - } - } - // Enable Rosetta when running an amd64 container on an arm64 - // host. Without this flag the apiserver rejects amd64 containers - // with "unsupported: platform linux/amd64". Mirrors - // apple/container CLI's containerConfigFromFlags auto-enabling - // of rosetta for the same case. Subject to host's Rosetta-for- - // Linux being installed and Virtualization.framework allowing - // its use — neither is universally available, and an - // unsupported host surfaces as VZErrorDomain Code=1 at bootstrap. - let host = ContainerizationOCI.Platform.current - if host.architecture == "arm64" && platform.architecture == "amd64" { - cfg.rosetta = true - } - // Attach explicitly to any networks the caller requested. The - // hostname per attachment defaults to the container id, matching - // apple/container CLI's behavior. Empty Networks => no override: - // the apiserver attaches to the built-in default automatically. - if let nets = spec.networks, !nets.isEmpty { - cfg.networks = nets.map { - AttachmentConfiguration(network: $0, options: AttachmentOptions(hostname: spec.id)) - } - } - - // Kernel selection: always use the host platform. For amd64 - // containers on arm64 hosts, the VM still runs an arm64 kernel - // and Apple's Rosetta translates amd64 userland binaries - // (cfg.rosetta=true, set below). Mirrors apple/container's CLI: - // the kernel is host-arch; the container's platform only - // influences Rosetta enablement and image manifest selection. - let hostSysPlatform: SystemPlatform = .linuxArm - let kernel = try await ClientKernel.getDefaultKernel(for: hostSysPlatform) - // Stage the init image for the host platform (.current). - // The init binary runs in the VM's pid 1 slot — apple's - // apiserver wires up a translation when the container's - // platform differs (Rosetta on Apple silicon). Mirrors the - // CLI's containerConfigFromFlags: it always fetches init for - // .current regardless of the container's platform. - let initImageRef = ClientImage.initImageRef - let initImg = try await ClientImage.fetch( - reference: initImageRef, - platform: .current, - scheme: .auto, - progressUpdate: nil - ) - try await initImg.getCreateSnapshot(platform: .current, progressUpdate: nil) - - let options = ContainerCreateOptions(autoRemove: false) - try await ContainerClient().create( - configuration: cfg, - options: options, - kernel: kernel, - initImage: initImageRef - ) -} - -private func buildProcessConfiguration( - spec: RunSpecJSON, - imageConfig: ImageConfig? -) throws -> ProcessConfiguration { - let executable: String - let arguments: [String] - - if spec.overrideCommand ?? false { - // Engine sets OverrideCommand=true to make the container - // long-lived for exec. Matches the docker runtime's choice - // (runtime/runtime.go:228-231). - executable = "/bin/sh" - arguments = ["-c", "while sleep 1000; do :; done"] - } else { - // Merge image defaults with spec-provided cmd/entrypoint. - // Entrypoint replaces image's Entrypoint; Cmd replaces image's - // Cmd. If both are missing on spec side, fall back to image - // config. This matches OCI conventions and docker semantics. - let entry = spec.entrypoint ?? imageConfig?.entrypoint ?? [] - let cmd = spec.cmd ?? imageConfig?.cmd ?? [] - let combined = entry + cmd - guard let first = combined.first else { - throw BridgeError.invalidArgument("no executable: spec.cmd/entrypoint empty and image has none") - } - executable = first - arguments = Array(combined.dropFirst()) - } - - let env: [String] - if let e = spec.env, !e.isEmpty { - env = e - } else { - env = imageConfig?.env ?? [] - } - - let user = parseUser(spec.user) ?? imageConfigUser(imageConfig) - - return ProcessConfiguration( - executable: executable, - arguments: arguments, - environment: env, - workingDirectory: spec.workingDir ?? imageConfig?.workingDir ?? "/", - terminal: false, - user: user, - supplementalGroups: [], - rlimits: [] - ) -} - -// parseUser turns the textual user spec from RunSpec.User into the -// Codable form Apple wants. ":" → .id; anything else -// (e.g. "vscode" or "vscode:dev") → .raw, which the apiserver -// resolves inside the container. -private func parseUser(_ s: String?) -> ProcessConfiguration.User? { - guard let s, !s.isEmpty else { return nil } - let parts = s.split(separator: ":") - if parts.count == 2, - let uid = UInt32(parts[0]), - let gid = UInt32(parts[1]) - { - return .id(uid: uid, gid: gid) - } - return .raw(userString: s) -} - -private func imageConfigUser(_ cfg: ImageConfig?) -> ProcessConfiguration.User { - if let s = cfg?.user, !s.isEmpty { - return parseUser(s) ?? .raw(userString: s) - } - return .id(uid: 0, gid: 0) -} - -private func toFilesystem(_ m: MountJSON) async throws -> Filesystem { - var options: MountOptions = [] - if m.readOnly ?? false { - options.append("ro") - } - switch m.type { - case "bind": - guard let src = m.source, !src.isEmpty else { - throw BridgeError.invalidArgument("bind mount requires source") - } - return .virtiofs(source: src, destination: m.target, options: options) - case "tmpfs": - return .tmpfs(destination: m.target, options: options) - case "volume": - // Named volume: source carries the volume name. Resolve it - // through ClientVolume.inspect to fetch the backing image - // path + filesystem format the apiserver expects, then build - // a proper Filesystem.volume. Treating the spec as a virtiofs - // bind (PR-C's interim shape) makes the apiserver resolve the - // name against CWD and fail with errno 2 at bootstrap. - guard let name = m.source, !name.isEmpty else { - throw BridgeError.invalidArgument("named volume mount requires source (volume name)") - } - let vol = try await ClientVolume.inspect(name) - return .volume( - name: vol.name, - format: vol.format, - source: vol.source, - destination: m.target, - options: options - ) - default: - throw BridgeError.invalidArgument("unknown mount type \"\(m.type)\"") - } -} - -// resolvePlatform picks a platform descriptor the image actually -// supports. Default preference is the host's `.current`; if the -// image's index doesn't carry that variant (common case: amd64-only -// images on Apple silicon), fall back to the first variant the -// image's index declares. Falls back to .current if the image -// store can't surface an index (legacy single-manifest images). -private func resolvePlatform(for img: ClientImage) async throws -> ContainerizationOCI.Platform { - let current = ContainerizationOCI.Platform.current - do { - let index = try await img.index() - for desc in index.manifests { - if let p = desc.platform, p.architecture == current.architecture && p.os == current.os { - return current - } - } - for desc in index.manifests { - if let p = desc.platform { - return p - } - } - } catch { - // Single-manifest image or other index lookup error; - // .current is the right default to try. - } - return current -} - -private enum BridgeError: LocalizedError { - case invalidArgument(String) - - var errorDescription: String? { - switch self { - case .invalidArgument(let m): return "invalid argument: \(m)" - } - } -} - -// ===== ac_start ====================================================== - -@_cdecl("ac_start") -public func ac_start(_ idPtr: UnsafePointer?) -> UnsafePointer? { - guard let id = readCString(idPtr) else { return dupNullArgErr("id") } - return runSync(timeoutSeconds: lifecycleTimeoutSeconds) { - do { - let client = ContainerClient() - let snap = try await client.get(id: id) - // Idempotency: if the container is already running, return - // success. Matches the CLI's behavior in - // ContainerStart.swift L60-72 and Docker's "start" on a - // running container (no-op). - if snap.status == .running { - return "{\"ok\":true}" - } - // Detached start: no stdio attachment. ProcessIO with - // detach=true returns [nil,nil,nil] for stdio; we replicate - // that directly without instantiating ProcessIO. - let process = try await client.bootstrap( - id: id, - stdio: [nil, nil, nil], - dynamicEnv: [:] - ) - try await process.start() - return "{\"ok\":true}" - } catch { - return encodeErr(error) - } - } -} - -// ===== ac_stop ======================================================= - -@_cdecl("ac_stop") -public func ac_stop(_ idPtr: UnsafePointer?, _ timeoutSeconds: Int32) -> UnsafePointer? { - guard let id = readCString(idPtr) else { return dupNullArgErr("id") } - return runSync(timeoutSeconds: lifecycleTimeoutSeconds) { - do { - // Apple's ContainerStopOptions has its own grace-period - // knob. timeoutSeconds <= 0 uses the API default (5s - // SIGTERM, then SIGKILL). - let opts: ContainerStopOptions = timeoutSeconds > 0 - ? .init(timeoutInSeconds: timeoutSeconds, signal: SIGTERM) - : .default - try await ContainerClient().stop(id: id, opts: opts) - return "{\"ok\":true}" - } catch { - return encodeErr(error) - } - } -} - -// ===== ac_delete ===================================================== - -@_cdecl("ac_delete") -public func ac_delete(_ idPtr: UnsafePointer?, _ force: Int32) -> UnsafePointer? { - guard let id = readCString(idPtr) else { return dupNullArgErr("id") } - return runSync(timeoutSeconds: lifecycleTimeoutSeconds) { - do { - try await ContainerClient().delete(id: id, force: force != 0) - return "{\"ok\":true}" - } catch { - return encodeErr(error) - } - } -} diff --git a/applecontainer-bridge/Sources/ACBridge/list.swift b/applecontainer-bridge/Sources/ACBridge/list.swift deleted file mode 100644 index c66039e..0000000 --- a/applecontainer-bridge/Sources/ACBridge/list.swift +++ /dev/null @@ -1,97 +0,0 @@ -import ContainerAPIClient -import ContainerizationError -import ContainerizationOCI -import Foundation - -// Compose orchestrator primitives — listing containers / images and -// removing images. Apple's surface lacks server-side label -// filtering (probe R1b confirmed `container list` has no --filter -// in 0.12.3), so these exports enumerate full result sets and -// expect the Go layer to filter. The result-set size for a single -// project is small enough that the overhead is negligible. - -private let listTimeoutSeconds = 15 - -// ContainerListItem is the projection per container the Go side -// consumes from ListContainers. Mirrors the runtime.Container shape -// the docker backend returns from its own ContainerList. -private struct ContainerListItem: Encodable { - let id: String - let name: String - let image: String - let state: String - let labels: [String: String] -} - -private struct ContainerListData: Encodable { - let containers: [ContainerListItem] -} - -@_cdecl("ac_list_containers") -public func ac_list_containers() -> UnsafePointer? { - return runSync(timeoutSeconds: listTimeoutSeconds) { - do { - // ContainerListFilters.all enumerates every container - // regardless of state. The Go side applies label-based - // filtering after this returns. - let snaps = try await ContainerClient().list(filters: .all) - let items = snaps.map { snap -> ContainerListItem in - let cfg = snap.configuration - return ContainerListItem( - id: cfg.id, - name: cfg.id, - image: cfg.image.reference, - state: snap.status.rawValue, - labels: cfg.labels - ) - } - return encodeOK(ContainerListData(containers: items)) - } catch { - return encodeErr(error) - } - } -} - -// ImageListItem mirrors runtime.ImageRef. Tags slice carries the -// image's user-facing reference; ID is the manifest digest. -private struct ImageListItem: Encodable { - let id: String - let tags: [String] -} - -private struct ImageListData: Encodable { - let images: [ImageListItem] -} - -@_cdecl("ac_list_images") -public func ac_list_images() -> UnsafePointer? { - return runSync(timeoutSeconds: listTimeoutSeconds) { - do { - let imgs = try await ClientImage.list() - let items = imgs.map { img -> ImageListItem in - ImageListItem( - id: img.description.digest, - tags: [img.description.reference] - ) - } - return encodeOK(ImageListData(images: items)) - } catch { - return encodeErr(error) - } - } -} - -@_cdecl("ac_remove_image") -public func ac_remove_image(_ refPtr: UnsafePointer?) -> UnsafePointer? { - guard let ref = readCString(refPtr) else { return dupNullArgErr("reference") } - return runSync(timeoutSeconds: listTimeoutSeconds) { - do { - try await ClientImage.delete(reference: ref, garbageCollect: true) - return "{\"ok\":true}" - } catch let e as ContainerizationError where e.code == .notFound { - return "{\"ok\":true}" - } catch { - return encodeErr(error) - } - } -} diff --git a/applecontainer-bridge/Sources/ACBridge/logs.swift b/applecontainer-bridge/Sources/ACBridge/logs.swift deleted file mode 100644 index 8e2ebca..0000000 --- a/applecontainer-bridge/Sources/ACBridge/logs.swift +++ /dev/null @@ -1,47 +0,0 @@ -import ContainerAPIClient -import Darwin -import Foundation - -// Logs design notes: -// Apple's client.logs(id:) returns an array of FileHandles — index 0 -// is the container's stdio log, index 1 is the boot log. We always -// return stdio. Boot logs aren't part of the runtime.Runtime contract. -// -// The returned fd is dup(2)'d from Apple's FileHandle so that -// FileHandle deinit can close its end without affecting the Go-side -// reader. Go owns the dup'd fd from this point. -// -// Follow vs non-follow is implemented Go-side: the log is a regular -// file on disk; non-follow reads to EOF, follow polls after EOF until -// ctx cancellation closes the fd. Pushing the polling logic to Go -// keeps ctx cancellation simple (close fd → Read returns) and avoids -// another handle table. - -private struct LogsOpenResult: Encodable { - let fd: Int32 -} - -private let logsTimeoutSeconds = 10 - -@_cdecl("ac_logs_open") -public func ac_logs_open(_ idPtr: UnsafePointer?) -> UnsafePointer? { - guard let id = readCString(idPtr) else { return dupNullArgErr("id") } - return runSync(timeoutSeconds: logsTimeoutSeconds) { - do { - let handles = try await ContainerClient().logs(id: id) - guard let stdioHandle = handles.first else { - return "{\"ok\":false,\"err\":\"apiserver returned no log handles\"}" - } - // dup so Apple's FileHandle deinit doesn't kill the fd - // out from under the Go reader. - let dupFd = Darwin.dup(stdioHandle.fileDescriptor) - if dupFd < 0 { - let err = String(cString: strerror(errno)) - return "{\"ok\":false,\"err\":\"dup logs fd: \(err)\"}" - } - return encodeOK(LogsOpenResult(fd: dupFd)) - } catch { - return encodeErr(error) - } - } -} diff --git a/applecontainer-bridge/Sources/ACBridge/networks.swift b/applecontainer-bridge/Sources/ACBridge/networks.swift deleted file mode 100644 index aaca8c7..0000000 --- a/applecontainer-bridge/Sources/ACBridge/networks.swift +++ /dev/null @@ -1,116 +0,0 @@ -import ContainerAPIClient -import ContainerResource -import ContainerizationError -import Foundation - -// Compose orchestrator primitives — apple/container 0.12 network -// surface. Each export marshals a runtime-neutral request struct -// over XPC via NetworkClient and returns the canonical JSON -// envelope. -// -// Apple's network model uses NetworkConfiguration with .nat mode -// + the vmnet plugin as the default. Compose orchestrator-created -// project networks always use the .nat mode (the host-only mode -// is not what compose semantics expect). Subnet auto-allocation -// is delegated to the apiserver by passing nil for ipv4Subnet. - -// Apiserver round-trips are local + fast; 10s is generous. -private let networkTimeoutSeconds = 10 - -// NetworkSpecJSON mirrors the Go-side runtime.NetworkSpec wire shape. -// driver / options are accepted for parity with the runtime -// interface but ignored on this backend (apple's network plugin -// selection is not user-facing in 0.12.x). -private struct NetworkSpecJSON: Decodable { - var name: String - var labels: [String: String]? - var driver: String? - var options: [String: String]? -} - -// NetworkResultData reports the apiserver-assigned id back to Go. -// On apple the network id IS the name (NetworkConfiguration.id is -// the unique identifier), so we return it both as id and the same -// name the caller passed in. -private struct NetworkResultData: Encodable { - let id: String -} - -@_cdecl("ac_network_create") -public func ac_network_create(_ specPtr: UnsafePointer?) -> UnsafePointer? { - guard let specStr = readCString(specPtr) else { return dupNullArgErr("spec") } - return runSync(timeoutSeconds: networkTimeoutSeconds) { - do { - guard let data = specStr.data(using: .utf8) else { - return "{\"ok\":false,\"err\":\"spec not utf8\"}" - } - let spec = try JSONDecoder().decode(NetworkSpecJSON.self, from: data) - guard !spec.name.isEmpty else { - return "{\"ok\":false,\"err\":\"NetworkSpec.Name is required\"}" - } - - let client = NetworkClient() - - // Idempotency on (name, label superset): if a network with - // this id already exists and its labels are a superset of - // ours, reuse it. Matches docker.CreateNetwork's behavior. - if let existing = try? await client.get(id: spec.name) { - if labelsSuperset(networkLabels(existing), want: spec.labels ?? [:]) { - return encodeOK(NetworkResultData(id: existing.id)) - } - } - - let labels = try ResourceLabels(spec.labels ?? [:]) - let config = try NetworkConfiguration( - id: spec.name, - mode: .nat, - ipv4Subnet: nil, - ipv6Subnet: nil, - labels: labels, - pluginInfo: NetworkPluginInfo(plugin: "container-network-vmnet", variant: nil) - ) - let state = try await client.create(configuration: config) - return encodeOK(NetworkResultData(id: state.id)) - } catch { - return encodeErr(error) - } - } -} - -@_cdecl("ac_network_remove") -public func ac_network_remove(_ idPtr: UnsafePointer?) -> UnsafePointer? { - guard let id = readCString(idPtr) else { return dupNullArgErr("id") } - return runSync(timeoutSeconds: networkTimeoutSeconds) { - do { - // delete throws notFound when the network is missing — - // swallow that case so RemoveNetwork is idempotent at the - // Go interface boundary. - try await NetworkClient().delete(id: id) - return "{\"ok\":true}" - } catch let e as ContainerizationError where e.code == .notFound { - return "{\"ok\":true}" - } catch { - return encodeErr(error) - } - } -} - -// networkLabels extracts the resource-labels dictionary from a -// NetworkState. The Swift enum carries the configuration as an -// associated value; pattern-match to reach the labels. -private func networkLabels(_ state: NetworkState) -> [String: String] { - switch state { - case .created(let cfg), .running(let cfg, _): - return cfg.labels.dictionary - } -} - -// labelsSuperset is the apple-bridge analogue of runtime/docker's -// labelsMatch: every (k,v) in want must appear in have. Used by -// the network create idempotency check. -private func labelsSuperset(_ have: [String: String], want: [String: String]) -> Bool { - for (k, v) in want { - if have[k] != v { return false } - } - return true -} diff --git a/applecontainer-bridge/Sources/ACBridge/pull.swift b/applecontainer-bridge/Sources/ACBridge/pull.swift deleted file mode 100644 index 714925f..0000000 --- a/applecontainer-bridge/Sources/ACBridge/pull.swift +++ /dev/null @@ -1,40 +0,0 @@ -import ContainerAPIClient -import ContainerizationOCI -import Foundation - -// PR-F scope: synchronous pull. Apple's ClientImage.pull returns when -// the entire image is fetched + unpacked. Progress streaming is left -// for a future PR — the Runtime interface accepts a BuildEvent -// channel, but the engine treats coarse "started / completed" as -// acceptable for v1. See design/runtime-applecontainer.md §8. -// -// Cancellation: not yet wired. Apple's pull API doesn't expose a -// cancellation token; aborting a pull cleanly would require deleting -// the partial image, which is risky if other pulls share the same -// content store. Documented limitation; revisit when DAP needs it. - -private struct PullResult: Encodable { - let reference: String - let digest: String -} - -// 30 minutes — covers a cold pull of a multi-GB base image on -// reasonable networks. The bridge will trip its own timeout before -// this fires on most realistic links. -private let pullTimeoutSeconds = 1800 - -@_cdecl("ac_pull_image") -public func ac_pull_image(_ refPtr: UnsafePointer?) -> UnsafePointer? { - guard let ref = readCString(refPtr) else { return dupNullArgErr("reference") } - return runSync(timeoutSeconds: pullTimeoutSeconds) { - do { - let img = try await ClientImage.pull(reference: ref, platform: .current) - return encodeOK(PullResult( - reference: img.description.reference, - digest: img.description.digest - )) - } catch { - return encodeErr(error) - } - } -} diff --git a/applecontainer-bridge/Sources/ACBridge/volumes.swift b/applecontainer-bridge/Sources/ACBridge/volumes.swift deleted file mode 100644 index 09d10b1..0000000 --- a/applecontainer-bridge/Sources/ACBridge/volumes.swift +++ /dev/null @@ -1,80 +0,0 @@ -import ContainerAPIClient -import ContainerizationError -import Foundation - -// Compose orchestrator primitives — apple/container 0.12 volume -// surface. Apple's volumes are ext4-on-disk-image and exclusively -// mounted: probe 4 in design/compose-native.md confirmed -// multi-attach fails at the VM layer. The orchestrator's Plan -// validator refuses shared volumes on apple (via the SharedVolumes -// capability flag); these primitives only handle the simple -// single-mount case. - -private let volumeTimeoutSeconds = 15 - -private struct VolumeSpecJSON: Decodable { - var name: String - var labels: [String: String]? - var driver: String? - var options: [String: String]? -} - -private struct VolumeResultData: Encodable { - let name: String -} - -@_cdecl("ac_volume_create") -public func ac_volume_create(_ specPtr: UnsafePointer?) -> UnsafePointer? { - guard let specStr = readCString(specPtr) else { return dupNullArgErr("spec") } - return runSync(timeoutSeconds: volumeTimeoutSeconds) { - do { - guard let data = specStr.data(using: .utf8) else { - return "{\"ok\":false,\"err\":\"spec not utf8\"}" - } - let spec = try JSONDecoder().decode(VolumeSpecJSON.self, from: data) - guard !spec.name.isEmpty else { - return "{\"ok\":false,\"err\":\"VolumeSpec.Name is required\"}" - } - - // Idempotency on (name, label superset). - if let existing = try? await ClientVolume.inspect(spec.name) { - if labelsSupersetVol(existing.labels, want: spec.labels ?? [:]) { - return encodeOK(VolumeResultData(name: existing.name)) - } - } - - let driver = (spec.driver?.isEmpty == false) ? spec.driver! : "local" - let created = try await ClientVolume.create( - name: spec.name, - driver: driver, - driverOpts: spec.options ?? [:], - labels: spec.labels ?? [:] - ) - return encodeOK(VolumeResultData(name: created.name)) - } catch { - return encodeErr(error) - } - } -} - -@_cdecl("ac_volume_remove") -public func ac_volume_remove(_ namePtr: UnsafePointer?) -> UnsafePointer? { - guard let name = readCString(namePtr) else { return dupNullArgErr("name") } - return runSync(timeoutSeconds: volumeTimeoutSeconds) { - do { - try await ClientVolume.delete(name: name) - return "{\"ok\":true}" - } catch let e as ContainerizationError where e.code == .notFound { - return "{\"ok\":true}" - } catch { - return encodeErr(error) - } - } -} - -private func labelsSupersetVol(_ have: [String: String], want: [String: String]) -> Bool { - for (k, v) in want { - if have[k] != v { return false } - } - return true -} diff --git a/applecontainer-bridge/include/ac_bridge.h b/applecontainer-bridge/include/ac_bridge.h deleted file mode 100644 index 575c676..0000000 --- a/applecontainer-bridge/include/ac_bridge.h +++ /dev/null @@ -1,364 +0,0 @@ -#ifndef AC_BRIDGE_H -#define AC_BRIDGE_H - -#include - -// ===== ac_bridge.h header-comment style guide ======================== -// -// Every export below documents the following five contracts so a -// caller reading only this header knows how to use the function -// safely. Backfilled to ac_version / ac_ping / ac_free in PR-B. -// -// * Ownership: who frees returned pointers (always the caller for -// `const char*` returns; free via `ac_free`). -// * Cancellation: whether `ctx.Done()` on the Go side can interrupt -// a call mid-flight. PR-B exports are all sync from the Go view -// (Swift `Task` + DispatchSemaphore wait); cancellation lands when -// PR-D introduces the handle-table pattern. -// * Threading: which Swift thread the underlying work runs on. PR-A -// and PR-B exports run their work in a detached `Task { ... }` -// and signal a DispatchSemaphore on completion; the @_cdecl -// function itself runs on the cgo-thread Go gave it. -// * Error encoding: every export returns a JSON string of shape -// `{ "ok": bool, "err"?: string, "data"?: }`. -// `err` is absent or empty on success; `data` is absent on -// failure. Some PR-A exports return non-JSON payloads — those are -// called out explicitly in their header comments. -// * Blocking: whether the call blocks the calling cgo thread. PR-B -// exports all block (sync from Go's view); fire-and-forget exports -// with completion callbacks arrive in PR-D. -// -// ===================================================================== - -// ac_version returns a static descriptor of this bridge build — -// "ACBridge/ apple-container/". Useful -// for diagnostics and confirming the linked bridge matches what the -// caller expects. -// -// Ownership: caller frees the returned string with ac_free. -// Cancellation: n/a; constant-time. -// Threading: runs on the cgo thread, no Task indirection. -// Encoding: plain UTF-8 string, NOT the JSON envelope. -// Blocking: non-blocking. -const char* ac_version(void); - -// ac_ping probes the apple/container apiserver via -// ClientHealthCheck.ping. Returns a JSON envelope describing the -// daemon's SystemHealth on success or the underlying error on failure. -// `timeout_seconds <= 0` uses the bridge default (5s). -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired; PR-D adds a handle-table mechanism. -// The bridge guards its internal semaphore wait with a -// `timeout_seconds + 2` cap so a stuck Task can't hang -// the cgo caller indefinitely. -// Threading: work runs on a Swift Task; the cgo thread blocks on -// a DispatchSemaphore until that Task signals. -// Encoding: JSON envelope. On success: -// { "ok": true, "apiServerVersion": "...", ... } -// On failure: -// { "ok": false, "err": "..." } -// The success shape is not wrapped under a "data" key -// (predates the style guide; left as-is for PR-A -// stability — see PR-B for the canonical shape on -// inspect/find exports). -// Blocking: blocks the cgo thread until the daemon responds or -// the internal timeout fires (timeout_seconds + 2). -const char* ac_ping(int32_t timeout_seconds); - -// ac_free releases a pointer previously returned by another bridge -// export. Maps to `free()` inside the dylib so allocations and frees -// stay on the same libc; passing a non-bridge pointer is undefined -// behavior. -// -// Ownership: transfers ownership back to the dylib for freeing. -// Cancellation: n/a. -// Threading: safe to call from any thread. -// Encoding: n/a. -// Blocking: non-blocking; same cost as free(). -void ac_free(void* p); - -// ---- PR-B: Inspect + Find ----------------------------------------- - -// ac_inspect_container fetches the snapshot for a container by id. -// Wraps ContainerClient.get(id:). -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired (PR-D). -// Threading: Swift Task + DispatchSemaphore wait on the cgo thread. -// Encoding: { "ok": true, "data": } or -// { "ok": false, "err": "..." }. The snapshot keys -// follow Apple's Codable representation: -// configuration{id,image,initProcess,labels,mounts,...}, -// status, networks, startedDate (optional, RFC3339). -// Blocking: blocks until the XPC round-trip completes. -const char* ac_inspect_container(const char* id); - -// ac_inspect_image fetches the OCI image config for a local image by -// reference. Wraps ClientImage.get + image.config(for: .current). -// Critical path: the `devcontainer.metadata` label lives here. -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired (PR-D). -// Threading: Swift Task + DispatchSemaphore wait on the cgo thread. -// Encoding: { "ok": true, "data": { -// "reference": "...", "digest": "...", -// "labels": {...}, "env": ["..."], -// "user": "...", "architecture": "...", "os": "..." -// } } or { "ok": false, "err": "..." }. Fields are -// flattened from the OCI Image + ImageConfig + the -// owning ImageDescription so the Go side gets a single -// object to unmarshal. -// Blocking: blocks until the local content store lookup completes. -// Does NOT pull from a remote registry. -const char* ac_inspect_image(const char* reference); - -// ac_find_container_by_label lists running and stopped containers and -// returns the most-recently-started one whose -// `configuration.labels[key] == value`. Matches our -// runtime.FindContainerByLabel contract. -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired (PR-D). -// Threading: Swift Task + DispatchSemaphore wait on the cgo thread. -// Encoding: { "ok": true, "data": } -// on success — null when no container matches. On -// failure: { "ok": false, "err": "..." }. -// Blocking: blocks until ContainerClient.list() returns. -const char* ac_find_container_by_label(const char* key, const char* value); - -// ---- PR-C: Run / Start / Stop / Delete ----------------------------- - -// ac_run creates a container from a JSON-encoded RunSpec. Wraps -// ContainerClient.create + ClientKernel.getDefaultKernel. Image must -// already be in the local content store (PullImage handled by PR-F). -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired (PR-D). -// Threading: Swift Task + DispatchSemaphore wait on the cgo thread. -// Encoding: spec_json is the canonical RunSpec wire shape (see -// applecontainer/lifecycle_darwin_arm64.go for the Go -// marshaller). Response: -// { "ok": true, "data": { "id": "" } } -// or { "ok": false, "err": "..." }. -// RunSpec.RunArgs / Privileged / SecurityOpt are not -// modeled on this backend per design §8. The Go layer -// rejects callers that populate them with a typed -// UnsupportedOptionError before reaching this entry -// point; the wire shape doesn't carry those fields. -// Image must be pre-pulled. -// Blocking: up to 60s (covers cold kernel + init-image fetch on -// first run; cached after that). -const char* ac_run(const char* spec_json); - -// ac_start bootstraps and starts a previously created container in -// detached mode (no stdio attachment). Idempotent: a running -// container is a no-op success. Wraps ContainerClient.bootstrap + -// ClientProcess.start. -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired (PR-D). -// Threading: Swift Task + DispatchSemaphore wait on the cgo thread. -// Encoding: { "ok": true } | { "ok": false, "err": "..." }. -// Blocking: up to 60s; bootstrap is fast but `process.start()` -// spawns the in-VM init. -const char* ac_start(const char* id); - -// ac_stop stops a running container. Wraps ContainerClient.stop with -// the given grace-period; timeout_seconds <= 0 uses Apple's default. -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired (PR-D). -// Threading: Swift Task + DispatchSemaphore wait on the cgo thread. -// Encoding: { "ok": true } | { "ok": false, "err": "..." }. -// Blocking: up to 60s (covers the grace period + SIGKILL fallback). -const char* ac_stop(const char* id, int32_t timeout_seconds); - -// ac_delete removes a container. `force != 0` deletes even if the -// container is running. Wraps ContainerClient.delete. -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired (PR-D). -// Threading: Swift Task + DispatchSemaphore wait on the cgo thread. -// Encoding: { "ok": true } | { "ok": false, "err": "..." }. -// Blocking: up to 60s. -const char* ac_delete(const char* id, int32_t force); - -// ---- PR-D: Exec (stdin/TTY + cancellation) ------------------------- - -// ac_exec_start launches an exec process inside a running container. -// stdio fds are caller-supplied via os.Pipe() on the Go side: caller -// passes the apiserver-facing end (read-end for stdin, write-end for -// stdout/stderr); -1 disables that stream. XPC dup's the fds when the -// XPCMessage is serialized, so the caller may close its passed fd -// immediately after this call returns (the process keeps the dup'd -// copy). -// -// Ownership: caller frees the returned JSON with ac_free. -// Cancellation: returned `handle` is the cancellation token. Pass -// it to ac_exec_signal with SIGTERM (or any signal) -// to deliver to the in-VM process. -// Threading: Swift Task + DispatchSemaphore wait on the cgo -// thread. The launched process runs on its own VM -// thread; this call returns once createProcess + -// start have settled at the apiserver. -// Encoding: opts_json = { "cmd":[..], "env":[..], "user":"", -// "workingDir":"", "tty":false }. -// Response on success: -// { "ok": true, "data": { "handle": uint64 } } -// On failure: { "ok": false, "err": "..." }. After -// this returns ok, the caller MUST eventually call -// ac_exec_release(handle) to free the registry slot. -// Blocking: up to 30s (createProcess XPC + start). -const char* ac_exec_start( - const char* id, - const char* opts_json, - int32_t stdin_read_fd, - int32_t stdout_write_fd, - int32_t stderr_write_fd -); - -// ac_exec_wait blocks until the exec process exits, returning its -// exit code. timeout_seconds <= 0 disables the timeout (capped to -// ~Int32.max/1000 internally to avoid Duration overflow). -// -// Ownership: caller frees the returned JSON with ac_free. -// Cancellation: external — call ac_exec_signal from another thread -// to send SIGTERM; this wait then returns the -// resulting exit code naturally. -// Threading: Swift Task + DispatchSemaphore wait on the cgo -// thread. -// Encoding: { "ok": true, "data": { "exitCode": int32 } } or -// { "ok": false, "err": "..." }. -// Blocking: unbounded by design (modulo timeout_seconds). -const char* ac_exec_wait(uint64_t handle, int32_t timeout_seconds); - -// ac_exec_signal delivers a signal to the in-VM process. The -// cancellation contract: Go's ctx.Done() goroutine calls this with -// SIGTERM; ac_exec_wait then returns naturally as the process exits. -// -// Ownership: caller frees the returned JSON with ac_free. -// Cancellation: n/a (this IS the cancellation primitive). -// Threading: Swift Task + DispatchSemaphore wait on the cgo -// thread. -// Encoding: { "ok": true } or { "ok": false, "err": "..." }. -// Blocking: up to 5s (the apiserver's kill XPC is fast). -const char* ac_exec_signal(uint64_t handle, int32_t signal); - -// ac_exec_release frees the handle's registry slot. Idempotent — -// calling on an unknown handle is a no-op. Must be called after -// ac_exec_wait returns to avoid leaking ClientProcess instances. -// -// Ownership: n/a; no return value. -// Cancellation: n/a. -// Threading: safe from any thread. -// Encoding: n/a. -// Blocking: non-blocking; lock acquisition only. -void ac_exec_release(uint64_t handle); - -// ---- PR-E: Logs streaming ------------------------------------------ - -// ac_logs_open returns a dup'd file descriptor for the container's -// stdio log. The fd is a regular file on disk; reads return 0 bytes -// at EOF. Callers implement follow mode by polling on EOF; ctx -// cancellation is signaled by closing the fd Go-side. -// -// Ownership: caller owns the returned fd and must close it with -// close(2). The JSON envelope itself is freed with -// ac_free as usual. -// Cancellation: external — close the fd from another thread to -// unblock a Go read. -// Threading: Swift Task + DispatchSemaphore wait on the cgo -// thread. -// Encoding: { "ok": true, "data": { "fd": int32 } } or -// { "ok": false, "err": "..." }. -// Blocking: up to 10s (one XPC round-trip + dup). -const char* ac_logs_open(const char* id); - -// ---- PR-F: Pull ---------------------------------------------------- - -// ac_pull_image fetches an image from a remote registry into the -// local content store. Synchronous; returns when the image is fully -// pulled and unpacked. -// -// Ownership: caller frees the returned JSON with ac_free. -// Cancellation: not yet wired. Apple's pull API doesn't expose a -// cancellation token; aborting cleanly would require -// deleting the partial image — left for a future PR. -// Documented in design §8. -// Threading: Swift Task + DispatchSemaphore wait on the cgo -// thread. -// Encoding: { "ok": true, "data": { "reference": "...", "digest": "..." } } -// or { "ok": false, "err": "..." }. -// Blocking: up to 30 min (covers a cold pull of a multi-GB -// base image on a reasonable network). The bridge's -// timeout will trip before this in most realistic -// cases. -const char* ac_pull_image(const char* reference); - -// ---- PR-G2: Build -------------------------------------------------- - -// ac_build_probe checks whether Apple's buildkit container is up. -// Callers use this to short-circuit with a typed -// BuilderUnavailableError before paying the cost of marshaling a -// full BuildSpec. -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired. -// Threading: Swift Task + DispatchSemaphore wait on the cgo -// thread. -// Encoding: Success: { "ok": true }. -// Failure with stable code: -// { "ok": false, -// "code": "BUILDER_UNAVAILABLE", -// "err": "" } -// Failure without a known code: -// { "ok": false, "err": "..." } -// The `code` field is the machine-readable contract -// the Go side keys typed errors off of; `err` is for -// diagnostics only. -// Blocking: up to 5s (one XPC round-trip). -const char* ac_build_probe(void); - -// ac_build performs the actual BuildKit build. Dials the buildkit -// container over vsock (must be running; we surface a clear error -// otherwise), constructs a SwiftNIO-backed Builder, runs the build -// to an OCI tarball export, then loads + unpacks + tags the result -// in the local content store. -// -// Ownership: caller frees with ac_free. -// Cancellation: not yet wired. Build is long-running; callers -// should treat it as best-effort uninterruptible -// until a follow-up PR adds streaming cancellation. -// Threading: Swift Task + DispatchSemaphore wait on the cgo -// thread. Internally spins up a NIO -// MultiThreadedEventLoopGroup for the duration of -// the build and shuts it down on the way out. -// Encoding: spec_json fields (omitempty unless noted): -// contextPath (required), dockerfile, -// tag, args (map[string]string), target, -// cacheFrom ([]string), noCache (bool), -// platform (single platform string, e.g. linux/arm64). -// Engine concepts not modeled on this backend -// (RunArgs, Privileged, SecurityOpt analogues) are -// rejected by the Go layer before reaching this -// entry point — same pattern as ac_run (design §8). -// Multi-platform builds are out of scope; pass a -// single platform. -// Success: -// { "ok": true, "data": { "reference": "...", "digest": "..." } } -// Failure with stable code (same contract as -// ac_build_probe — currently BUILDER_UNAVAILABLE): -// { "ok": false, "code": "...", "err": "..." } -// Failure without a known code: -// { "ok": false, "err": "..." }. -// BuildKit progress goes to FileHandle.standardError -// of the bridge process (which the Go process -// inherits). Typed BuildEvent streaming is a future -// PR — for now callers see raw output on stderr. -// Blocking: up to 30 min (covers a cold cache pull + multi- -// layer build). The bridge's internal timeout fires -// at the same horizon. -const char* ac_build(const char* spec_json); - -#endif diff --git a/cmd/devcontainer/root.go b/cmd/devcontainer/root.go index 158aca1..6dcfd8e 100644 --- a/cmd/devcontainer/root.go +++ b/cmd/devcontainer/root.go @@ -33,7 +33,7 @@ func newRootCmd() *cobra.Command { pf := cmd.PersistentFlags() pf.StringVar(&f.workspaceFolder, "workspace-folder", "", "Path to the project workspace (defaults to current directory)") pf.StringVar(&f.configPath, "config", "", "Path to devcontainer.json (defaults to .devcontainer/devcontainer.json under the workspace)") - pf.StringVar(&f.runtimeName, "runtime", "docker", "Container backend: docker | applecontainer") + pf.StringVar(&f.runtimeName, "runtime", "docker", "Container backend: docker") pf.StringVar(&f.logLevel, "log-level", "info", "Log verbosity: info | debug | trace") cmd.AddCommand( @@ -92,10 +92,8 @@ func (f *rootFlags) newRuntime(ctx context.Context) (runtime.Runtime, func(), er return nil, nil, fmt.Errorf("docker runtime: %w", err) } return rt, func() { _ = rt.Close() }, nil - case "applecontainer": - return newAppleContainerRuntime(ctx) default: - return nil, nil, fmt.Errorf("unknown runtime %q (want docker | applecontainer)", f.runtimeName) + return nil, nil, fmt.Errorf("unknown runtime %q (want docker)", f.runtimeName) } } diff --git a/cmd/devcontainer/runtime_applecontainer_darwin_arm64.go b/cmd/devcontainer/runtime_applecontainer_darwin_arm64.go deleted file mode 100644 index c861142..0000000 --- a/cmd/devcontainer/runtime_applecontainer_darwin_arm64.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build darwin && arm64 - -package main - -import ( - "context" - "fmt" - - "github.com/crunchloop/devcontainer/runtime" - "github.com/crunchloop/devcontainer/runtime/applecontainer" -) - -func newAppleContainerRuntime(ctx context.Context) (runtime.Runtime, func(), error) { - rt, err := applecontainer.New(ctx, applecontainer.Options{}) - if err != nil { - return nil, nil, fmt.Errorf("applecontainer runtime: %w", err) - } - return rt, func() {}, nil -} diff --git a/cmd/devcontainer/runtime_applecontainer_other.go b/cmd/devcontainer/runtime_applecontainer_other.go deleted file mode 100644 index aeecfad..0000000 --- a/cmd/devcontainer/runtime_applecontainer_other.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !(darwin && arm64) - -package main - -import ( - "context" - "fmt" - - "github.com/crunchloop/devcontainer/runtime" -) - -func newAppleContainerRuntime(_ context.Context) (runtime.Runtime, func(), error) { - return nil, nil, fmt.Errorf("applecontainer runtime is only supported on darwin/arm64") -} diff --git a/compose/errors.go b/compose/errors.go index 52b4d99..d4f9c18 100644 --- a/compose/errors.go +++ b/compose/errors.go @@ -71,7 +71,7 @@ func sortFields(in []UnsupportedField) []UnsupportedField { // implement) because the gating is backend-specific and may flip if // the backend gains the capability later. type UnsupportedFeatureOnBackendError struct { - Backend string // backend display name (e.g. "applecontainer") + Backend string // backend display name (e.g. "docker") Capability string // Capabilities struct field name (e.g. "Healthchecks") Service string // service that triggered the refusal Detail string // one-sentence explanation @@ -92,9 +92,7 @@ func (e *UnsupportedFeatureOnBackendError) Error() string { // VolumeSharedAcrossServicesError is returned by Plan.Validate when // the project mounts a single named volume into 2+ services and the -// active backend's Capabilities().SharedVolumes is false (today: -// applecontainer, due to ext4-on-disk-image multi-attach -// restrictions per design probe 4). +// active backend's Capabilities().SharedVolumes is false. type VolumeSharedAcrossServicesError struct { Volume string Services []string // sorted diff --git a/compose/orchestrator.go b/compose/orchestrator.go index 22d728e..7c4dcc6 100644 --- a/compose/orchestrator.go +++ b/compose/orchestrator.go @@ -370,11 +370,10 @@ func (o *Orchestrator) Down(ctx context.Context, plan *DownPlan) error { } } - // Remove the project network. Both backends accept the network - // name (docker's NetworkRemove and apple's NetworkClient.delete - // both resolve by id-or-name; our CreateNetwork uses + // Remove the project network by name. Docker's NetworkRemove + // resolves by id-or-name, and our CreateNetwork uses // _default as the name + id, so RemoveNetwork with the - // same string works). The call is idempotent — missing-network + // same string works. The call is idempotent — missing-network // errors are swallowed at the backend. _ = o.rt.RemoveNetwork(ctx, plan.ProjectName+"_default") diff --git a/compose/plan.go b/compose/plan.go index 5e346a5..40b3c7a 100644 --- a/compose/plan.go +++ b/compose/plan.go @@ -369,9 +369,9 @@ func deployUnsupported(service string, d *composetypes.DeployConfig) []Unsupport // resourcesUnsupported refuses anything inside deploy.resources beyond // limits.memory and limits.cpus. Reservations are silently dropped on -// our runtimes today (apple has no equivalent; docker honors them but -// we don't currently translate them), so refusing them surfaces the -// silent loss to the user. +// our runtimes today (docker honors them but we don't currently +// translate them), so refusing them surfaces the silent loss to the +// user. func resourcesUnsupported(service string, r composetypes.Resources) []UnsupportedField { var out []UnsupportedField if r.Reservations != nil { diff --git a/compose/plan_test.go b/compose/plan_test.go index 8d10441..5756fba 100644 --- a/compose/plan_test.go +++ b/compose/plan_test.go @@ -20,7 +20,7 @@ func dockerCaps() runtime.Capabilities { } } -func appleCaps() runtime.Capabilities { +func limitedCaps() runtime.Capabilities { return runtime.Capabilities{} } @@ -95,7 +95,7 @@ func TestValidate_AcceptsScaleOne(t *testing.T) { } } -func TestValidate_RefusesHealthyOnAppleCaps(t *testing.T) { +func TestValidate_RefusesHealthyOnLimitedCaps(t *testing.T) { proj := &composetypes.Project{ Services: composetypes.Services{ "app": composetypes.ServiceConfig{ @@ -107,7 +107,7 @@ func TestValidate_RefusesHealthyOnAppleCaps(t *testing.T) { }, } p := &Plan{Project: proj, ProjectName: "dc-x"} - err := p.Validate("applecontainer", appleCaps()) + err := p.Validate("limited", limitedCaps()) var bad *UnsupportedFeatureOnBackendError if !errors.As(err, &bad) { t.Fatalf("want *UnsupportedFeatureOnBackendError, got %T: %v", err, err) @@ -117,7 +117,7 @@ func TestValidate_RefusesHealthyOnAppleCaps(t *testing.T) { } } -func TestValidate_RefusesCompletedSuccessfullyOnAppleCaps(t *testing.T) { +func TestValidate_RefusesCompletedSuccessfullyOnLimitedCaps(t *testing.T) { proj := &composetypes.Project{ Services: composetypes.Services{ "app": composetypes.ServiceConfig{ @@ -129,7 +129,7 @@ func TestValidate_RefusesCompletedSuccessfullyOnAppleCaps(t *testing.T) { }, } p := &Plan{Project: proj, ProjectName: "dc-x"} - err := p.Validate("applecontainer", appleCaps()) + err := p.Validate("limited", limitedCaps()) var bad *UnsupportedFeatureOnBackendError if !errors.As(err, &bad) { t.Fatalf("want *UnsupportedFeatureOnBackendError, got %T: %v", err, err) @@ -139,9 +139,9 @@ func TestValidate_RefusesCompletedSuccessfullyOnAppleCaps(t *testing.T) { } } -func TestValidate_AcceptsServiceStartedOnAppleCaps(t *testing.T) { +func TestValidate_AcceptsServiceStartedOnLimitedCaps(t *testing.T) { // service_started is the v1 / default condition — no health - // gate, just "exists." Apple caps must allow it. + // gate, just "exists." Limited caps must allow it. proj := &composetypes.Project{ Services: composetypes.Services{ "app": composetypes.ServiceConfig{ @@ -154,12 +154,12 @@ func TestValidate_AcceptsServiceStartedOnAppleCaps(t *testing.T) { }, } p := &Plan{Project: proj, ProjectName: "dc-x"} - if err := p.Validate("applecontainer", appleCaps()); err != nil { + if err := p.Validate("limited", limitedCaps()); err != nil { t.Errorf("service_started must be accepted: %v", err) } } -func TestValidate_RefusesNamespaceSharingOnAppleCaps(t *testing.T) { +func TestValidate_RefusesNamespaceSharingOnLimitedCaps(t *testing.T) { proj := &composetypes.Project{ Services: composetypes.Services{ "app": composetypes.ServiceConfig{Name: "app", Image: "alpine", NetworkMode: "service:primary"}, @@ -167,7 +167,7 @@ func TestValidate_RefusesNamespaceSharingOnAppleCaps(t *testing.T) { }, } p := &Plan{Project: proj, ProjectName: "dc-x"} - err := p.Validate("applecontainer", appleCaps()) + err := p.Validate("limited", limitedCaps()) var bad *UnsupportedFeatureOnBackendError if !errors.As(err, &bad) { t.Fatalf("want *UnsupportedFeatureOnBackendError, got %T: %v", err, err) @@ -177,7 +177,7 @@ func TestValidate_RefusesNamespaceSharingOnAppleCaps(t *testing.T) { } } -func TestValidate_RefusesSharedVolumeOnAppleCaps(t *testing.T) { +func TestValidate_RefusesSharedVolumeOnLimitedCaps(t *testing.T) { proj := &composetypes.Project{ Volumes: composetypes.Volumes{ "data": composetypes.VolumeConfig{Name: "data"}, @@ -198,7 +198,7 @@ func TestValidate_RefusesSharedVolumeOnAppleCaps(t *testing.T) { }, } p := &Plan{Project: proj, ProjectName: "dc-x"} - err := p.Validate("applecontainer", appleCaps()) + err := p.Validate("limited", limitedCaps()) var bad *VolumeSharedAcrossServicesError if !errors.As(err, &bad) { t.Fatalf("want *VolumeSharedAcrossServicesError, got %T: %v", err, err) @@ -226,7 +226,7 @@ func TestValidate_AcceptsSingleServiceVolume(t *testing.T) { }, } p := &Plan{Project: proj, ProjectName: "dc-x"} - if err := p.Validate("applecontainer", appleCaps()); err != nil { + if err := p.Validate("limited", limitedCaps()); err != nil { t.Errorf("single-service volume must be accepted: %v", err) } } diff --git a/design/README.md b/design/README.md index d68a831..c97ce5a 100644 --- a/design/README.md +++ b/design/README.md @@ -20,8 +20,7 @@ work shipped. | --- | --- | | [`resolved-config.md`](resolved-config.md) | The `ResolvedConfig` type — the central data structure that flows out of `devcontainer.Resolve` into every downstream package. Locks down the data shape, the parse → merge → substitute pipeline, and `Source` polymorphism. | | [`runtime.md`](runtime.md) | The `runtime.Runtime` interface, the `Workspace` value object, the `Engine` layer, container-context substitution, and lifecycle idempotency. The boundary that makes a second backend possible. | -| [`runtime-applecontainer.md`](runtime-applecontainer.md) | The second `Runtime` backend: Apple's `container` stack on macOS via a cgo + Swift bridge. Daemon model, bridge ABI, version pinning, build constraints. | -| [`compose-native.md`](compose-native.md) | The runtime-agnostic compose orchestrator that drives any backend through `runtime.Runtime` primitives. Replaces (when opted in) the `docker compose` shell-out, and is what enables compose source on apple-container. | +| [`compose-native.md`](compose-native.md) | The runtime-agnostic compose orchestrator that drives any backend through `runtime.Runtime` primitives. Replaces (when opted in) the `docker compose` shell-out. | | [`features.md`](features.md) | The Dev Container Features pipeline: OCI / HTTPS / local resolution, DAG ordering, dockerfile generation, the pre-baked-image fast path, and the content-addressed cache. | | [`structured-errors.md`](structured-errors.md) | The `*devcontainer.Error` surface returned from every public failure path. Code catalog, `Cause` chain conventions, and the `StderrCarrier` interface for subprocess-output access. | diff --git a/design/runtime-applecontainer.md b/design/runtime-applecontainer.md deleted file mode 100644 index a1ecce7..0000000 --- a/design/runtime-applecontainer.md +++ /dev/null @@ -1,402 +0,0 @@ -# Design — apple-container Runtime backend - -**Status:** Draft for review -**Date:** 2026-05-14 -**Scope:** a second `Runtime` implementation that drives Apple's `container` -stack on macOS (Sequoia+ / arm64) as an alternative to `runtime/docker`. -Defines the cgo + Swift bridge architecture, the daemon dependency, version -pinning discipline, build constraints, and the M6 shipping subset. - -Companion to `design/runtime.md` (Runtime / Engine layering, locked -decisions). The backend implements the existing `Runtime` interface -from `runtime/runtime.go` without changing its shape; one planned -engine-side adjustment is the `updateRemoteUserUID` short-circuit -described in §13.8 (driven by a small capability flag on `Runtime`, -not a new method). - ---- - -## 1. Layering - -```text -┌──────────────────────────────────────────────────────────────────┐ -│ Engine (devcontainer pkg) │ -│ selects runtime.Runtime impl by EngineOptions │ -└──────────────────────────────────────────────────────────────────┘ - │ -┌─────────────────────────────▼────────────────────────────────────┐ -│ Runtime interface (runtime pkg) │ -│ unchanged — both DockerRuntime and AppleContainerRuntime │ -│ satisfy it; ComposeRuntime sub-interface is Docker-only │ -└─────────────────────────────┬────────────────────────────────────┘ - │ -┌─────────────────────────────▼────────────────────────────────────┐ -│ runtime/applecontainer (Go, build-tagged darwin && arm64) │ -│ thin cgo wrapper; marshals options to JSON, calls @_cdecl │ -│ exports, decodes responses, owns the handle table for │ -│ cancellation and streams. │ -└─────────────────────────────┬────────────────────────────────────┘ - │ cgo -┌─────────────────────────────▼────────────────────────────────────┐ -│ libACBridge.dylib (Swift, this repo's applecontainer-bridge/) │ -│ imports apple/container's ContainerAPIClient + supporting │ -│ modules; wraps async APIs as fire-and-forget @_cdecl funcs; │ -│ pipes for high-throughput streams (exec stdio, logs, build). │ -└─────────────────────────────┬────────────────────────────────────┘ - │ XPC (mach service) -┌─────────────────────────────▼────────────────────────────────────┐ -│ container-apiserver (Apple's daemon; launchd LaunchAgent) │ -│ owns VMs (Virtualization.framework), virtiofs, image cache, │ -│ networks, persistent state. Installed via `brew install │ -│ container`. Started via `container system start`. │ -└──────────────────────────────────────────────────────────────────┘ -``` - -**Strict separation, same as runtime.md §1:** `runtime/applecontainer` knows -nothing about `ResolvedConfig`, features, lifecycle phases, idempotency, -or substitution. It speaks the `Runtime` interface. - -## 2. What's in Apple's stack - -Pinned target version: **`apple/container` 0.12.x** (locked exact in -`Package.swift`; see §5). - -| Apple module / product | What it gives us | -| ---------------------------------- | ------------------------------------------------------- | -| `ContainerAPIClient` | Swift client for the apiserver — `ContainerClient`, `ClientHealthCheck.ping`, image / network / sandbox sub-clients. | -| `ContainerXPC` | XPC transport (mach service `com.apple.container.apiserver`). EUID-only auth — no entitlement required on callers (`XPCServer.swift:178-193`). | -| `ContainerBuild` | BuildKit-style builder. Spoken to over gRPC; runs in its own VM started via `container builder start`. | -| `ContainerizationOCI` | OCI image refs, registry interaction. | -| `Containerization` (core) | VM lifecycle, process I/O. Used transitively. | -| `ContainerResource` | Shared types — `ContainerListFilters`, `ContainerSnapshot`, etc. | - -The CLI binary `/usr/local/bin/container` (or brew's -`/opt/homebrew/bin/container`) is a thin client over `ContainerAPIClient` -— same Swift APIs we call. It's optional for our runtime; only the daemon -is mandatory. - -## 3. Bridge architecture - -cgo into a SwiftPM dynamic-library product (`libACBridge.dylib`) that -imports `ContainerAPIClient` and exposes a small C ABI of `@_cdecl` -exports. Hand-written header (`include/ac_bridge.h`) — no `swift-bridge` -or codegen. - -Surface (representative; full set lands in PR-A..H): - -```c -typedef uint64_t ac_call_t; -typedef void (*ac_done_cb)(void* ud, int32_t exit_code, const char* err); -typedef void (*ac_stream_cb)(void* ud, int32_t fd, const uint8_t* data, size_t len); - -const char* ac_version(void); -const char* ac_ping(int timeout_seconds); - -ac_call_t ac_inspect_container(const char* id, ac_done_cb, void* ud, char** json_out); -ac_call_t ac_inspect_image(const char* ref, ac_done_cb, void* ud, char** json_out); -ac_call_t ac_run(const char* spec_json, ac_done_cb, void* ud, char** id_out); -ac_call_t ac_exec(const char* id, const char* opts_json, - ac_done_cb done, void* ud, - int* stdin_fd_out, int* stdout_fd_out, int* stderr_fd_out); -ac_call_t ac_logs(const char* id, bool follow, ac_done_cb, void* ud, int* fd_out); -ac_call_t ac_build(const char* spec_json, ac_stream_cb progress, ac_done_cb, void* ud); - -void ac_call_cancel(ac_call_t); -void ac_free(void* p); -``` - -Key rules: - -- **Every call is fire-and-forget.** Returns a handle; callbacks fire from - Swift `Task`s later. The `@_cdecl` function must return fast — never - block the OS thread Go gave it. -- **Hot-path streams go through OS pipes**, not callbacks. Swift opens - pipes, hands fds to Go; Go reads/writes them with `os.NewFile(fd, ...)`. - This keeps the cgo boundary cold even during heavy stdio or log follow. -- **Control / completion / errors** use callbacks (low frequency). -- **Cancellation:** `ac_call_cancel(handle)` calls `Task.cancel()` on the - stored task. Go side calls this from a goroutine watching `ctx.Done()`. -- **Memory ownership:** all `const char*` returns are `strdup`'d; caller - frees via `ac_free`. JSON-shaped responses use intermediate `char**` - out-params to keep the cgo signature simple. - -## 4. Daemon dependency - -The `container-apiserver` daemon is **mandatory**, analogous to needing -`dockerd` for `runtime/docker`. Same shape as Docker Desktop: the CLI -(or our bridge) is a client; containers run inside the daemon's -virtualized environment. - -Why we can't embed the server side: - -1. The apiserver and its ancillary services (`container-network-vmnet`, - `container-core-images`) are launchd-managed LaunchAgents that own - host-level state (vmnet routing, on-disk image cache) which must be - shared across all clients on the user's machine. -2. Embedding it in our binary would require codesigning with - `com.apple.security.virtualization` and friends — Developer ID + - provisioning profile + the whole release dance. -3. Two engines fighting for the same image cache directory is a - data-corruption hazard. - -**Discovery:** `Engine.New` (when constructed with the apple-container -runtime) calls `ClientHealthCheck.ping` with a short timeout. On -failure the runtime constructor returns a typed -`*runtime.DaemonUnavailableError` (already defined for the Docker -path; we reuse it) with a message pointing the user at -`container system start`. - -## 5. Version pinning - -`apple/container` is pre-1.0. Spike findings showed both schema-level -and source-level breakage between minor versions: - -- 0.4.1 → 0.12.3 added `apiServerBuild`, `apiServerAppName`, `logRoot` - fields to `SystemHealth`. An older Swift client decoding a newer - daemon response fails at parse time with - `"failed to decode apiServerBuild in health check"`. -- The product name + primary type renamed across the same window: - 0.4.1 exported product `ContainerClient` with type `ClientContainer`; - 0.12.x exports product `ContainerAPIClient` with type - `ContainerClient`. - -**Rule:** `Package.swift` uses `.package(url: ..., exact: "0.12.3")` — -SwiftPM's `exact:` requires a fully-qualified semver and rejects -wildcards. Bumps are their own PRs, gated by re-running the parity -integration suite (PR-H) against the new version. We do NOT use `from:` -or `branch:`. - -## 6. Build & distribution - -Build pipeline: - -1. `make bridge` → `swift build -c release` in `applecontainer-bridge/` - → `libACBridge.dylib` in `.build/arm64-apple-macosx/release/`. -2. Go build embeds the dylib bytes via `go:embed`. At process start, - the runtime constructor writes the dylib to a per-user cache path - (`os.UserCacheDir()/devcontainer-go/applecontainer/.dylib`), - skips the write if the hashed file already exists, then `dlopen`s - it. -3. Cgo `LDFLAGS` references the dylib at build time via an absolute - path for the link, but the runtime loader uses the embed+dlopen - path — this avoids users needing the build-time path at runtime. - -Rejected alternatives: - -- **Ship as a separate file next to the binary.** Forces every consumer - to manage two artifacts; awkward for consumer release pipelines that - expects a single binary. -- **Static link.** SwiftPM's static product mode is fragile when the - graph pulls Foundation/XPC; the Swift runtime libs in `/usr/lib/swift` - are dynamic on the system anyway, so we don't save the dlopen. -- **Require `brew install applecontainer-bridge`.** Cleanest builds, - worst UX. - -## 7. Platform constraints - -- Whole `runtime/applecontainer` package is `//go:build darwin && arm64`. -- macOS 15+ is a hard runtime floor (apple/container requirement). -- Swift 6.x toolchain required to build the bridge. -- CI: add a macOS arm64 runner for the M6 PRs. The rest of the repo - continues to build cross-platform on Linux runners — the build tag - excludes the whole package on non-darwin. -- Cross-compilation from Linux is **not supported** for binaries that - include this runtime. Consumer release pipelines build the macOS binary - on a macOS runner already; no change required there. - -## 8. Mapping to `Runtime` methods - -Every method on `runtime.Runtime` (runtime/runtime.go:83-129) is -reachable through Apple's stack. Direct mapping: - -| `Runtime` method | Apple API | Bridge shape | Notes | -| ----------------------- | -------------------------------------------------- | ---------------------------- | ----- | -| `BuildImage` | `ContainerBuild` (BuildKit gRPC) | callback stream for progress | Builder runs in a separate VM; clear error if not started. Heaviest PR (PR-G). | -| `PullImage` | `ImagePull` via images service | callback stream for progress | low risk | -| `RunContainer` | `ContainerClient.create` + create-not-start path | sync request/reply | Mounts, env, labels, runArgs map cleanly. Verify UID semantics (§11). | -| `StartContainer` | `ContainerClient.start` | sync | low risk | -| `StopContainer` | `ContainerClient.stop` | sync | Timeout maps directly. | -| `RemoveContainer` | `ContainerClient.delete` / `kill` | sync | Force + remove-volumes flags. | -| `ExecContainer` | `ContainerExec` flow with `ProcessIO` | pipe fds + callback | Hardest. TTY mode supported by `config.terminal`. Cancellation via Task. | -| `InspectContainer` | `ContainerClient.get` / `inspect` | sync, JSON response | Confirm Created/StartedAt/ExitCode/FinishedAt all present. | -| `InspectImage` | image service `inspect` | sync, JSON response | Labels round-trip — load-bearing for the `devcontainer.metadata` fast path. | -| `ContainerLogs` | `ContainerLogs` flow | pipe fd | Follow + non-follow. | -| `FindContainerByLabel` | `ContainerClient.list` + client-side label filter | sync | If Apple adds server-side filtering, switch. | -| `ComposeRuntime` (sub) | not implemented | type assertion fails | See §9. | - -## 9. `ComposeRuntime` not implemented - -Apple's stack has no compose concept. The `ComposeRuntime` sub-interface -(`runtime/runtime.go:19-35`) is intentionally a separate type that -`Engine.Up` type-asserts before invoking the compose path -(`runtime/runtime.go:9-18` comment). With `AppleContainerRuntime` the -assertion fails and the engine returns `ErrNotImplemented` — same -behavior as v1's `runtime/docker` for the build/compose source -distinction. - -This is documented here so a future contributor doesn't try to wire -compose-go into this backend without a separate design conversation. -Pre-baked compose images (`image: foo:bar` on a compose service) are -also out — the spec edge case of "one service, image-only, treat as -single container" is a v2+ conversation. - -## 10. Spike findings - -A two-day spike (this branch, `examples/applecontainer-spike/`) proved -the load-bearing assumptions before writing this design: - -1. **cgo + Swift dynamic library linking works.** `@_cdecl` exports - callable from Go via cgo with rpath set to the dylib build dir. - `libswift*` runtime libs resolve from `/usr/lib/swift` without - special flags. -2. **`apple/container` SwiftPM dep resolves and builds inside our - bridge.** Full dependency graph (~1000 compile units) builds in - ~3min cold, ~6s incremental. -3. **No codesigning or entitlements required for clients.** Ad-hoc - signed Go binary (Go's default) successfully establishes the XPC - connection. The apiserver checks EUID match only - (`XPCServer.swift:178-193`) — no Team ID, no entitlement string. -4. **Round-trip on 0.12.3 returns the full `SystemHealth` schema.** - `ClientHealthCheck.ping` from a cgo-linked Go binary: - - ```text - ping ok: SystemHealth( - appRoot: file:///.../Application Support/com.apple.container/, - installRoot: file:///opt/homebrew/Cellar/container/0.12.3/, - apiServerVersion: "container-apiserver version 0.12.3 ...", - apiServerBuild: "release", - apiServerAppName: "container-apiserver", - ... - ) - ``` - -5. **Version-skew failure observable.** Same code against 0.4.1 daemon - surfaced exactly the schema mismatch the §5 rule prevents — - evidence the pinning discipline is the right one. - -`ClientContainer.list()` against a clean daemon returned `count=0` -as expected. - -### 10.1 Validation probes (2026-05-14) - -After PR-A landed, a throwaway probe branch (`m6/probe-validation`) -extended the bridge with `ac_probe_list` / `_get` / `_stop` / `_delete` -/ `_exec` exports to validate the design's most uncertain bets before -committing to PR-B..H. Three probes ran against a live daemon; results -informed §11.1 (resolved) and confirmed PR-D's pipe pattern. - -1. **Lifecycle round-trip — green.** Bridge drove List → Get → Stop → - Delete on a CLI-created `alpine sleep 120` container. JSON encoding - of `[ContainerSnapshot]` round-tripped cleanly into Go's typed - deserializer; mutations took effect (subsequent list confirmed - removal). Validates cgo handles both complex XPC responses and - stateful operations. - -2. **Exec stdin/stdout/exit-code — green.** Probe wrapped - `ContainerClient.createProcess` + `ProcessIO` with three subcases: - stdout-only (`echo` captured), stdin → stdout roundtrip (`cat` with - a marker string roundtripped exactly), exit-code propagation - (`exit 42` returned `42`). The pipe-fd pattern with Swift-side - `Pipe()` + `closeAfterStart` + `readToEnd()` works as PR-D will - need. No async/cancellation glue yet — that's PR-D's work. - -3. **Bind mount UID — green, with design impact.** Created host - tmpdir with marker file (`uid=501 gid=20`), bind-mounted into - container as `/mnt/probe`. As root inside the VM: files appeared - `root:root`. As `uid=1000`: same files appeared `nonroot:nonroot`, - `cat` read OK, `echo > write.txt` returned `WRITE_OK`. Virtiofs - is **identity-permissive** — every container-side user sees - themselves as owner of bind-mounted files, regardless of host UID. - This is the same pattern Docker Desktop's VirtioFS uses. - Resolution captured in §11.1 and §13.8. - -4. **BuildKit gRPC** — not run. Reachability isn't in doubt (the CLI - uses the same gRPC client); only event-mapping shape is open, and - that's a PR-G design detail, not a blocker. - -## 11. Open questions - -These are integration details to resolve during PR-A..H, not blockers -for the design itself: - -1. **~~Bind-mount UID mapping across the VM boundary.~~ RESOLVED - 2026-05-14 (§10.1 probe 3).** Apple's virtiofs is - identity-permissive: every container-side user sees themselves as - owner of bind-mounted files, regardless of host UID. Files written - from inside the VM appear to the host as owned by the host user - (because the host kernel attributes writes by inode owner, which - the mount layer reflects back). The `updateRemoteUserUID` dance - from `useruid.go` is unnecessary for this backend — see decision - §13.8. Perf characterization on a large monorepo deferred to PR-C's - integration test. -2. **Builder VM lifecycle.** `container builder start` boots a - dedicated VM. Open questions: cold-start latency, can it stay warm - across builds, what happens to in-flight builds if it crashes, - how do we surface "builder not started" cleanly. PR-G owns this. -3. **Exec stream cancellation semantics.** Does cancelling the Swift - `Task` propagate to a SIGTERM of the in-VM process, or do we leak - long-running execs? Critical for long-running readiness probes and attach patterns. PR-D owns this. -4. **Distribution codesigning.** Ad-hoc signing was sufficient for the - spike, run locally. Need to confirm that consumers receiving the - binary via the consumer's release pipeline (signed Developer ID + - notarized) don't hit any TCC / Gatekeeper friction loading the - embedded dylib. Test on a clean machine before M6 ships. - -## 12. M6 ship target - -PR-A..H land sequentially; each gates on the previous. Detailed -breakdown in `design/status.md`. - -In scope: - -- `runtime/applecontainer/` Go package implementing `runtime.Runtime`. -- `applecontainer-bridge/` SwiftPM package producing `libACBridge.dylib`. -- Embed-and-dlopen distribution path. -- `Engine.New` integration: opt-in via `EngineOptions.Runtime` or a - factory helper (`devcontainer.NewAppleContainer(...)`). -- Parity integration suite re-running M2/M3 fixtures against the new - backend, behind a build tag. - -Out of scope for M6: - -- `ComposeRuntime` (see §9). -- `forwardPorts` actuation — same as v1's Docker backend. -- Linux / x86 hosts. -- Downstream CLI cutover — consumers pick a backend; - we just provide one. - -## 13. Decisions - -Resolved during this design review (2026-05-14): - -1. **Bridge: cgo + Swift dynamic library.** No sidecar process, no - shell-out to the `container` CLI. Single binary at the Go side, one - embedded dylib. Spike evidence in `examples/applecontainer-spike/`. -2. **SwiftPM dep pinned via `exact:`.** Bumps are deliberate PRs that - re-run PR-H's parity suite. Documented evidence of breakage in §5. -3. **Daemon required, probed at runtime construction.** Constructor - calls `ClientHealthCheck.ping`; returns - `*runtime.DaemonUnavailableError` if missing. No fallback, no - auto-start. -4. **Dylib distribution: `go:embed` + dlopen.** Hash-named file in - `os.UserCacheDir()`. Single artifact for consumers; no install - step beyond `brew install container` for the daemon itself. -5. **`ComposeRuntime` not implemented.** Type assertion fails, engine - returns `ErrNotImplemented`. Documented per §9 so future - contributors don't backtrack into the question. -6. **Build constraints: `//go:build darwin && arm64`, macOS 15+.** - Cross-compilation from non-darwin hosts not supported when this - runtime is compiled in. Rest of repo unaffected. -7. **API surface marked stable from M6 v0.1.** The Go-side - `runtime.Runtime` interface doesn't change for this backend; the - bridge dylib version is an internal detail not exposed to consumers. -8. **Skip `updateRemoteUserUID` for apple-container.** Validated by - the §10.1 probe 3 — virtiofs is identity-permissive, so the UID - reconciliation that `runtime/docker` performs is unnecessary and - would be harmful (modifies the in-container user's UID for no - benefit). PR-C wires `Engine.Up` to short-circuit the - `updateRemoteUserUID` path when the runtime is apple-container. - Mechanism: a small capability flag on the `Runtime` interface - (rather than a type assertion against an apple-container-only - marker interface) so future backends can opt in without coupling - the engine to backend identity. diff --git a/engine.go b/engine.go index 67d643b..755b54f 100644 --- a/engine.go +++ b/engine.go @@ -94,7 +94,7 @@ const ( // ComposeBackendShellout (default) uses runtime.ComposeRuntime // — `docker compose` v2 plugin under the hood. Reliable for // Docker, refused-with-typed-error for backends that don't - // implement the sub-interface (i.e. applecontainer). + // implement the sub-interface. ComposeBackendShellout ComposeBackend = 0 // ComposeBackendNative uses compose.Orchestrator driving diff --git a/runtime/applecontainer/build_darwin_arm64.go b/runtime/applecontainer/build_darwin_arm64.go deleted file mode 100644 index cea5d78..0000000 --- a/runtime/applecontainer/build_darwin_arm64.go +++ /dev/null @@ -1,156 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -/* -#include -#include "shim.h" -*/ -import "C" - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "unsafe" - - "github.com/crunchloop/devcontainer/runtime" -) - -// buildSpecWire mirrors applecontainer-bridge/Sources/ACBridge/build.swift's -// BuildSpecJSON. Engine concepts we don't model on this backend -// (RunArgs/Privileged/SecurityOpt analogs) are intentionally absent -// from this wire type — same pattern as runSpecJSON in lifecycle. -type buildSpecWire struct { - ContextPath string `json:"contextPath"` - Dockerfile string `json:"dockerfile,omitempty"` - Tag string `json:"tag,omitempty"` - Args map[string]string `json:"args,omitempty"` - Target string `json:"target,omitempty"` - CacheFrom []string `json:"cacheFrom,omitempty"` - NoCache bool `json:"noCache,omitempty"` - Platform string `json:"platform,omitempty"` -} - -type buildResultData struct { - Reference string `json:"reference"` - Digest string `json:"digest"` -} - -// BuildImage performs a Dockerfile build via Apple's BuildKit -// container. Requires the user to have run `container builder start` -// beforehand — auto-start would require reimplementing -// BuilderStart.start which is module-internal to ContainerCommands. -// -// Behavior: -// - If the builder is not running, returns -// *runtime.BuilderUnavailableError with a hint. -// - Otherwise dials buildkit over vsock, runs the BuildKit build, -// loads the resulting OCI tarball into the local content store, -// tags it with spec.Tag, and returns a runtime.ImageRef. -// -// Progress events: PR-G2 ships without streaming events. BuildKit's -// progress output goes to the bridge's stderr (which the Go process -// inherits). Callers see raw build output on the console. A future -// PR can swap in a pipe-fd capture and emit typed BuildEvents. -func (r *Runtime) BuildImage(ctx context.Context, spec runtime.BuildSpec, events chan<- runtime.BuildEvent) (runtime.ImageRef, error) { - if err := ctx.Err(); err != nil { - return runtime.ImageRef{}, err - } - if err := ensureLoaded(); err != nil { - return runtime.ImageRef{}, err - } - - // Builder-liveness probe. Cheap and gives us a clean typed error - // before we marshal the full spec. The bridge tags the - // builder-down case with code="BUILDER_UNAVAILABLE"; we key off - // that rather than message text. - probeRaw := goStringAndFree(C.ac_build_probe_p()) - if probeRaw == "" { - return runtime.ImageRef{}, errors.New("applecontainer: bridge returned nil for BuildImage probe") - } - if probeEnv, err := decodeEnvelope[map[string]any](probeRaw); err != nil { - if probeEnv.Code == bridgeCodeBuilderUnavailable { - return runtime.ImageRef{}, &runtime.BuilderUnavailableError{ - Hint: "run `container builder start` to start the build VM", - Err: err, - } - } - return runtime.ImageRef{}, err - } - - emitBuildEvent(events, runtime.BuildEvent{ - Kind: runtime.BuildEventLog, - Message: "applecontainer: building " + spec.Tag, - }) - - wire := buildSpecWire{ - ContextPath: spec.ContextPath, - Dockerfile: spec.Dockerfile, - Tag: spec.Tag, - Args: spec.Args, - Target: spec.Target, - CacheFrom: spec.CacheFrom, - NoCache: spec.NoCache, - Platform: spec.Platform, - } - specBytes, err := json.Marshal(wire) - if err != nil { - return runtime.ImageRef{}, fmt.Errorf("applecontainer: marshal BuildSpec: %w", err) - } - cSpec := C.CString(string(specBytes)) - defer C.free(unsafe.Pointer(cSpec)) - - raw := goStringAndFree(C.ac_build_p(cSpec)) - if raw == "" { - return runtime.ImageRef{}, errors.New("applecontainer: bridge returned nil for BuildImage") - } - env, err := decodeEnvelope[buildResultData](raw) - if err != nil { - // Apple's "builder not running" error path can surface here - // if the buildkit container vanished between probe and build. - // Primary key is the bridge's machine-readable `code`; the - // message-text fallback covers older bridge builds. - if env.Code == bridgeCodeBuilderUnavailable || isBuilderUnavailable(err) { - return runtime.ImageRef{}, &runtime.BuilderUnavailableError{ - Hint: "run `container builder start` to start the build VM", - Err: err, - } - } - return runtime.ImageRef{}, err - } - - emitBuildEvent(events, runtime.BuildEvent{ - Kind: runtime.BuildEventCompleted, - Digest: env.decoded.Digest, - Message: "applecontainer: built " + env.decoded.Reference, - }) - - tags := []string{} - if env.decoded.Reference != "" { - tags = append(tags, env.decoded.Reference) - } - return runtime.ImageRef{ - ID: env.decoded.Digest, - Tags: tags, - }, nil -} - -// bridgeCodeBuilderUnavailable matches the `code` the Swift bridge -// stamps on the failure envelope when Apple's buildkit container is -// not running. Keep in sync with applecontainer-bridge/Sources/ -// ACBridge/build.swift. -const bridgeCodeBuilderUnavailable = "BUILDER_UNAVAILABLE" - -func isBuilderUnavailable(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return containsAny(msg, - "builder not running", - "container buildkit not found", - "buildkit container", - ) -} diff --git a/runtime/applecontainer/build_darwin_arm64_test.go b/runtime/applecontainer/build_darwin_arm64_test.go deleted file mode 100644 index b753299..0000000 --- a/runtime/applecontainer/build_darwin_arm64_test.go +++ /dev/null @@ -1,157 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/crunchloop/devcontainer/runtime" -) - -// TestBuildImage_DockerfileSmoke is the PR-G2 happy-path test: -// write a 2-line Dockerfile, BuildImage, then InspectImage to confirm -// the resulting tag exists in the local content store. Skips when -// the builder isn't running (PR-G stub path). -func TestBuildImage_DockerfileSmoke(t *testing.T) { - rt := runtimeOrSkip(t) - ctx := context.Background() - - // Warm alpine first — the FROM line references it. - cliRunStrict(t, - "run", "--rm", "--name", "ac-alpine-warmup", - "docker.io/library/alpine:latest", "/bin/true", - ) - - contextDir := t.TempDir() - dockerfile := "FROM docker.io/library/alpine:latest\nRUN echo built-by-pr-g2 > /built-marker\n" - if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { - t.Fatalf("write dockerfile: %v", err) - } - - tag := "applecontainer-bridge-test/pr-g2-smoke:latest" - events := make(chan runtime.BuildEvent, 8) - imageRef, err := rt.BuildImage(ctx, runtime.BuildSpec{ - ContextPath: contextDir, - Dockerfile: "Dockerfile", - Tag: tag, - }, events) - close(events) - - if err != nil { - var unavail *runtime.BuilderUnavailableError - if errors.As(err, &unavail) { - t.Skipf("builder not running (run `container builder start`): %v", err) - } - t.Fatalf("BuildImage: %v", err) - } - - if imageRef.ID == "" { - t.Errorf("ImageRef.ID (digest) empty") - } - if len(imageRef.Tags) == 0 || imageRef.Tags[0] != tag { - t.Errorf("Tags: want [%q], got %v", tag, imageRef.Tags) - } - - // Inspect should now see the tag locally. - details, err := rt.InspectImage(ctx, tag) - if err != nil { - t.Fatalf("InspectImage(%q): %v", tag, err) - } - if details.ID == "" { - t.Errorf("InspectImage: empty digest") - } - - // Event surface: at least one completed event with a digest. - var sawCompleted bool - for ev := range events { - if ev.Kind == runtime.BuildEventCompleted { - sawCompleted = true - if ev.Digest == "" { - t.Errorf("BuildEventCompleted: empty digest") - } - } - } - if !sawCompleted { - t.Errorf("no BuildEventCompleted emitted") - } -} - -// TestBuildImage_NoCache_FreshLayers re-runs the same Dockerfile with -// NoCache=true and asserts the RUN step is NOT marked CACHED. The -// previous smoke test relies on the cache hit; this one verifies the -// flag actually plumbs through. -func TestBuildImage_NoCache_FreshLayers(t *testing.T) { - rt := runtimeOrSkip(t) - ctx := context.Background() - - cliRunStrict(t, - "run", "--rm", "--name", "ac-alpine-warmup", - "docker.io/library/alpine:latest", "/bin/true", - ) - - contextDir := t.TempDir() - // Use $(date) so each build sees a different RUN line if cache - // is honored — actually, we want the OPPOSITE: same RUN line, - // noCache=true → fresh execution. The buildkit output won't say - // "CACHED" in that case. Hard to assert from Go-side since - // progress is on stderr; we mostly assert the call succeeds and - // produces an image. - dockerfile := "FROM docker.io/library/alpine:latest\nRUN echo nocache-pass\n" - if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { - t.Fatalf("write dockerfile: %v", err) - } - - tag := "applecontainer-bridge-test/pr-g2-nocache:latest" - imageRef, err := rt.BuildImage(ctx, runtime.BuildSpec{ - ContextPath: contextDir, - Dockerfile: "Dockerfile", - Tag: tag, - NoCache: true, - }, nil) - if err != nil { - var unavail *runtime.BuilderUnavailableError - if errors.As(err, &unavail) { - t.Skipf("builder not running: %v", err) - } - t.Fatalf("BuildImage(noCache): %v", err) - } - if imageRef.ID == "" { - t.Errorf("ImageRef.ID empty") - } -} - -// TestBuildImage_PartialContract keeps the PR-G builder-unavailable -// path covered: if no builder is running the call MUST return a typed -// error, not surface a generic one. Only meaningful when the builder -// is actually down; otherwise we skip rather than warm it back up. -func TestBuildImage_BuilderDownTypedError(t *testing.T) { - rt := runtimeOrSkip(t) - // Try to stop the builder to force the error path. Best-effort — - // if it fails (e.g. wasn't running anyway), proceed. - cliRun(t, "builder", "stop") - - _, err := rt.BuildImage(context.Background(), runtime.BuildSpec{ - ContextPath: t.TempDir(), - Dockerfile: "Dockerfile", - Tag: "should-fail:latest", - }, nil) - if err == nil { - // Builder came back up between our stop and the call — - // skip rather than misreport. - t.Skip("builder is up; cannot exercise the down path here") - } - var unavail *runtime.BuilderUnavailableError - if !errors.As(err, &unavail) { - // Could also be an "image not found" or other typed error if - // the dockerfile read fails before the builder dial. Inspect - // for clues. - if !strings.Contains(err.Error(), "Dockerfile") { - t.Errorf("want *BuilderUnavailableError, got %T: %v", err, err) - } - } -} diff --git a/runtime/applecontainer/compose_primitives_darwin_arm64.go b/runtime/applecontainer/compose_primitives_darwin_arm64.go deleted file mode 100644 index 3472a40..0000000 --- a/runtime/applecontainer/compose_primitives_darwin_arm64.go +++ /dev/null @@ -1,322 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -/* -#include -#include "shim.h" -*/ -import "C" - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "unsafe" - - "github.com/crunchloop/devcontainer/runtime" -) - -// Compose orchestrator primitives — apple/container 0.12 surface. -// Each Go method marshals a runtime-neutral *Spec into JSON, calls -// through the cgo shim into the Swift bridge, decodes the envelope, -// and returns the typed result. -// -// Capabilities() still advertises all-false per design §11.5: the -// upstream apple/container gaps (healthchecks #1502, exit codes -// #1501, restart policies #286, shared volumes #889, -// namespace-sharing architectural) remain open as of 0.12.x. -// Compose orchestrator's Plan validator refuses projects that need -// those features; projects that don't (the simple service_started -// case, no shared volumes, no namespace games, no restart-on-crash) -// work fine with these primitives. - -// networkSpecWire mirrors applecontainer-bridge/Sources/ACBridge/ -// networks.swift's NetworkSpecJSON. Apple ignores driver / options -// (it has one network plugin); we send them through for parity but -// they have no effect on the backend side. -type networkSpecWire struct { - Name string `json:"name"` - Labels map[string]string `json:"labels,omitempty"` - Driver string `json:"driver,omitempty"` - Options map[string]string `json:"options,omitempty"` -} - -type networkResultData struct { - ID string `json:"id"` -} - -// CreateNetwork creates a project network via apple's NetworkClient. -// Idempotent on (name, label superset) — re-runs of compose Up -// against an existing project reuse the network rather than erroring. -func (r *Runtime) CreateNetwork(ctx context.Context, spec runtime.NetworkSpec) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - if err := ensureLoaded(); err != nil { - return "", err - } - wire := networkSpecWire{ - Name: spec.Name, - Labels: spec.Labels, - Driver: spec.Driver, - Options: spec.Options, - } - specBytes, err := json.Marshal(wire) - if err != nil { - return "", fmt.Errorf("applecontainer: marshal NetworkSpec: %w", err) - } - cSpec := C.CString(string(specBytes)) - defer C.free(unsafe.Pointer(cSpec)) - - raw := goStringAndFree(C.ac_network_create_p(cSpec)) - if raw == "" { - return "", errors.New("applecontainer: bridge returned nil for CreateNetwork") - } - env, err := decodeEnvelope[networkResultData](raw) - if err != nil { - return "", err - } - return env.decoded.ID, nil -} - -// RemoveNetwork deletes a network by ID. Missing-network errors are -// swallowed in the bridge so this is naturally idempotent. -func (r *Runtime) RemoveNetwork(ctx context.Context, id string) error { - if err := ctx.Err(); err != nil { - return err - } - if err := ensureLoaded(); err != nil { - return err - } - cID := C.CString(id) - defer C.free(unsafe.Pointer(cID)) - raw := goStringAndFree(C.ac_network_remove_p(cID)) - if raw == "" { - return errors.New("applecontainer: bridge returned nil for RemoveNetwork") - } - if _, err := decodeEnvelope[json.RawMessage](raw); err != nil { - return err - } - return nil -} - -// volumeSpecWire mirrors volumes.swift's VolumeSpecJSON. -type volumeSpecWire struct { - Name string `json:"name"` - Labels map[string]string `json:"labels,omitempty"` - Driver string `json:"driver,omitempty"` - Options map[string]string `json:"options,omitempty"` -} - -type volumeResultData struct { - Name string `json:"name"` -} - -// CreateVolume creates a named volume via ClientVolume. Idempotent -// on label-superset match. -func (r *Runtime) CreateVolume(ctx context.Context, spec runtime.VolumeSpec) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - if err := ensureLoaded(); err != nil { - return "", err - } - wire := volumeSpecWire{ - Name: spec.Name, - Labels: spec.Labels, - Driver: spec.Driver, - Options: spec.Options, - } - specBytes, err := json.Marshal(wire) - if err != nil { - return "", fmt.Errorf("applecontainer: marshal VolumeSpec: %w", err) - } - cSpec := C.CString(string(specBytes)) - defer C.free(unsafe.Pointer(cSpec)) - - raw := goStringAndFree(C.ac_volume_create_p(cSpec)) - if raw == "" { - return "", errors.New("applecontainer: bridge returned nil for CreateVolume") - } - env, err := decodeEnvelope[volumeResultData](raw) - if err != nil { - return "", err - } - return env.decoded.Name, nil -} - -// RemoveVolume deletes a named volume. Bridge swallows notFound. -func (r *Runtime) RemoveVolume(ctx context.Context, name string) error { - if err := ctx.Err(); err != nil { - return err - } - if err := ensureLoaded(); err != nil { - return err - } - cName := C.CString(name) - defer C.free(unsafe.Pointer(cName)) - raw := goStringAndFree(C.ac_volume_remove_p(cName)) - if raw == "" { - return errors.New("applecontainer: bridge returned nil for RemoveVolume") - } - if _, err := decodeEnvelope[json.RawMessage](raw); err != nil { - return err - } - return nil -} - -// containerListItem mirrors list.swift's ContainerListItem. -type containerListItem struct { - ID string `json:"id"` - Name string `json:"name"` - Image string `json:"image"` - State string `json:"state"` - Labels map[string]string `json:"labels"` -} - -type containerListData struct { - Containers []containerListItem `json:"containers"` -} - -// ListContainers enumerates every container the apiserver knows -// about and applies the filter client-side. Apple's list endpoint -// doesn't support server-side label filtering as of 0.12.x (design -// probe R1b); the workspace-scale traffic makes the overhead a -// non-issue. -func (r *Runtime) ListContainers(ctx context.Context, filter runtime.LabelFilter) ([]runtime.Container, error) { - if len(filter.Match) == 0 { - return nil, errors.New("applecontainer: ListContainers requires a non-empty filter") - } - if err := ctx.Err(); err != nil { - return nil, err - } - if err := ensureLoaded(); err != nil { - return nil, err - } - raw := goStringAndFree(C.ac_list_containers_p()) - if raw == "" { - return nil, errors.New("applecontainer: bridge returned nil for ListContainers") - } - env, err := decodeEnvelope[containerListData](raw) - if err != nil { - return nil, err - } - out := make([]runtime.Container, 0, len(env.decoded.Containers)) - for _, item := range env.decoded.Containers { - if !labelsMatchFilter(item.Labels, filter.Match) { - continue - } - labels := make(map[string]string, len(item.Labels)) - for k, v := range item.Labels { - labels[k] = v - } - out = append(out, runtime.Container{ - ID: item.ID, - Name: item.Name, - Image: item.Image, - State: mapState(item.State), - Labels: labels, - }) - } - return out, nil -} - -// imageListItem mirrors list.swift's ImageListItem. -type imageListItem struct { - ID string `json:"id"` - Tags []string `json:"tags"` -} - -type imageListData struct { - Images []imageListItem `json:"images"` -} - -// ListImages enumerates local images and filters client-side. Apple -// doesn't carry custom labels on images today; filter.Match against -// our project-label keys will typically match nothing, which is -// fine — Down's --rmi local would simply find no project-built -// images to prune on apple. -func (r *Runtime) ListImages(ctx context.Context, filter runtime.LabelFilter) ([]runtime.ImageRef, error) { - if len(filter.Match) == 0 { - return nil, errors.New("applecontainer: ListImages requires a non-empty filter") - } - if err := ctx.Err(); err != nil { - return nil, err - } - if err := ensureLoaded(); err != nil { - return nil, err - } - raw := goStringAndFree(C.ac_list_images_p()) - if raw == "" { - return nil, errors.New("applecontainer: bridge returned nil for ListImages") - } - env, err := decodeEnvelope[imageListData](raw) - if err != nil { - return nil, err - } - // Apple's ImageDescription doesn't currently expose labels — - // without per-image labels we can't filter on them. Return the - // full list; callers (Orchestrator.Down --rmi local) typically - // pass a project label that matches nothing here, so the - // downstream RemoveImage loop is a no-op. - out := make([]runtime.ImageRef, 0, len(env.decoded.Images)) - for _, item := range env.decoded.Images { - out = append(out, runtime.ImageRef{ - ID: item.ID, - Tags: append([]string(nil), item.Tags...), - }) - } - return out, nil -} - -// RemoveImage removes a local image by reference. Bridge swallows -// notFound. -func (r *Runtime) RemoveImage(ctx context.Context, ref string) error { - if err := ctx.Err(); err != nil { - return err - } - if err := ensureLoaded(); err != nil { - return err - } - cRef := C.CString(ref) - defer C.free(unsafe.Pointer(cRef)) - raw := goStringAndFree(C.ac_remove_image_p(cRef)) - if raw == "" { - return errors.New("applecontainer: bridge returned nil for RemoveImage") - } - if _, err := decodeEnvelope[json.RawMessage](raw); err != nil { - return err - } - return nil -} - -// Capabilities reports the apple-container backend's compose -// feature support. As of 0.12.x every flag is false — the upstream -// apple/container issues governing each capability are still open -// (see design/compose-native.md §11.5). The compose Plan validator -// uses this struct to refuse projects that require any of these -// features; everything else works through the primitive surface -// implemented above. -func (r *Runtime) Capabilities() runtime.Capabilities { - return runtime.Capabilities{ - Healthchecks: false, - ExitCodes: false, - NamespaceSharing: false, - RestartPolicies: false, - SharedVolumes: false, - } -} - -// labelsMatchFilter is the client-side label filter we apply after -// fetching a full container/image list. Every (k, v) in want must -// appear in have for the resource to match. -func labelsMatchFilter(have map[string]string, want map[string]string) bool { - for k, v := range want { - if have[k] != v { - return false - } - } - return true -} diff --git a/runtime/applecontainer/doc.go b/runtime/applecontainer/doc.go deleted file mode 100644 index 47259c5..0000000 --- a/runtime/applecontainer/doc.go +++ /dev/null @@ -1,21 +0,0 @@ -// 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/embed_darwin_arm64.go b/runtime/applecontainer/embed_darwin_arm64.go deleted file mode 100644 index 5330d86..0000000 --- a/runtime/applecontainer/embed_darwin_arm64.go +++ /dev/null @@ -1,98 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -/* -#include -#include "shim.h" -*/ -import "C" - -import ( - "crypto/sha256" - _ "embed" - "encoding/hex" - "fmt" - "os" - "path/filepath" - "sync" - "unsafe" -) - -// bridgeDylib is the libACBridge.dylib produced by `make bridge`. -// The embed expects the file to exist at build time; on a fresh -// checkout you must run `make bridge` once before the package will -// compile on darwin/arm64. -// -//go:embed embed/libACBridge.dylib -var bridgeDylib []byte - -var ( - loadOnce sync.Once - loadErr error -) - -// ensureLoaded extracts the embedded dylib to a per-user cache path -// (keyed by the dylib's content hash so multiple bridge versions -// coexist) and dlopens it via the C shim. Idempotent — subsequent -// calls are no-ops. -func ensureLoaded() error { - loadOnce.Do(func() { loadErr = loadBridge() }) - return loadErr -} - -func loadBridge() error { - if len(bridgeDylib) == 0 { - return fmt.Errorf("applecontainer: embedded dylib is empty (run `make bridge`)") - } - cacheDir, err := os.UserCacheDir() - if err != nil { - return fmt.Errorf("applecontainer: UserCacheDir: %w", err) - } - cacheDir = filepath.Join(cacheDir, "devcontainer-go", "applecontainer") - if err := os.MkdirAll(cacheDir, 0o755); err != nil { - return fmt.Errorf("applecontainer: mkdir cache: %w", err) - } - - sum := sha256.Sum256(bridgeDylib) - hashed := hex.EncodeToString(sum[:]) - dylibPath := filepath.Join(cacheDir, hashed+".dylib") - - if _, err := os.Stat(dylibPath); os.IsNotExist(err) { - // Write to a temp file then rename, so a partial write from a - // crashed process can't be picked up by a concurrent reader. - tmp, err := os.CreateTemp(cacheDir, hashed+".dylib.*") - if err != nil { - return fmt.Errorf("applecontainer: create tmp dylib: %w", err) - } - tmpPath := tmp.Name() - if _, err := tmp.Write(bridgeDylib); err != nil { - tmp.Close() - os.Remove(tmpPath) - return fmt.Errorf("applecontainer: write dylib: %w", err) - } - if err := tmp.Chmod(0o755); err != nil { - tmp.Close() - os.Remove(tmpPath) - return fmt.Errorf("applecontainer: chmod dylib: %w", err) - } - if err := tmp.Close(); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("applecontainer: close dylib: %w", err) - } - if err := os.Rename(tmpPath, dylibPath); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("applecontainer: rename dylib: %w", err) - } - } else if err != nil { - return fmt.Errorf("applecontainer: stat dylib: %w", err) - } - - cPath := C.CString(dylibPath) - defer C.free(unsafe.Pointer(cPath)) - var errbuf [512]C.char - if rc := C.ac_load(cPath, &errbuf[0], C.size_t(len(errbuf))); rc != 0 { - return fmt.Errorf("applecontainer: dlopen %s: %s", dylibPath, C.GoString(&errbuf[0])) - } - return nil -} diff --git a/runtime/applecontainer/envelope_test.go b/runtime/applecontainer/envelope_test.go deleted file mode 100644 index 62d90c5..0000000 --- a/runtime/applecontainer/envelope_test.go +++ /dev/null @@ -1,121 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -import ( - "errors" - "strings" - "testing" - - "github.com/crunchloop/devcontainer/runtime" -) - -// Pure-Go tests for the envelope decoder. No daemon, no cgo at -// runtime — exercises the JSON contract from the style guide. - -func TestDecodeEnvelope_Success(t *testing.T) { - raw := `{"ok":true,"data":{"reference":"alpine","digest":"sha256:abc","architecture":"arm64"}}` - env, err := decodeEnvelope[imageInspectPayload](raw) - if err != nil { - t.Fatalf("decode: %v", err) - } - if !env.OK { - t.Error("OK: want true") - } - if env.decoded.Reference != "alpine" { - t.Errorf("Reference: %q", env.decoded.Reference) - } - if env.decoded.Digest != "sha256:abc" { - t.Errorf("Digest: %q", env.decoded.Digest) - } -} - -func TestDecodeEnvelope_Failure(t *testing.T) { - raw := `{"ok":false,"err":"not found"}` - _, err := decodeEnvelope[imageInspectPayload](raw) - if err == nil { - t.Fatal("want error, got nil") - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("err msg: %v", err) - } -} - -func TestDecodeEnvelope_NullData(t *testing.T) { - // find-by-label miss path: ok=true but data is null. - raw := `{"ok":true,"data":null}` - env, err := decodeEnvelope[containerSnapshot](raw) - if err != nil { - t.Fatalf("decode: %v", err) - } - if !env.OK { - t.Error("OK: want true for a null-data miss") - } - if env.decoded.Configuration.ID != "" { - t.Errorf("decoded container ID should be empty for null data, got %q", - env.decoded.Configuration.ID) - } -} - -func TestDecodeEnvelope_Malformed(t *testing.T) { - _, err := decodeEnvelope[imageInspectPayload](`{not-json}`) - if err == nil { - t.Fatal("want error for malformed JSON") - } -} - -// TestRejectUnsupportedRunSpec pins the contract that RunArgs, -// Privileged, and SecurityOpt fail fast with a typed error rather -// than being silently dropped at the bridge boundary. -func TestRejectUnsupportedRunSpec(t *testing.T) { - cases := []struct { - name string - spec runtime.RunSpec - opt string - }{ - {name: "RunArgs", spec: runtime.RunSpec{RunArgs: []string{"--add-host=foo:1.2.3.4"}}, opt: "RunArgs"}, - {name: "Privileged", spec: runtime.RunSpec{Privileged: true}, opt: "Privileged"}, - {name: "SecurityOpt", spec: runtime.RunSpec{SecurityOpt: []string{"no-new-privileges"}}, opt: "SecurityOpt"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := rejectUnsupportedRunSpec(tc.spec) - if err == nil { - t.Fatalf("want error, got nil") - } - var unsup *runtime.UnsupportedOptionError - if !errors.As(err, &unsup) { - t.Fatalf("want *UnsupportedOptionError, got %T: %v", err, err) - } - if unsup.Option != tc.opt { - t.Errorf("Option = %q, want %q", unsup.Option, tc.opt) - } - if unsup.Backend != "applecontainer" { - t.Errorf("Backend = %q, want %q", unsup.Backend, "applecontainer") - } - }) - } -} - -// TestRejectUnsupportedRunSpec_AllowsZeroValue confirms the validator -// is a no-op for the common case (all unsupported fields empty). -func TestRejectUnsupportedRunSpec_AllowsZeroValue(t *testing.T) { - if err := rejectUnsupportedRunSpec(runtime.RunSpec{Image: "x", Name: "y"}); err != nil { - t.Errorf("want nil, got %v", err) - } -} - -func TestMapState(t *testing.T) { - cases := map[string]string{ - "running": "running", - "stopped": "exited", - "stopping": "removing", - "unknown": "", - "garbage": "", - } - for in, want := range cases { - if got := mapState(in); string(got) != want { - t.Errorf("mapState(%q): want %q got %q", in, want, got) - } - } -} diff --git a/runtime/applecontainer/exec_darwin_arm64.go b/runtime/applecontainer/exec_darwin_arm64.go deleted file mode 100644 index e850612..0000000 --- a/runtime/applecontainer/exec_darwin_arm64.go +++ /dev/null @@ -1,349 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -/* -#include -#include "shim.h" -*/ -import "C" - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strings" - "sync" - "syscall" - "unsafe" - - "github.com/crunchloop/devcontainer/runtime" -) - -// execOptsJSON mirrors lifecycle's RunSpecJSON pattern: explicit -// wire-only struct so the silently-dropped fields from -// runtime.ExecOptions are visible in code review. -type execOptsJSON struct { - Cmd []string `json:"cmd"` - Env []string `json:"env,omitempty"` - User string `json:"user,omitempty"` - WorkingDir string `json:"workingDir,omitempty"` - TTY bool `json:"tty,omitempty"` -} - -type execStartData struct { - Handle uint64 `json:"handle"` -} - -type execWaitData struct { - ExitCode int32 `json:"exitCode"` -} - -// ExecContainer runs a command inside a running container. -// -// stdio plumbing: each requested stream gets an os.Pipe pair. The -// "apiserver-facing" end is passed to the bridge as an fd (XPC dup's -// it during the createProcess XPC), then closed locally. The -// "Go-facing" end stays open and is wired to the caller's -// io.Reader/Writer via goroutines. -// -// Cancellation: a ctx-watcher goroutine sends SIGTERM via the bridge -// when ctx.Done() fires. The wait then returns naturally (typically -// exit code 143 = 128 + SIGTERM). ExecContainer returns ctx.Err() in -// that case, regardless of the underlying exit code. -// -// When opts.Stdout/Stderr are nil, output is captured into the -// returned ExecResult.Stdout/Stderr. The bridge always opens a -// stdout pipe; stderr is suppressed in TTY mode (Apple merges -// stderr into stdout when terminal=true) and the captured/streamed -// stderr is empty in that case. -func (r *Runtime) ExecContainer(ctx context.Context, id string, opts runtime.ExecOptions) (runtime.ExecResult, error) { - if err := ctx.Err(); err != nil { - return runtime.ExecResult{}, err - } - if err := ensureLoaded(); err != nil { - return runtime.ExecResult{}, err - } - if len(opts.Cmd) == 0 { - return runtime.ExecResult{}, errors.New("applecontainer: ExecOptions.Cmd is empty") - } - - // Build the pipes Go-side. Failure on any pipe is unrecoverable; - // we close partial allocations before bailing. - pipes, err := openExecPipes(opts) - if err != nil { - return runtime.ExecResult{}, err - } - defer pipes.closeLocal() // belt-and-suspenders; goroutines also close - - // Marshal the per-call opts and hand fds to the bridge. - wire := execOptsJSON{ - Cmd: opts.Cmd, - Env: envMapToSlice(opts.Env), - User: opts.User, - WorkingDir: opts.WorkingDir, - TTY: opts.Tty, - } - optsBytes, err := json.Marshal(wire) - if err != nil { - return runtime.ExecResult{}, fmt.Errorf("applecontainer: marshal exec opts: %w", err) - } - - cID := C.CString(id) - defer C.free(unsafe.Pointer(cID)) - cOpts := C.CString(string(optsBytes)) - defer C.free(unsafe.Pointer(cOpts)) - - raw := goStringAndFree(C.ac_exec_start_p( - cID, cOpts, - C.int32_t(pipes.stdinReadFd()), - C.int32_t(pipes.stdoutWriteFd()), - C.int32_t(pipes.stderrWriteFd()), - )) - if raw == "" { - return runtime.ExecResult{}, errors.New("applecontainer: bridge returned nil for ExecStart") - } - startEnv, err := decodeEnvelope[execStartData](raw) - if err != nil { - return runtime.ExecResult{}, mapLifecycleErr(id, err) - } - handle := startEnv.decoded.Handle - - // The bridge (and through XPC, the apiserver) now owns dup'd - // copies of the apiserver-facing fds. Close ours so the only - // references are the ones we still want (Go-facing ends). - pipes.closeRemoteEnds() - - // Spawn the stdio goroutines. We use a WaitGroup to ensure all - // copies drain before we return — losing stdout because the - // reader goroutine hadn't finished would be a silent footgun. - var ( - wg sync.WaitGroup - stdoutBuf strings.Builder - stderrBuf strings.Builder - stdoutSink = pickWriter(opts.Stdout, &stdoutBuf) - stderrSink = pickWriter(opts.Stderr, &stderrBuf) - copyErrCh = make(chan error, 3) - ) - - if pipes.stdinWriter() != nil { - wg.Add(1) - go func() { - defer wg.Done() - // Write what the caller provided, then close so the - // container sees EOF on stdin. Errors here are usually - // "broken pipe" because the process exited; surface them - // via copyErrCh but don't fail the exec. - if opts.Stdin != nil { - if _, err := io.Copy(pipes.stdinWriter(), opts.Stdin); err != nil && !isBrokenPipe(err) { - copyErrCh <- fmt.Errorf("stdin copy: %w", err) - } - } - pipes.stdinWriter().Close() - }() - } - if pipes.stdoutReader() != nil { - wg.Add(1) - go func() { - defer wg.Done() - if _, err := io.Copy(stdoutSink, pipes.stdoutReader()); err != nil && !isBrokenPipe(err) { - copyErrCh <- fmt.Errorf("stdout copy: %w", err) - } - }() - } - if pipes.stderrReader() != nil { - wg.Add(1) - go func() { - defer wg.Done() - if _, err := io.Copy(stderrSink, pipes.stderrReader()); err != nil && !isBrokenPipe(err) { - copyErrCh <- fmt.Errorf("stderr copy: %w", err) - } - }() - } - - // ctx-watcher: send SIGTERM on cancel. The watcher exits either - // when ctx fires (cancelled path) or when we close `done` after - // wait returns (clean path). - var cancelled bool - var cancelMu sync.Mutex - doneSignal := make(chan struct{}) - go func() { - select { - case <-ctx.Done(): - cancelMu.Lock() - cancelled = true - cancelMu.Unlock() - C.ac_exec_signal_p(C.uint64_t(handle), C.int32_t(syscall.SIGTERM)) - case <-doneSignal: - } - }() - - // Wait for the in-VM process to exit. This is the long block. - // timeout_seconds=0 means "use the bridge's internal max". - waitRaw := goStringAndFree(C.ac_exec_wait_p(C.uint64_t(handle), 0)) - close(doneSignal) - - // Always release the handle before we leave the function. - defer C.ac_exec_release_p(C.uint64_t(handle)) - - // Drain copy goroutines. The apiserver closes its fd when the - // process exits, which surfaces as EOF on our reader, so io.Copy - // returns naturally without us having to close anything. Closing - // the Go-facing ends BEFORE the drain (an earlier safety net) - // risked truncating buffered output, so it was removed. - wg.Wait() - close(copyErrCh) - - var copyErr error - for e := range copyErrCh { - copyErr = errors.Join(copyErr, e) - } - - cancelMu.Lock() - wasCancelled := cancelled - cancelMu.Unlock() - if wasCancelled { - // ctx.Err() (DeadlineExceeded / Canceled) takes precedence - // over whatever exit code the SIGTERMed process surfaced. - return runtime.ExecResult{}, ctx.Err() - } - - if waitRaw == "" { - return runtime.ExecResult{}, errors.New("applecontainer: bridge returned nil for ExecWait") - } - waitEnv, werr := decodeEnvelope[execWaitData](waitRaw) - if werr != nil { - return runtime.ExecResult{}, werr - } - - result := runtime.ExecResult{ - ExitCode: int(waitEnv.decoded.ExitCode), - } - if opts.Stdout == nil { - result.Stdout = stdoutBuf.String() - } - if opts.Stderr == nil { - result.Stderr = stderrBuf.String() - } - return result, copyErr -} - -// ---- pipe pair management ------------------------------------------ - -// execPipes holds the three pipe pairs we may need for an exec. Each -// entry is nil if that stream is unused (no stdin requested, or TTY -// mode for stderr). The "apiserver-facing" end is the one passed to -// the bridge and closed locally after start; the "Go-facing" end is -// the one we read/write. -type execPipes struct { - stdinRead, stdinWrite *os.File - stdoutRead, stdoutWrite *os.File - stderrRead, stderrWrite *os.File -} - -func openExecPipes(opts runtime.ExecOptions) (*execPipes, error) { - p := &execPipes{} - cleanup := func() { p.closeLocal(); p.closeRemoteEnds() } - - if opts.Stdin != nil { - r, w, err := os.Pipe() - if err != nil { - return nil, fmt.Errorf("pipe(stdin): %w", err) - } - p.stdinRead, p.stdinWrite = r, w - } - - // We always open stdout — capture or stream. The caller can't - // opt out; suppression is up to the in-VM process. - { - r, w, err := os.Pipe() - if err != nil { - cleanup() - return nil, fmt.Errorf("pipe(stdout): %w", err) - } - p.stdoutRead, p.stdoutWrite = r, w - } - - // Stderr is suppressed in TTY mode (Apple merges into stdout). - if !opts.Tty { - r, w, err := os.Pipe() - if err != nil { - cleanup() - return nil, fmt.Errorf("pipe(stderr): %w", err) - } - p.stderrRead, p.stderrWrite = r, w - } - - return p, nil -} - -func (p *execPipes) stdinReadFd() int { return fdOrMinusOne(p.stdinRead) } -func (p *execPipes) stdoutWriteFd() int { return fdOrMinusOne(p.stdoutWrite) } -func (p *execPipes) stderrWriteFd() int { return fdOrMinusOne(p.stderrWrite) } - -func (p *execPipes) stdinWriter() *os.File { return p.stdinWrite } -func (p *execPipes) stdoutReader() *os.File { return p.stdoutRead } -func (p *execPipes) stderrReader() *os.File { return p.stderrRead } - -// closeRemoteEnds closes the apiserver-facing ends. Called right -// after ac_exec_start succeeds, since XPC has dup'd those fds. -func (p *execPipes) closeRemoteEnds() { - closeIf(p.stdinRead) - p.stdinRead = nil - closeIf(p.stdoutWrite) - p.stdoutWrite = nil - closeIf(p.stderrWrite) - p.stderrWrite = nil -} - -// closeLocal closes everything still open. Safe to call multiple -// times. -func (p *execPipes) closeLocal() { - closeIf(p.stdinRead) - closeIf(p.stdinWrite) - closeIf(p.stdoutRead) - closeIf(p.stdoutWrite) - closeIf(p.stderrRead) - closeIf(p.stderrWrite) - p.stdinRead, p.stdinWrite = nil, nil - p.stdoutRead, p.stdoutWrite = nil, nil - p.stderrRead, p.stderrWrite = nil, nil -} - -func closeIf(f *os.File) { - if f != nil { - _ = f.Close() - } -} - -func fdOrMinusOne(f *os.File) int { - if f == nil { - return -1 - } - return int(f.Fd()) -} - -func pickWriter(w io.Writer, fallback io.Writer) io.Writer { - if w == nil { - return fallback - } - return w -} - -func isBrokenPipe(err error) bool { - if err == nil { - return false - } - // errors.Is matches syscall.EPIPE on the standard path; the - // string fallback covers wrapped/wrapped-and-formatted variants - // that some library boundaries produce. - if errors.Is(err, syscall.EPIPE) { - return true - } - msg := err.Error() - return strings.Contains(msg, "broken pipe") || - strings.Contains(msg, "file already closed") -} diff --git a/runtime/applecontainer/exec_darwin_arm64_test.go b/runtime/applecontainer/exec_darwin_arm64_test.go deleted file mode 100644 index adcdeab..0000000 --- a/runtime/applecontainer/exec_darwin_arm64_test.go +++ /dev/null @@ -1,189 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -import ( - "bytes" - "context" - "errors" - "strings" - "testing" - "time" - - "github.com/crunchloop/devcontainer/runtime" -) - -// runningContainer sets up a long-lived alpine container the test -// function can exec into. Cleans up automatically. -func runningContainer(t *testing.T, id string) *Runtime { - t.Helper() - rt := runtimeOrSkip(t) - ctx := context.Background() - - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - t.Cleanup(func() { - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - }) - - cliRunStrict(t, - "run", "--rm", "--name", "ac-alpine-warmup", - "docker.io/library/alpine:latest", "/bin/true", - ) - - if _, err := rt.RunContainer(ctx, runtime.RunSpec{ - Image: "docker.io/library/alpine:latest", - Name: id, - Cmd: []string{"sleep", "180"}, - }); err != nil { - t.Fatalf("RunContainer: %v", err) - } - if err := rt.StartContainer(ctx, id); err != nil { - t.Fatalf("StartContainer: %v", err) - } - if err := waitForState(t, rt, id, runtime.StateRunning, 5*time.Second); err != nil { - t.Fatalf("waitForState running: %v", err) - } - return rt -} - -// TestExec_CaptureStdoutAndExit covers the bread-and-butter path: -// no stdin, captured stdout, exit code propagation. -func TestExec_CaptureStdoutAndExit(t *testing.T) { - rt := runningContainer(t, "ac-exec-capture") - ctx := context.Background() - - res, err := rt.ExecContainer(ctx, "ac-exec-capture", runtime.ExecOptions{ - Cmd: []string{"/bin/sh", "-c", "echo hello-stdout; echo hello-stderr 1>&2; exit 7"}, - }) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if res.ExitCode != 7 { - t.Errorf("ExitCode: want 7 got %d", res.ExitCode) - } - if !strings.Contains(res.Stdout, "hello-stdout") { - t.Errorf("Stdout: want contains %q, got %q", "hello-stdout", res.Stdout) - } - if !strings.Contains(res.Stderr, "hello-stderr") { - t.Errorf("Stderr: want contains %q, got %q", "hello-stderr", res.Stderr) - } -} - -// TestExec_StdinRoundTrip pipes a payload through cat and verifies -// the bidirectional pipe wiring. -func TestExec_StdinRoundTrip(t *testing.T) { - rt := runningContainer(t, "ac-exec-stdin") - ctx := context.Background() - - const payload = "ping-pong-marker-42" - res, err := rt.ExecContainer(ctx, "ac-exec-stdin", runtime.ExecOptions{ - Cmd: []string{"/bin/cat"}, - Stdin: strings.NewReader(payload), - }) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if res.ExitCode != 0 { - t.Errorf("ExitCode: want 0 got %d", res.ExitCode) - } - if !strings.Contains(res.Stdout, payload) { - t.Errorf("Stdout: want contains %q, got %q", payload, res.Stdout) - } -} - -// TestExec_StreamingWriters bypasses the captured-string fallback to -// exercise the io.Writer streaming path (which is what DAP's readiness -// probe + workd attach will use in production). -func TestExec_StreamingWriters(t *testing.T) { - rt := runningContainer(t, "ac-exec-stream") - ctx := context.Background() - - var outBuf, errBuf bytes.Buffer - res, err := rt.ExecContainer(ctx, "ac-exec-stream", runtime.ExecOptions{ - Cmd: []string{"/bin/sh", "-c", "echo stream-out; echo stream-err 1>&2"}, - Stdout: &outBuf, - Stderr: &errBuf, - }) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if res.ExitCode != 0 { - t.Errorf("ExitCode: want 0 got %d", res.ExitCode) - } - // Captured fields stay empty when caller provides writers — this - // is the documented contract on runtime.ExecOptions. - if res.Stdout != "" || res.Stderr != "" { - t.Errorf("captured fields should be empty when writers provided; got Stdout=%q Stderr=%q", - res.Stdout, res.Stderr) - } - if !strings.Contains(outBuf.String(), "stream-out") { - t.Errorf("stdout buffer: want contains stream-out, got %q", outBuf.String()) - } - if !strings.Contains(errBuf.String(), "stream-err") { - t.Errorf("stderr buffer: want contains stream-err, got %q", errBuf.String()) - } -} - -// TestExec_ContextCancelKillsProcess is the design §11.3 question. -// A long-running process is launched, ctx is cancelled after a beat, -// and we assert ExecContainer returns ctx.Err() promptly (not after -// the process's natural timeout). -func TestExec_ContextCancelKillsProcess(t *testing.T) { - rt := runningContainer(t, "ac-exec-cancel") - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Spawn `sleep 60` then cancel after 500ms. ExecContainer should - // return well before 60s (we give it 10s tolerance for the - // SIGTERM round-trip + apiserver scheduling). - done := make(chan struct{}) - var execErr error - start := time.Now() - go func() { - defer close(done) - _, execErr = rt.ExecContainer(ctx, "ac-exec-cancel", runtime.ExecOptions{ - Cmd: []string{"/bin/sleep", "60"}, - }) - }() - - time.Sleep(500 * time.Millisecond) - cancel() - - select { - case <-done: - case <-time.After(15 * time.Second): - t.Fatal("ExecContainer did not return within 15s of ctx cancel — SIGTERM may not be propagating") - } - - elapsed := time.Since(start) - if elapsed > 12*time.Second { - t.Errorf("ExecContainer took %v after cancel; expected ≪ sleep timeout", elapsed) - } - if !errors.Is(execErr, context.Canceled) { - t.Errorf("ExecContainer returned err %v; want context.Canceled", execErr) - } -} - -// TestExec_EnvAndCwd verifies the per-call env and workingDir -// overrides land in the in-VM process. Engine relies on this for -// devcontainer.json's remoteEnv + workspaceFolder. -func TestExec_EnvAndCwd(t *testing.T) { - rt := runningContainer(t, "ac-exec-env") - ctx := context.Background() - - res, err := rt.ExecContainer(ctx, "ac-exec-env", runtime.ExecOptions{ - Cmd: []string{"/bin/sh", "-c", "echo MY=$MY_VAR PWD=$(pwd)"}, - Env: map[string]string{"MY_VAR": "set-from-exec", "PATH": "/usr/bin:/bin"}, - WorkingDir: "/tmp", - }) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if !strings.Contains(res.Stdout, "MY=set-from-exec") { - t.Errorf("env not honored: stdout=%q", res.Stdout) - } - if !strings.Contains(res.Stdout, "PWD=/tmp") { - t.Errorf("workingDir not honored: stdout=%q", res.Stdout) - } -} diff --git a/runtime/applecontainer/inspect_darwin_arm64.go b/runtime/applecontainer/inspect_darwin_arm64.go deleted file mode 100644 index 902e1dd..0000000 --- a/runtime/applecontainer/inspect_darwin_arm64.go +++ /dev/null @@ -1,435 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -/* -#include -#include "shim.h" -*/ -import "C" - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - "unsafe" - - "github.com/crunchloop/devcontainer/runtime" -) - -// ---- envelope ------------------------------------------------------- - -// envelope is the canonical wire shape the bridge returns for inspect -// and find exports. Documented in applecontainer-bridge/include/ -// ac_bridge.h §header-comment style guide. Failure populates Err; -// success populates Data with the export-specific payload. -type envelope[T any] struct { - OK bool `json:"ok"` - Err string `json:"err"` - Code string `json:"code,omitempty"` - Data json.RawMessage `json:"data"` - // Decoded payload — populated by decodeEnvelope on success. - decoded T -} - -func decodeEnvelope[T any](raw string) (envelope[T], error) { - var env envelope[T] - if err := json.Unmarshal([]byte(raw), &env); err != nil { - return env, fmt.Errorf("applecontainer: malformed bridge response %q: %w", raw, err) - } - if !env.OK { - return env, errors.New(env.Err) - } - // Allow null `data` (find-by-label miss). - if len(env.Data) > 0 && string(env.Data) != "null" { - if err := json.Unmarshal(env.Data, &env.decoded); err != nil { - return env, fmt.Errorf("applecontainer: malformed bridge data %q: %w", string(env.Data), err) - } - } - return env, nil -} - -func goStringAndFree(c *C.char) string { - if c == nil { - return "" - } - s := C.GoString(c) - C.ac_free_p(unsafe.Pointer(c)) - return s -} - -// ---- Apple wire types ------------------------------------------------ - -// containerSnapshot mirrors the JSON shape Apple's -// ContainerSnapshot.Codable emits. Only the fields we use are listed; -// extras are ignored by encoding/json. -type containerSnapshot struct { - Configuration containerConfiguration `json:"configuration"` - Status string `json:"status"` - StartedDate *time.Time `json:"startedDate,omitempty"` - Networks []containerNetworkAttach `json:"networks,omitempty"` -} - -// containerNetworkAttach mirrors the per-attached-network shape on -// Apple's ContainerSnapshot.networks list. We only need the IPv4 -// address; Apple emits it as a CIDR string ("192.168.66.2/24"). -type containerNetworkAttach struct { - IPv4Address string `json:"ipv4Address"` -} - -type containerConfiguration struct { - ID string `json:"id"` - Image imageDescription `json:"image"` - Mounts []containerMount `json:"mounts"` - Labels map[string]string `json:"labels"` - InitProcess containerInitProcess `json:"initProcess"` -} - -type imageDescription struct { - Reference string `json:"reference"` -} - -type containerInitProcess struct { - Environment []string `json:"environment"` - WorkingDirectory string `json:"workingDirectory"` - User json.RawMessage `json:"user"` -} - -// containerMount mirrors the subset of Apple's Filesystem we need for -// MountInspect. Apple's Filesystem.type is a Codable enum-with- -// associated-values rendered as `{"virtiofs":{}}` / `{"tmpfs":{}}` / -// `{"block":{...}}` / `{"volume":{...}}`; mountTypeWire decodes -// either that object shape or a bare string so the rest of the file -// keeps treating Type as a kind tag. -type containerMount struct { - Type mountTypeWire `json:"type"` - Source string `json:"source"` - Destination string `json:"destination"` - Options []string `json:"options"` -} - -// mountTypeWire decodes Apple's FSType. Always lands as the variant -// name in lowercase ("virtiofs", "tmpfs", "block", "volume", ...). -type mountTypeWire string - -func (m *mountTypeWire) UnmarshalJSON(data []byte) error { - // Compatibility path: an older bridge or a test fixture might emit - // the kind as a plain string ("virtiofs"). Accept it first. - var asString string - if err := json.Unmarshal(data, &asString); err == nil { - *m = mountTypeWire(asString) - return nil - } - // Object form. The single key is the variant name; the value is - // the associated-values payload, which we don't need yet. - var asObject map[string]json.RawMessage - if err := json.Unmarshal(data, &asObject); err != nil { - return fmt.Errorf("mountTypeWire: %w (raw=%s)", err, string(data)) - } - for k := range asObject { - *m = mountTypeWire(k) - return nil - } - return errors.New("mountTypeWire: empty FSType object") -} - -type imageInspectPayload struct { - Reference string `json:"reference"` - Digest string `json:"digest"` - Architecture string `json:"architecture"` - OS string `json:"os"` - User string `json:"user"` - Env []string `json:"env"` - Labels map[string]string `json:"labels"` -} - -// ---- mapping helpers ------------------------------------------------- - -// mountKindToRuntime maps Apple's FSType variant name to the canonical -// runtime.MountType string. Apple uses `virtiofs` for bind-style host -// mounts; we surface that as "bind" since callers (engine, tests) -// reason about mounts in Docker-style terms. -func mountKindToRuntime(kind string) string { - switch kind { - case "virtiofs": - return string(runtime.MountBind) - case "tmpfs": - return string(runtime.MountTmpfs) - case "volume": - return string(runtime.MountVolume) - default: - return kind - } -} - -func containsOption(opts []string, target string) bool { - for _, o := range opts { - if o == target { - return true - } - } - return false -} - -func mapState(s string) runtime.State { - switch s { - case "running": - return runtime.StateRunning - case "stopping": - return runtime.StateRemoving - case "stopped": - return runtime.StateExited - default: - return "" - } -} - -func snapshotToContainer(s containerSnapshot) *runtime.Container { - return &runtime.Container{ - ID: s.Configuration.ID, - Name: s.Configuration.ID, // Apple has no separate name field; id doubles - Image: s.Configuration.Image.Reference, - State: mapState(s.Status), - } -} - -func snapshotToDetails(s containerSnapshot) *runtime.ContainerDetails { - mounts := make([]runtime.MountInspect, 0, len(s.Configuration.Mounts)) - for _, m := range s.Configuration.Mounts { - mounts = append(mounts, runtime.MountInspect{ - Type: mountKindToRuntime(string(m.Type)), - Source: m.Source, - Target: m.Destination, - ReadOnly: containsOption(m.Options, "ro"), - }) - } - var startedAt time.Time - if s.StartedDate != nil { - startedAt = *s.StartedDate - } - // Compose orchestrator looks up the container's primary IPv4 - // address through the well-known label key - // `dev.containers.network-ip` (see compose.Orchestrator's - // /etc/hosts patch). Apple's ContainerSnapshot exposes the - // address under networks[].ipv4Address in CIDR form - // ("192.168.66.2/24"); we strip the prefix and stash it in the - // labels map so the orchestrator doesn't need a typed network - // field on runtime.ContainerDetails. Labels we synthesize - // never override user-set labels with the same key. - labels := s.Configuration.Labels - if ip := primaryIPv4(s.Networks); ip != "" { - if labels == nil { - labels = map[string]string{} - } - if _, exists := labels["dev.containers.network-ip"]; !exists { - labels["dev.containers.network-ip"] = ip - } - } - - return &runtime.ContainerDetails{ - Container: *snapshotToContainer(s), - StartedAt: startedAt, - User: decodeUserString(s.Configuration.InitProcess.User), - Env: s.Configuration.InitProcess.Environment, - Mounts: mounts, - Labels: labels, - // Created / FinishedAt / ExitCode are not in Apple's - // ContainerSnapshot. Left as zero values; later PRs can - // surface them via an additional XPC call if exposed. - // - // Privileged / CapAdd / SecurityOpt likewise have no - // equivalent in Apple's snapshot (the VM-isolation model - // doesn't expose docker-style HostConfig security flags), so - // they stay at zero values. - } -} - -// primaryIPv4 returns the first non-empty network attachment's IP -// stripped of its CIDR prefix. Apple's ContainerSnapshot -// typically reports a single attachment per container. -func primaryIPv4(nets []containerNetworkAttach) string { - for _, n := range nets { - if n.IPv4Address == "" { - continue - } - if i := strings.Index(n.IPv4Address, "/"); i > 0 { - return n.IPv4Address[:i] - } - return n.IPv4Address - } - return "" -} - -// decodeUserString turns Apple's ProcessConfiguration.User Codable -// representation into a single string. The Codable shape is either -// {"raw":{"userString":"..."}} or {"id":{"uid":N,"gid":N}}; either way -// the description renders to "user[:group]" or "uid:gid". -func decodeUserString(raw json.RawMessage) string { - if len(raw) == 0 { - return "" - } - var pair struct { - Raw *struct { - UserString string `json:"userString"` - } `json:"raw"` - ID *struct { - UID uint32 `json:"uid"` - GID uint32 `json:"gid"` - } `json:"id"` - } - if err := json.Unmarshal(raw, &pair); err != nil { - return "" - } - if pair.Raw != nil { - return pair.Raw.UserString - } - if pair.ID != nil { - return fmt.Sprintf("%d:%d", pair.ID.UID, pair.ID.GID) - } - return "" -} - -// ---- Runtime methods ------------------------------------------------- - -// InspectContainer fetches a snapshot from the apiserver and projects -// it into our runtime.ContainerDetails. Returns -// *runtime.ContainerNotFoundError when the daemon reports "not found". -func (r *Runtime) InspectContainer(ctx context.Context, id string) (*runtime.ContainerDetails, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := ensureLoaded(); err != nil { - return nil, err - } - cID := C.CString(id) - defer C.free(unsafe.Pointer(cID)) - raw := goStringAndFree(C.ac_inspect_container_p(cID)) - if raw == "" { - return nil, errors.New("applecontainer: bridge returned nil for InspectContainer") - } - env, err := decodeEnvelope[containerSnapshot](raw) - if err != nil { - return nil, mapInspectErr(id, err) - } - return snapshotToDetails(env.decoded), nil -} - -// InspectImage fetches OCI config metadata for a locally-cached image. -// Critical path: caller reads env.Labels["devcontainer.metadata"] to -// short-circuit feature installs against pre-baked images. -func (r *Runtime) InspectImage(ctx context.Context, ref string) (*runtime.ImageDetails, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := ensureLoaded(); err != nil { - return nil, err - } - cRef := C.CString(ref) - defer C.free(unsafe.Pointer(cRef)) - raw := goStringAndFree(C.ac_inspect_image_p(cRef)) - if raw == "" { - return nil, errors.New("applecontainer: bridge returned nil for InspectImage") - } - env, err := decodeEnvelope[imageInspectPayload](raw) - if err != nil { - return nil, mapImageInspectErr(ref, err) - } - p := env.decoded - return &runtime.ImageDetails{ - ID: p.Digest, - Tags: []string{p.Reference}, - Labels: p.Labels, - Env: p.Env, - User: p.User, - }, nil -} - -// FindContainerByLabel returns the most recently started container -// whose configuration.labels[key] == value, or nil if no match. -func (r *Runtime) FindContainerByLabel(ctx context.Context, key, value string) (*runtime.Container, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := ensureLoaded(); err != nil { - return nil, err - } - cKey := C.CString(key) - defer C.free(unsafe.Pointer(cKey)) - cVal := C.CString(value) - defer C.free(unsafe.Pointer(cVal)) - raw := goStringAndFree(C.ac_find_container_by_label_p(cKey, cVal)) - if raw == "" { - return nil, errors.New("applecontainer: bridge returned nil for FindContainerByLabel") - } - env, err := decodeEnvelope[containerSnapshot](raw) - if err != nil { - return nil, err - } - // `data: null` means "no match" — env.Data starts as a nil - // RawMessage in that case and decodedEnvelope skipped decoding, so - // env.decoded is the zero ContainerSnapshot. Detect by checking - // the parsed configuration id. - if env.decoded.Configuration.ID == "" { - return nil, nil - } - return snapshotToContainer(env.decoded), nil -} - -// ---- error mapping --------------------------------------------------- - -func mapInspectErr(id string, err error) error { - if err == nil { - return nil - } - msg := err.Error() - // Apple's not-found errors look like: - // `notFound: "container with ID not found"` - // or contain "not found" — match loosely so future wording shifts - // don't drop us out of the typed-error contract. - if containsAny(msg, "notFound", "not found") { - return &runtime.ContainerNotFoundError{ID: id, Err: err} - } - return err -} - -func mapImageInspectErr(ref string, err error) error { - if err == nil { - return nil - } - msg := err.Error() - if containsAny(msg, "notFound", "not found") { - return &runtime.ImageNotFoundError{Ref: ref, Err: err} - } - return err -} - -func containsAny(s string, subs ...string) bool { - for _, sub := range subs { - if indexOf(s, sub) >= 0 { - return true - } - } - return false -} - -// indexOf is strings.Contains/strings.Index minus the import — keeps -// this file's deps narrow (and avoids re-importing strings for this -// one use). Inline implementation, O(n*m), fine for our short error -// strings. -func indexOf(s, sub string) int { - if len(sub) == 0 { - return 0 - } - if len(sub) > len(s) { - return -1 - } - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return i - } - } - return -1 -} diff --git a/runtime/applecontainer/inspect_darwin_arm64_test.go b/runtime/applecontainer/inspect_darwin_arm64_test.go deleted file mode 100644 index bc8cf9c..0000000 --- a/runtime/applecontainer/inspect_darwin_arm64_test.go +++ /dev/null @@ -1,184 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -import ( - "context" - "errors" - "os/exec" - "strings" - "testing" - - "github.com/crunchloop/devcontainer/runtime" -) - -// runtimeOrSkip returns a Runtime if the daemon is up, skipping -// otherwise. Keeps each smoke test boilerplate-free. -func runtimeOrSkip(t *testing.T) *Runtime { - t.Helper() - rt, err := New(context.Background(), Options{PingTimeoutSeconds: 3}) - if err != nil { - var unavail *runtime.DaemonUnavailableError - if errors.As(err, &unavail) { - t.Skipf("daemon not reachable: %v", err) - } - t.Fatalf("New: %v", err) - } - return rt -} - -// cliRun is a best-effort wrapper around the `container` CLI used to -// seed and tear down containers for these smoke tests. Errors are -// logged in verbose mode but not fatal — cleanup paths use this. -func cliRun(t *testing.T, args ...string) { - t.Helper() - out, err := exec.Command("container", args...).CombinedOutput() - if err != nil && testing.Verbose() { - t.Logf("container %v -> %v\n%s", args, err, strings.TrimSpace(string(out))) - } -} - -// cliRunStrict logs full output unconditionally and fails the test -// on a non-zero exit. Skips (not fatals) only when the `container` -// binary itself is missing — that's an env-setup mismatch, not a -// regression. Any other non-zero exit is a real failure we want to -// see, not silently bypass. -func cliRunStrict(t *testing.T, args ...string) { - t.Helper() - out, err := exec.Command("container", args...).CombinedOutput() - if err == nil { - return - } - if errors.Is(err, exec.ErrNotFound) { - t.Skipf("`container` CLI not on PATH: %v", err) - } - t.Fatalf("`container %v` failed: %v\n%s", args, err, strings.TrimSpace(string(out))) -} - -// TestInspectContainer_RoundTrip seeds a container with a known label -// via the CLI, then inspects it through the bridge and asserts the -// label and status round-trip. -func TestInspectContainer_RoundTrip(t *testing.T) { - rt := runtimeOrSkip(t) - const id = "ac-inspect-test" - cliRun(t, "delete", "--force", id) - t.Cleanup(func() { cliRun(t, "delete", "--force", id) }) - - cliRunStrict(t, - "run", "-d", "--name", id, - "--label", "dev.containers.id=test-marker-42", - "docker.io/library/alpine:latest", - "sleep", "120", - ) - - details, err := rt.InspectContainer(context.Background(), id) - if err != nil { - t.Fatalf("InspectContainer: %v", err) - } - if details.ID != id { - t.Errorf("ID: want %q got %q", id, details.ID) - } - if details.State != runtime.StateRunning { - t.Errorf("State: want %q got %q", runtime.StateRunning, details.State) - } - if got := details.Labels["dev.containers.id"]; got != "test-marker-42" { - t.Errorf("Labels[dev.containers.id]: want %q got %q (all labels: %v)", - "test-marker-42", got, details.Labels) - } -} - -// TestInspectContainer_NotFound asserts the typed-error contract. -func TestInspectContainer_NotFound(t *testing.T) { - rt := runtimeOrSkip(t) - _, err := rt.InspectContainer(context.Background(), "ac-no-such-container-xyz") - if err == nil { - t.Fatal("InspectContainer: want error, got nil") - } - var nf *runtime.ContainerNotFoundError - if !errors.As(err, &nf) { - t.Fatalf("want *ContainerNotFoundError, got %T: %v", err, err) - } -} - -// TestInspectImage_LabelsRoundTrip pulls alpine and verifies the -// inspect surface returns the expected OS/arch fields. We don't check -// for specific labels because alpine doesn't ship any, but we DO -// verify Labels is at least a non-nil map (or empty) so the -// devcontainer.metadata fast path has predictable behavior against -// real images. -func TestInspectImage_LabelsRoundTrip(t *testing.T) { - rt := runtimeOrSkip(t) - // `container images pull` requires a plugin that isn't always - // installed locally. Instead, ensure alpine is in the local image - // cache by running a throwaway container (which fetches the image - // implicitly if missing). The image stays cached after the - // container is removed. - cliRunStrict(t, - "run", "--rm", "--name", "ac-alpine-warmup", - "docker.io/library/alpine:latest", "/bin/true", - ) - - details, err := rt.InspectImage(context.Background(), "docker.io/library/alpine:latest") - if err != nil { - t.Fatalf("InspectImage: %v", err) - } - if details.ID == "" { - t.Errorf("ID (digest) is empty") - } - if len(details.Tags) == 0 { - t.Errorf("Tags is empty") - } - if details.Labels == nil { - t.Errorf("Labels is nil; bridge guarantees a non-nil map even when the image has no labels") - } - t.Logf("alpine: digest=%s tags=%v user=%q env=%v labels=%v", - details.ID, details.Tags, details.User, details.Env, details.Labels) -} - -// TestInspectImage_NotFound asserts the typed-error contract. -func TestInspectImage_NotFound(t *testing.T) { - rt := runtimeOrSkip(t) - _, err := rt.InspectImage(context.Background(), "docker.io/library/no-such-image-xyz:0") - if err == nil { - t.Fatal("InspectImage: want error, got nil") - } - var nf *runtime.ImageNotFoundError - if !errors.As(err, &nf) { - t.Logf("InspectImage non-found-but-not-typed err: %T %v", err, err) - t.Fatalf("want *ImageNotFoundError, got %T: %v", err, err) - } -} - -// TestFindContainerByLabel covers both the hit and miss paths. -func TestFindContainerByLabel(t *testing.T) { - rt := runtimeOrSkip(t) - const id = "ac-findbylabel-test" - cliRun(t, "delete", "--force", id) - t.Cleanup(func() { cliRun(t, "delete", "--force", id) }) - - cliRunStrict(t, - "run", "-d", "--name", id, - "--label", "dev.containers.id=findme-99", - "docker.io/library/alpine:latest", - "sleep", "120", - ) - - hit, err := rt.FindContainerByLabel(context.Background(), "dev.containers.id", "findme-99") - if err != nil { - t.Fatalf("FindContainerByLabel(hit): %v", err) - } - if hit == nil { - t.Fatal("FindContainerByLabel(hit): want container, got nil") - } - if hit.ID != id { - t.Errorf("FindContainerByLabel(hit): want id %q got %q", id, hit.ID) - } - - miss, err := rt.FindContainerByLabel(context.Background(), "dev.containers.id", "does-not-exist") - if err != nil { - t.Fatalf("FindContainerByLabel(miss): %v", err) - } - if miss != nil { - t.Errorf("FindContainerByLabel(miss): want nil, got %+v", miss) - } -} diff --git a/runtime/applecontainer/lifecycle_darwin_arm64.go b/runtime/applecontainer/lifecycle_darwin_arm64.go deleted file mode 100644 index ed0a606..0000000 --- a/runtime/applecontainer/lifecycle_darwin_arm64.go +++ /dev/null @@ -1,292 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -/* -#include -#include "shim.h" -*/ -import "C" - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "unsafe" - - "github.com/crunchloop/devcontainer/runtime" -) - -// runSpecJSON is the wire shape sent to the bridge. Fields are kept -// in lockstep with applecontainer-bridge/Sources/ACBridge/lifecycle.swift's -// RunSpecJSON. Anything we silently drop on this backend -// (RunArgs, Privileged, SecurityOpt) is intentionally absent here, so -// the build fails if a caller starts depending on those fields without -// updating the design doc. -type runSpecJSON struct { - Image string `json:"image"` - ID string `json:"id"` - Cmd []string `json:"cmd,omitempty"` - Entrypoint []string `json:"entrypoint,omitempty"` - User string `json:"user,omitempty"` - WorkingDir string `json:"workingDir,omitempty"` - Env []string `json:"env,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Mounts []mountJSON `json:"mounts,omitempty"` - Networks []string `json:"networks,omitempty"` - InitProcess bool `json:"initProcess,omitempty"` - CapAdd []string `json:"capAdd,omitempty"` - OverrideCommand bool `json:"overrideCommand,omitempty"` - MemoryBytes int64 `json:"memoryBytes,omitempty"` - NanoCPUs int64 `json:"nanoCPUs,omitempty"` -} - -type mountJSON struct { - Type string `json:"type"` - Source string `json:"source,omitempty"` - Target string `json:"target"` - ReadOnly bool `json:"readOnly,omitempty"` -} - -type runResultData struct { - ID string `json:"id"` -} - -// RunContainer creates a container from a RunSpec, returning the -// resulting handle. Apple's split is create→bootstrap→start; this -// method only does create, matching runtime.Runtime's contract that -// callers invoke StartContainer separately so the engine can write -// idempotency markers between phases. -func (r *Runtime) RunContainer(ctx context.Context, spec runtime.RunSpec) (*runtime.Container, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := ensureLoaded(); err != nil { - return nil, err - } - if err := rejectUnsupportedRunSpec(spec); err != nil { - return nil, err - } - wire := runSpecToWire(spec) - if wire.ID == "" { - return nil, errors.New("applecontainer: RunSpec.Name is required (used as container id)") - } - if wire.Image == "" { - return nil, errors.New("applecontainer: RunSpec.Image is required") - } - specBytes, err := json.Marshal(wire) - if err != nil { - return nil, fmt.Errorf("applecontainer: marshal RunSpec: %w", err) - } - cSpec := C.CString(string(specBytes)) - defer C.free(unsafe.Pointer(cSpec)) - - raw := goStringAndFree(C.ac_run_p(cSpec)) - if raw == "" { - return nil, errors.New("applecontainer: bridge returned nil for RunContainer") - } - env, err := decodeEnvelope[runResultData](raw) - if err != nil { - return nil, mapRunErr(spec.Image, err) - } - return &runtime.Container{ - ID: env.decoded.ID, - Name: env.decoded.ID, - Image: spec.Image, - State: runtime.StateCreated, - }, nil -} - -// StartContainer bootstraps and starts a previously created container. -// Idempotent against already-running containers (the bridge short- -// circuits when the snapshot reports running). -func (r *Runtime) StartContainer(ctx context.Context, id string) error { - if err := ctx.Err(); err != nil { - return err - } - if err := ensureLoaded(); err != nil { - return err - } - cID := C.CString(id) - defer C.free(unsafe.Pointer(cID)) - raw := goStringAndFree(C.ac_start_p(cID)) - if raw == "" { - return errors.New("applecontainer: bridge returned nil for StartContainer") - } - if _, err := decodeEnvelope[json.RawMessage](raw); err != nil { - return mapLifecycleErr(id, err) - } - return nil -} - -// StopContainer sends SIGTERM with a grace period, then SIGKILL. Apple's -// stop API takes an Int32 grace period in seconds; we round nanos up -// and clamp. -func (r *Runtime) StopContainer(ctx context.Context, id string, opts runtime.StopOptions) error { - if err := ctx.Err(); err != nil { - return err - } - if err := ensureLoaded(); err != nil { - return err - } - var graceSec int32 - if opts.Timeout > 0 { - // Round up to whole seconds, clamp to MaxInt32 in the unlikely - // case of a wildly-large timeout. - secs := (opts.Timeout.Nanoseconds() + 999_999_999) / 1_000_000_000 - if secs > 1<<31-1 { - secs = 1<<31 - 1 - } - graceSec = int32(secs) - } - cID := C.CString(id) - defer C.free(unsafe.Pointer(cID)) - raw := goStringAndFree(C.ac_stop_p(cID, C.int32_t(graceSec))) - if raw == "" { - return errors.New("applecontainer: bridge returned nil for StopContainer") - } - if _, err := decodeEnvelope[json.RawMessage](raw); err != nil { - return mapLifecycleErr(id, err) - } - return nil -} - -// RemoveContainer deletes a container. Force=true allows deletion of -// running containers; RemoveVolumes is silently dropped on this -// backend (volume lifecycle isn't yet wired up — see PR-C scope cuts -// in design §8). -func (r *Runtime) RemoveContainer(ctx context.Context, id string, opts runtime.RemoveOptions) error { - if err := ctx.Err(); err != nil { - return err - } - if err := ensureLoaded(); err != nil { - return err - } - var force C.int32_t - if opts.Force { - force = 1 - } - cID := C.CString(id) - defer C.free(unsafe.Pointer(cID)) - raw := goStringAndFree(C.ac_delete_p(cID, force)) - if raw == "" { - return errors.New("applecontainer: bridge returned nil for RemoveContainer") - } - if _, err := decodeEnvelope[json.RawMessage](raw); err != nil { - return mapLifecycleErr(id, err) - } - return nil -} - -// rejectUnsupportedRunSpec fails fast on RunSpec fields that this -// backend cannot honor. Documented in design §8 as not modeled on -// Apple's `container` runtime; rather than silently dropping them -// (which would let callers observe apparent success with the option -// ignored), we surface a typed UnsupportedOptionError before crossing -// the cgo boundary. -func rejectUnsupportedRunSpec(spec runtime.RunSpec) error { - if len(spec.RunArgs) > 0 { - return &runtime.UnsupportedOptionError{Backend: "applecontainer", Option: "RunArgs"} - } - if spec.Privileged { - return &runtime.UnsupportedOptionError{Backend: "applecontainer", Option: "Privileged"} - } - if len(spec.SecurityOpt) > 0 { - return &runtime.UnsupportedOptionError{Backend: "applecontainer", Option: "SecurityOpt"} - } - return nil -} - -// runSpecToWire projects runtime.RunSpec onto the JSON shape the -// bridge expects. Fields the apple-container backend does not support -// (RunArgs, Privileged, SecurityOpt) are rejected by -// rejectUnsupportedRunSpec before reaching this function, so they -// don't appear in the wire type at all. -func runSpecToWire(spec runtime.RunSpec) runSpecJSON { - out := runSpecJSON{ - Image: spec.Image, - ID: spec.Name, - Cmd: spec.Cmd, - Entrypoint: spec.Entrypoint, - User: spec.User, - WorkingDir: spec.WorkingDir, - Env: envMapToSlice(spec.Env), - Labels: spec.Labels, - Mounts: mapMounts(spec.Mounts), - Networks: append([]string(nil), spec.Networks...), - InitProcess: spec.Init, - CapAdd: spec.CapAdd, - OverrideCommand: spec.OverrideCommand, - MemoryBytes: spec.MemoryBytes, - NanoCPUs: spec.NanoCPUs, - } - return out -} - -func envMapToSlice(m map[string]string) []string { - if len(m) == 0 { - return nil - } - out := make([]string, 0, len(m)) - for k, v := range m { - out = append(out, k+"="+v) - } - return out -} - -func mapMounts(in []runtime.MountSpec) []mountJSON { - if len(in) == 0 { - return nil - } - out := make([]mountJSON, 0, len(in)) - for _, m := range in { - out = append(out, mountJSON{ - Type: mountTypeToWire(m.Type), - Source: m.Source, - Target: m.Target, - ReadOnly: m.ReadOnly, - }) - } - return out -} - -func mountTypeToWire(t runtime.MountType) string { - switch t { - case runtime.MountBind: - return "bind" - case runtime.MountTmpfs: - return "tmpfs" - case runtime.MountVolume: - return "volume" - default: - return string(t) - } -} - -func mapRunErr(image string, err error) error { - if err == nil { - return nil - } - msg := err.Error() - // Apple emits `notFound: "image with reference "` from - // ClientImage.get; less-specific paths might surface "no such - // image" / "no metadata for image" / "image not found". Match - // loosely so wording shifts don't silently drop the typed-error - // contract. - if containsAny(msg, "image with reference", "image not found", "no such image", "no metadata for image") { - return &runtime.ImageNotFoundError{Ref: image, Err: err} - } - return err -} - -func mapLifecycleErr(id string, err error) error { - if err == nil { - return nil - } - msg := err.Error() - if containsAny(msg, "notFound", "not found") { - return &runtime.ContainerNotFoundError{ID: id, Err: err} - } - return err -} diff --git a/runtime/applecontainer/lifecycle_darwin_arm64_test.go b/runtime/applecontainer/lifecycle_darwin_arm64_test.go deleted file mode 100644 index 990f357..0000000 --- a/runtime/applecontainer/lifecycle_darwin_arm64_test.go +++ /dev/null @@ -1,266 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/crunchloop/devcontainer/runtime" -) - -// TestLifecycle_EndToEnd exercises the full PR-C surface: -// -// Run → Start → Inspect (running) → Stop → Inspect (stopped) → Remove -// -// Validates the create/start split + the JSON wire shape + -// integration with PR-B's InspectContainer. Skips when the daemon is -// down. -func TestLifecycle_EndToEnd(t *testing.T) { - rt := runtimeOrSkip(t) - ctx := context.Background() - - const id = "ac-lifecycle-e2e" - // Pre-clean leftover from a previous failed run. - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - t.Cleanup(func() { - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - }) - - // Ensure alpine is locally cached (RunContainer requires it). - cliRunStrict(t, - "run", "--rm", "--name", "ac-alpine-warmup", - "docker.io/library/alpine:latest", "/bin/true", - ) - - created, err := rt.RunContainer(ctx, runtime.RunSpec{ - Image: "docker.io/library/alpine:latest", - Name: id, - Cmd: []string{"sleep", "120"}, - Env: map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"}, - Labels: map[string]string{ - "dev.containers.id": "lifecycle-test-7", - "dev.containers.engine": "devcontainer-go/test", - }, - }) - if err != nil { - t.Fatalf("RunContainer: %v", err) - } - if created.ID != id { - t.Errorf("created.ID: want %q got %q", id, created.ID) - } - if created.State != runtime.StateCreated { - t.Errorf("created.State: want %q got %q", runtime.StateCreated, created.State) - } - - if err := rt.StartContainer(ctx, id); err != nil { - t.Fatalf("StartContainer: %v", err) - } - - // Give the apiserver a beat to flip status; the snapshot reflects - // the latest known state of the runtime status finite-state machine. - if err := waitForState(t, rt, id, runtime.StateRunning, 5*time.Second); err != nil { - t.Fatalf("waiting for running: %v", err) - } - - // Inspect should now see the running container with our labels. - details, err := rt.InspectContainer(ctx, id) - if err != nil { - t.Fatalf("InspectContainer (running): %v", err) - } - if got := details.Labels["dev.containers.id"]; got != "lifecycle-test-7" { - t.Errorf("Labels[dev.containers.id]: want %q got %q", "lifecycle-test-7", got) - } - - // Idempotent start: calling again on a running container should - // no-op (matches CLI behavior + Docker semantics). - if err := rt.StartContainer(ctx, id); err != nil { - t.Errorf("StartContainer (idempotent): %v", err) - } - - if err := rt.StopContainer(ctx, id, runtime.StopOptions{Timeout: 3 * time.Second}); err != nil { - t.Fatalf("StopContainer: %v", err) - } - - if err := waitForState(t, rt, id, runtime.StateExited, 5*time.Second); err != nil { - t.Fatalf("waiting for stopped: %v", err) - } - - if err := rt.RemoveContainer(ctx, id, runtime.RemoveOptions{}); err != nil { - t.Fatalf("RemoveContainer: %v", err) - } - - // Post-remove inspect should now return ContainerNotFoundError. - if _, err := rt.InspectContainer(ctx, id); err == nil { - t.Error("InspectContainer after Remove: want error, got nil") - } else { - var nf *runtime.ContainerNotFoundError - if !errors.As(err, &nf) { - t.Errorf("InspectContainer after Remove: want *ContainerNotFoundError, got %T: %v", err, err) - } - } -} - -// TestRunContainer_MissingImage asserts the typed-error contract for -// the load-bearing case: a caller asks us to create a container against -// an image that hasn't been pulled. -func TestRunContainer_MissingImage(t *testing.T) { - rt := runtimeOrSkip(t) - _, err := rt.RunContainer(context.Background(), runtime.RunSpec{ - Image: "docker.io/library/does-not-exist-zzz:0", - Name: "ac-missing-image", - Cmd: []string{"/bin/true"}, - }) - if err == nil { - t.Fatal("RunContainer: want error, got nil") - } - var nf *runtime.ImageNotFoundError - if !errors.As(err, &nf) { - t.Logf("err: %T %v", err, err) - t.Fatalf("want *ImageNotFoundError, got %T", err) - } -} - -// TestRunContainer_BindMount creates a container with a virtiofs bind -// mount and verifies the inspect path round-trips it. Doesn't run the -// container — just exercises the mount-spec wiring. -func TestRunContainer_BindMount(t *testing.T) { - rt := runtimeOrSkip(t) - ctx := context.Background() - const id = "ac-bindmount-test" - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - t.Cleanup(func() { - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - }) - - hostDir := t.TempDir() - - cliRunStrict(t, - "run", "--rm", "--name", "ac-alpine-warmup", - "docker.io/library/alpine:latest", "/bin/true", - ) - - _, err := rt.RunContainer(ctx, runtime.RunSpec{ - Image: "docker.io/library/alpine:latest", - Name: id, - Cmd: []string{"sleep", "60"}, - Mounts: []runtime.MountSpec{ - {Type: runtime.MountBind, Source: hostDir, Target: "/mnt/work", ReadOnly: false}, - }, - }) - if err != nil { - t.Fatalf("RunContainer with bind: %v", err) - } - - details, err := rt.InspectContainer(ctx, id) - if err != nil { - t.Fatalf("InspectContainer: %v", err) - } - // Apple normalizes the source via URL(fileURLWithPath:).absolutePath(), - // which canonically appends a trailing slash for directories. Match - // with TrimRight to absorb that without becoming hostage to the - // quirk in case it changes. - var found bool - for _, m := range details.Mounts { - if m.Target == "/mnt/work" && - strings.TrimRight(m.Source, "/") == strings.TrimRight(hostDir, "/") { - found = true - } - } - if !found { - t.Errorf("bind mount not round-tripped; details.Mounts=%+v want target=/mnt/work source=%q", - details.Mounts, hostDir) - } -} - -// TestRunContainer_NamedVolume creates a container with a named volume -// mount and verifies the inspect path reports a MountVolume entry -// pointing at the volume's backing image (not a virtiofs bind against -// the launcher CWD). Regression guard for the bug where named volumes -// fell through to virtiofs and bootstrapped with errno 2. -func TestRunContainer_NamedVolume(t *testing.T) { - rt := runtimeOrSkip(t) - ctx := context.Background() - const ( - id = "ac-namedvol-test" - volume = "ac-namedvol-test-vol" - ) - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - cliRun(t, "volume", "rm", volume) - t.Cleanup(func() { - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - cliRun(t, "volume", "rm", volume) - }) - - cliRunStrict(t, "volume", "create", volume) - cliRunStrict(t, - "run", "--rm", "--name", "ac-alpine-warmup-vol", - "docker.io/library/alpine:latest", "/bin/true", - ) - - _, err := rt.RunContainer(ctx, runtime.RunSpec{ - Image: "docker.io/library/alpine:latest", - Name: id, - Cmd: []string{"sleep", "60"}, - Mounts: []runtime.MountSpec{ - {Type: runtime.MountVolume, Source: volume, Target: "/mnt/data"}, - }, - }) - if err != nil { - t.Fatalf("RunContainer with named volume: %v", err) - } - - details, err := rt.InspectContainer(ctx, id) - if err != nil { - t.Fatalf("InspectContainer: %v", err) - } - cwd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd: %v", err) - } - cwd = filepath.Clean(cwd) - var found bool - for _, m := range details.Mounts { - if m.Target == "/mnt/data" && m.Type == string(runtime.MountVolume) { - found = true - // The apiserver substitutes the volume's on-disk image - // path as the canonical source. We don't pin the exact - // path (changes across apple/container releases) but it - // must not be empty and must not be the launcher CWD — - // the original bug had a non-empty source rooted at the - // launcher's working directory (apple's virtiofs path - // resolution), so empty-source alone wouldn't catch it. - if m.Source == "" { - t.Errorf("named volume mount has empty source; expected backing path") - } - if strings.HasPrefix(filepath.Clean(m.Source), cwd+string(filepath.Separator)) { - t.Errorf("named volume mount source rooted at launcher CWD: source=%q cwd=%q", m.Source, cwd) - } - } - } - if !found { - t.Errorf("named volume mount not round-tripped; details.Mounts=%+v want target=/mnt/data type=volume", - details.Mounts) - } -} - -// waitForState polls InspectContainer until the desired state is -// observed or the timeout fires. Apple's runtime status transitions -// asynchronously through the apiserver event loop. -func waitForState(t *testing.T, rt *Runtime, id string, want runtime.State, timeout time.Duration) error { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - d, err := rt.InspectContainer(context.Background(), id) - if err == nil && d.State == want { - return nil - } - time.Sleep(150 * time.Millisecond) - } - return errors.New("timeout waiting for state " + string(want)) -} diff --git a/runtime/applecontainer/logs_darwin_arm64.go b/runtime/applecontainer/logs_darwin_arm64.go deleted file mode 100644 index 939cdb2..0000000 --- a/runtime/applecontainer/logs_darwin_arm64.go +++ /dev/null @@ -1,128 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -/* -#include -#include "shim.h" -*/ -import "C" - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "time" - "unsafe" -) - -type logsOpenData struct { - FD int32 `json:"fd"` -} - -// ContainerLogs streams the container's stdio log to w. The -// underlying log file is on disk; non-follow reads to EOF and -// returns. Follow polls after EOF for new data and keeps streaming -// until ctx is cancelled — closing the fd from a watcher goroutine -// unblocks the read. -// -// Boot logs (index 1 from Apple's logs API) are not surfaced. -func (r *Runtime) ContainerLogs(ctx context.Context, id string, w io.Writer, follow bool) error { - if err := ctx.Err(); err != nil { - return err - } - if w == nil { - return errors.New("applecontainer: log writer is nil") - } - if err := ensureLoaded(); err != nil { - return err - } - cID := C.CString(id) - defer C.free(unsafe.Pointer(cID)) - raw := goStringAndFree(C.ac_logs_open_p(cID)) - if raw == "" { - return errors.New("applecontainer: bridge returned nil for ContainerLogs") - } - env, err := decodeEnvelope[logsOpenData](raw) - if err != nil { - return mapLifecycleErr(id, err) - } - if env.decoded.FD < 0 { - return errors.New("applecontainer: bridge returned invalid log fd") - } - file := os.NewFile(uintptr(env.decoded.FD), "applecontainer-logs") - if file == nil { - return errors.New("applecontainer: os.NewFile failed for log fd") - } - defer file.Close() - - // ctx-watcher: closing the file unblocks any in-flight Read. - // Use a Once so the file isn't closed twice (defer also runs). - stop := make(chan struct{}) - go func() { - select { - case <-ctx.Done(): - _ = file.Close() - case <-stop: - } - }() - defer close(stop) - - return copyLogs(ctx, file, w, follow) -} - -// copyLogs reads from src to w. In follow mode, it sleeps briefly on -// zero-byte reads (EOF on a still-open regular file means "no new -// data yet"). Always returns ctx.Err() if ctx is cancelled, even if -// the underlying file close manifests as an EBADF / "file already -// closed" error. -func copyLogs(ctx context.Context, src *os.File, dst io.Writer, follow bool) error { - buf := make([]byte, 32*1024) - pollInterval := 200 * time.Millisecond - for { - n, err := src.Read(buf) - if n > 0 { - // io.Writer is allowed to short-write without returning - // an error; loop until the chunk is fully drained so we - // never silently drop log bytes. - off := 0 - for off < n { - written, werr := dst.Write(buf[off:n]) - if werr != nil { - return fmt.Errorf("applecontainer: log writer: %w", werr) - } - if written == 0 { - return fmt.Errorf("applecontainer: log writer: %w", io.ErrShortWrite) - } - off += written - } - } - if err == nil { - continue - } - if errors.Is(err, io.EOF) || err == io.EOF { - if !follow { - return nil - } - // Wait for new data, but bail if ctx is done. We can't - // use a single timer that resets per loop without making - // the close-on-cancel race tricky; the sleep-poll is - // simple and bounded. - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(pollInterval): - } - continue - } - // Read errored — either ctx cancellation triggered a close - // or the apiserver did something unexpected. Prefer ctx.Err() - // if cancelled; that's the contract caller wired up. - if cErr := ctx.Err(); cErr != nil { - return cErr - } - return fmt.Errorf("applecontainer: log read: %w", err) - } -} diff --git a/runtime/applecontainer/logs_darwin_arm64_test.go b/runtime/applecontainer/logs_darwin_arm64_test.go deleted file mode 100644 index a10a0a1..0000000 --- a/runtime/applecontainer/logs_darwin_arm64_test.go +++ /dev/null @@ -1,191 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -import ( - "bytes" - "context" - "errors" - "strings" - "sync" - "testing" - "time" - - "github.com/crunchloop/devcontainer/runtime" -) - -// chattyContainer runs an alpine container whose init process emits -// known output on a known cadence. Lets us assert on the log stream -// shape without poking at non-deterministic kernel/runtime chatter. -func chattyContainer(t *testing.T, id, script string) *Runtime { - t.Helper() - rt := runtimeOrSkip(t) - ctx := context.Background() - - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - t.Cleanup(func() { - _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) - }) - - cliRunStrict(t, - "run", "--rm", "--name", "ac-alpine-warmup", - "docker.io/library/alpine:latest", "/bin/true", - ) - - if _, err := rt.RunContainer(ctx, runtime.RunSpec{ - Image: "docker.io/library/alpine:latest", - Name: id, - Cmd: []string{"/bin/sh", "-c", script}, - }); err != nil { - t.Fatalf("RunContainer: %v", err) - } - if err := rt.StartContainer(ctx, id); err != nil { - t.Fatalf("StartContainer: %v", err) - } - if err := waitForState(t, rt, id, runtime.StateRunning, 5*time.Second); err != nil { - t.Fatalf("waitForState: %v", err) - } - return rt -} - -// TestLogs_NonFollow asserts the non-follow path reads everything -// emitted so far and returns. -func TestLogs_NonFollow(t *testing.T) { - const id = "ac-logs-nonfollow" - // Emit two short lines then sleep so the log is bounded. - rt := chattyContainer(t, id, "echo hello-from-logs-1; echo hello-from-logs-2; sleep 60") - - // Poll under a bounded timeout until both markers appear (or fail). - // Avoids a flaky fixed sleep on slow log-flush and an unbounded - // context.Background() on regressions. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var ( - buf bytes.Buffer - got string - ) - deadline := time.Now().Add(3 * time.Second) - for { - buf.Reset() - if err := rt.ContainerLogs(ctx, id, &buf, false); err != nil { - t.Fatalf("ContainerLogs: %v", err) - } - got = buf.String() - if strings.Contains(got, "hello-from-logs-1") && strings.Contains(got, "hello-from-logs-2") { - break - } - if time.Now().After(deadline) { - t.Fatalf("logs missing markers; got %q", got) - } - time.Sleep(100 * time.Millisecond) - } -} - -// TestLogs_FollowBlocksUntilCancel verifies follow mode keeps reading -// past EOF until ctx fires. Runs a container that emits a marker -// every second, then cancels after observing the first marker — we -// expect ContainerLogs to return ctx.Err() promptly. -func TestLogs_FollowBlocksUntilCancel(t *testing.T) { - const id = "ac-logs-follow" - rt := chattyContainer(t, id, - `i=0; while true; do echo follow-marker-$i; i=$((i+1)); sleep 1; done`, - ) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - pr, pw := newPipeWriter() - - var ( - wg sync.WaitGroup - logErr error - ) - wg.Add(1) - go func() { - defer wg.Done() - logErr = rt.ContainerLogs(ctx, id, pw, true) - _ = pw.Close() - }() - - // Block until we see the first marker line. - if err := pr.waitFor("follow-marker-", 8*time.Second); err != nil { - t.Fatalf("waiting for marker: %v (collected so far=%q)", err, pr.collected()) - } - - cancel() - - done := make(chan struct{}) - go func() { wg.Wait(); close(done) }() - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("ContainerLogs did not return within 5s of ctx cancel") - } - - if !errors.Is(logErr, context.Canceled) { - t.Errorf("logs err: want context.Canceled, got %v", logErr) - } -} - -// pipeWriter is a tiny in-memory streaming sink: a goroutine can -// write to it while another waits for a substring to appear. Avoids -// the racy hand-rolled bytes.Buffer + sleep loop most tests reach for. -type pipeWriter struct { - mu sync.Mutex - cond *sync.Cond - buf []byte - done bool -} - -func newPipeWriter() (*pipeWriter, *pipeWriter) { - p := &pipeWriter{} - p.cond = sync.NewCond(&p.mu) - return p, p -} - -func (p *pipeWriter) Write(b []byte) (int, error) { - p.mu.Lock() - defer p.mu.Unlock() - p.buf = append(p.buf, b...) - p.cond.Broadcast() - return len(b), nil -} - -func (p *pipeWriter) Close() error { - p.mu.Lock() - p.done = true - p.cond.Broadcast() - p.mu.Unlock() - return nil -} - -func (p *pipeWriter) waitFor(substr string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - p.mu.Lock() - defer p.mu.Unlock() - for !p.matchLocked(substr) { - if p.done { - return errors.New("writer closed before substring appeared") - } - now := time.Now() - if !now.Before(deadline) { - return errors.New("timeout waiting for substring") - } - // Brief wait then re-check. - p.mu.Unlock() - time.Sleep(50 * time.Millisecond) - p.mu.Lock() - } - return nil -} - -func (p *pipeWriter) matchLocked(s string) bool { - return bytes.Contains(p.buf, []byte(s)) -} - -func (p *pipeWriter) collected() string { - p.mu.Lock() - defer p.mu.Unlock() - return string(p.buf) -} diff --git a/runtime/applecontainer/pull_darwin_arm64.go b/runtime/applecontainer/pull_darwin_arm64.go deleted file mode 100644 index 727520b..0000000 --- a/runtime/applecontainer/pull_darwin_arm64.go +++ /dev/null @@ -1,77 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -/* -#include -#include "shim.h" -*/ -import "C" - -import ( - "context" - "errors" - "unsafe" - - "github.com/crunchloop/devcontainer/runtime" -) - -type pullResultData struct { - Reference string `json:"reference"` - Digest string `json:"digest"` -} - -// PullImage fetches an image from a remote registry. PR-F scope: -// synchronous pull, no in-flight progress events. The runtime.Runtime -// contract accepts a BuildEvent channel; we emit a "pulling" log -// event before the call and a "completed" event after, both -// non-blocking. Fine-grained progress is a future PR. -// -// Cancellation: ctx is checked at entry. Once the bridge call is -// in-flight, ctx cancellation is best-effort — Apple's pull API -// doesn't expose a cancellation token, so the underlying pull -// continues to completion even if ctx fires. -func (r *Runtime) PullImage(ctx context.Context, ref string, events chan<- runtime.BuildEvent) (runtime.ImageRef, error) { - if err := ctx.Err(); err != nil { - return runtime.ImageRef{}, err - } - if err := ensureLoaded(); err != nil { - return runtime.ImageRef{}, err - } - emitBuildEvent(events, runtime.BuildEvent{ - Kind: runtime.BuildEventLog, - Message: "applecontainer: pulling " + ref, - }) - - cRef := C.CString(ref) - defer C.free(unsafe.Pointer(cRef)) - raw := goStringAndFree(C.ac_pull_image_p(cRef)) - if raw == "" { - return runtime.ImageRef{}, errors.New("applecontainer: bridge returned nil for PullImage") - } - env, err := decodeEnvelope[pullResultData](raw) - if err != nil { - return runtime.ImageRef{}, mapImageInspectErr(ref, err) - } - emitBuildEvent(events, runtime.BuildEvent{ - Kind: runtime.BuildEventCompleted, - Digest: env.decoded.Digest, - Message: "applecontainer: pulled " + env.decoded.Reference, - }) - return runtime.ImageRef{ - ID: env.decoded.Digest, - Tags: []string{env.decoded.Reference}, - }, nil -} - -// emitBuildEvent is a non-blocking send. Per the BuildEvent contract, -// events are best-effort: a slow consumer doesn't gate progress. -func emitBuildEvent(ch chan<- runtime.BuildEvent, ev runtime.BuildEvent) { - if ch == nil { - return - } - select { - case ch <- ev: - default: - } -} diff --git a/runtime/applecontainer/pull_darwin_arm64_test.go b/runtime/applecontainer/pull_darwin_arm64_test.go deleted file mode 100644 index 1d1b3b9..0000000 --- a/runtime/applecontainer/pull_darwin_arm64_test.go +++ /dev/null @@ -1,105 +0,0 @@ -//go:build darwin && arm64 - -package applecontainer - -import ( - "context" - "errors" - "os/exec" - "strings" - "testing" - - "github.com/crunchloop/devcontainer/runtime" -) - -// TestPullImage_SmallPublic pulls a tiny public image. Skips if the -// daemon is unreachable. Verifies the ImageRef + the BuildEvent -// surface (one log + one completed event, both non-blocking). -// -// Pre-removes the image first so we test the real pull path rather -// than the local-cache hit (which Apple resolves without an upstream -// fetch). -func TestPullImage_SmallPublic(t *testing.T) { - rt := runtimeOrSkip(t) - ctx := context.Background() - const ref = "docker.io/library/alpine:latest" - - // Pre-cleanup: try to delete via CLI. Best-effort. - cliRun(t, "images", "rm", ref) - cliRun(t, "image", "rm", ref) - - events := make(chan runtime.BuildEvent, 16) - imageRef, err := rt.PullImage(ctx, ref, events) - close(events) - - if err != nil { - // If `container images rm` isn't available, the image may - // still be cached from earlier tests and pull will still - // succeed. If it actually fails, surface it. - t.Fatalf("PullImage: %v", err) - } - - if imageRef.ID == "" { - t.Errorf("ImageRef.ID is empty") - } - if len(imageRef.Tags) == 0 || imageRef.Tags[0] == "" { - t.Errorf("ImageRef.Tags: want non-empty, got %v", imageRef.Tags) - } - - // We accept anywhere from 1 (just completed, with the slow- - // consumer guard dropping the log) to 2 events. Just verify at - // least the completed event made it through. - var sawCompleted bool - for ev := range events { - if ev.Kind == runtime.BuildEventCompleted { - sawCompleted = true - if ev.Digest == "" { - t.Errorf("BuildEventCompleted.Digest is empty") - } - } - } - if !sawCompleted { - t.Errorf("BuildEventCompleted not emitted") - } -} - -// TestPullImage_NoSuchImage asserts the typed-error contract — must -// translate Apple's "notFound" into runtime.ImageNotFoundError so -// engine code can do typed dispatch. -func TestPullImage_NoSuchImage(t *testing.T) { - rt := runtimeOrSkip(t) - _, err := rt.PullImage(context.Background(), - "docker.io/library/does-not-exist-zzz-99:latest", nil) - if err == nil { - t.Fatal("PullImage: want error, got nil") - } - var nf *runtime.ImageNotFoundError - if !errors.As(err, &nf) { - // Some registry-level errors don't surface as "notFound" — - // network refusal, auth — and we don't want to falsely claim - // not-found in those cases. Accept any error here, but log - // the type so a real regression surfaces in CI output. - t.Logf("non-typed pull error (acceptable for genuine network failures): %T %v", err, err) - // Best-effort assertion: real "missing image" errors should - // reach this branch. If they don't, the test still passes - // because some valid network errors land here too — but the - // CI log will show which. - if isLikelyMissingImage(err) { - t.Errorf("error looks like a missing-image error but wasn't typed: %v", err) - } - } -} - -func isLikelyMissingImage(err error) bool { - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "not found") || - strings.Contains(msg, "notfound") || - strings.Contains(msg, "no such") -} - -// cliRun reused from inspect_darwin_arm64_test.go — best-effort -// `container ...` wrapper. Re-declared here would be a duplicate; -// the import of os/exec keeps this file self-contained for the cases -// where the helper isn't useful (e.g. when running this test file -// in isolation in IDEs that don't load sibling files). -var _ = exec.LookPath diff --git a/runtime/applecontainer/runtime_darwin_arm64.go b/runtime/applecontainer/runtime_darwin_arm64.go deleted file mode 100644 index 203ec99..0000000 --- a/runtime/applecontainer/runtime_darwin_arm64.go +++ /dev/null @@ -1,152 +0,0 @@ -//go:build darwin && arm64 - -// libACBridge.dylib is loaded at runtime via dlopen — see -// embed_darwin_arm64.go for the embed-and-extract mechanic. This file -// only links the cgo C shim (shim.c / shim.h) that wraps dlsym'd -// function pointers. No build-time dependency on the SwiftPM output -// directory; the dylib travels embedded in the binary. -package applecontainer - -/* -#include -#include "shim.h" -*/ -import "C" - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "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 extracts -// the embedded bridge dylib (idempotent, hashed cache file), dlopens -// it, then probes the daemon via ClientHealthCheck.ping. 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 - } - if err := ensureLoaded(); 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 - } - if err := ensureLoaded(); 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_p(C.int32_t(effective)) - if cstr == nil { - return nil, &runtime.DaemonUnavailableError{Err: errors.New("bridge returned nil")} - } - raw := C.GoString(cstr) - C.ac_free_p(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_p() - if cstr == nil { - return "" - } - defer C.ac_free_p(unsafe.Pointer(cstr)) - return C.GoString(cstr) -} - -// ---- runtime.Runtime method map ------------------------------------- - -// InspectContainer, InspectImage, FindContainerByLabel — PR-B -// (inspect_darwin_arm64.go). -// RunContainer, StartContainer, StopContainer, RemoveContainer — PR-C -// (lifecycle_darwin_arm64.go). -// ExecContainer — PR-D (exec_darwin_arm64.go). -// ContainerLogs — PR-E (logs_darwin_arm64.go). -// PullImage — PR-F (pull_darwin_arm64.go). -// BuildImage — PR-G (build_darwin_arm64.go, partial: builder probe + -// typed not-implemented error; full BuildKit wiring is a follow-up). diff --git a/runtime/applecontainer/runtime_darwin_arm64_test.go b/runtime/applecontainer/runtime_darwin_arm64_test.go deleted file mode 100644 index 81e0abb..0000000 --- a/runtime/applecontainer/runtime_darwin_arm64_test.go +++ /dev/null @@ -1,60 +0,0 @@ -//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 deleted file mode 100644 index c803364..0000000 --- a/runtime/applecontainer/runtime_unsupported.go +++ /dev/null @@ -1,33 +0,0 @@ -//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") -} diff --git a/runtime/applecontainer/shim.c b/runtime/applecontainer/shim.c deleted file mode 100644 index 7ec180b..0000000 --- a/runtime/applecontainer/shim.c +++ /dev/null @@ -1,251 +0,0 @@ -//go:build darwin && arm64 - -#include "shim.h" - -#include -#include -#include - -static const char* (*p_ac_version)(void) = NULL; -static const char* (*p_ac_ping)(int32_t) = NULL; -static void (*p_ac_free)(void*) = NULL; - -static const char* (*p_ac_inspect_container)(const char*) = NULL; -static const char* (*p_ac_inspect_image)(const char*) = NULL; -static const char* (*p_ac_find_container_by_label)(const char*, const char*) = NULL; - -static const char* (*p_ac_run)(const char*) = NULL; -static const char* (*p_ac_start)(const char*) = NULL; -static const char* (*p_ac_stop)(const char*, int32_t) = NULL; -static const char* (*p_ac_delete)(const char*, int32_t) = NULL; - -static const char* (*p_ac_exec_start)(const char*, const char*, int32_t, int32_t, int32_t) = NULL; -static const char* (*p_ac_exec_wait)(uint64_t, int32_t) = NULL; -static const char* (*p_ac_exec_signal)(uint64_t, int32_t) = NULL; -static void (*p_ac_exec_release)(uint64_t) = NULL; - -static const char* (*p_ac_logs_open)(const char*) = NULL; -static const char* (*p_ac_pull_image)(const char*) = NULL; -static const char* (*p_ac_build_probe)(void) = NULL; -static const char* (*p_ac_build)(const char*) = NULL; - -static const char* (*p_ac_network_create)(const char*) = NULL; -static const char* (*p_ac_network_remove)(const char*) = NULL; -static const char* (*p_ac_volume_create)(const char*) = NULL; -static const char* (*p_ac_volume_remove)(const char*) = NULL; -static const char* (*p_ac_list_containers)(void) = NULL; -static const char* (*p_ac_list_images)(void) = NULL; -static const char* (*p_ac_remove_image)(const char*) = NULL; - -static void copy_err(char* errbuf, size_t errlen, const char* msg) { - if (!errbuf || errlen == 0) { - return; - } - if (!msg) { - msg = "unknown"; - } - size_t n = strnlen(msg, errlen - 1); - memcpy(errbuf, msg, n); - errbuf[n] = '\0'; -} - -int ac_load(const char* path, char* errbuf, size_t errlen) { - if (!path) { - copy_err(errbuf, errlen, "null path"); - return -1; - } - // RTLD_LOCAL keeps the bridge's symbols isolated from the global - // namespace so we don't collide with anything else the Go binary - // might dlopen. - void* h = dlopen(path, RTLD_NOW | RTLD_LOCAL); - if (!h) { - copy_err(errbuf, errlen, dlerror()); - return -1; - } - - // Cast through a generic function pointer to silence the warning - // about converting void* (data) to a function-pointer type. - p_ac_version = (const char* (*)(void)) dlsym(h, "ac_version"); - p_ac_ping = (const char* (*)(int32_t)) dlsym(h, "ac_ping"); - p_ac_free = (void (*)(void*)) dlsym(h, "ac_free"); - p_ac_inspect_container = (const char* (*)(const char*)) dlsym(h, "ac_inspect_container"); - p_ac_inspect_image = (const char* (*)(const char*)) dlsym(h, "ac_inspect_image"); - p_ac_find_container_by_label = (const char* (*)(const char*, const char*)) dlsym(h, "ac_find_container_by_label"); - p_ac_run = (const char* (*)(const char*)) dlsym(h, "ac_run"); - p_ac_start = (const char* (*)(const char*)) dlsym(h, "ac_start"); - p_ac_stop = (const char* (*)(const char*, int32_t)) dlsym(h, "ac_stop"); - p_ac_delete = (const char* (*)(const char*, int32_t)) dlsym(h, "ac_delete"); - p_ac_exec_start = (const char* (*)(const char*, const char*, int32_t, int32_t, int32_t)) dlsym(h, "ac_exec_start"); - p_ac_exec_wait = (const char* (*)(uint64_t, int32_t)) dlsym(h, "ac_exec_wait"); - p_ac_exec_signal = (const char* (*)(uint64_t, int32_t)) dlsym(h, "ac_exec_signal"); - p_ac_exec_release = (void (*)(uint64_t)) dlsym(h, "ac_exec_release"); - p_ac_logs_open = (const char* (*)(const char*)) dlsym(h, "ac_logs_open"); - p_ac_pull_image = (const char* (*)(const char*)) dlsym(h, "ac_pull_image"); - p_ac_build_probe = (const char* (*)(void)) dlsym(h, "ac_build_probe"); - p_ac_build = (const char* (*)(const char*)) dlsym(h, "ac_build"); - p_ac_network_create = (const char* (*)(const char*)) dlsym(h, "ac_network_create"); - p_ac_network_remove = (const char* (*)(const char*)) dlsym(h, "ac_network_remove"); - p_ac_volume_create = (const char* (*)(const char*)) dlsym(h, "ac_volume_create"); - p_ac_volume_remove = (const char* (*)(const char*)) dlsym(h, "ac_volume_remove"); - p_ac_list_containers = (const char* (*)(void)) dlsym(h, "ac_list_containers"); - p_ac_list_images = (const char* (*)(void)) dlsym(h, "ac_list_images"); - p_ac_remove_image = (const char* (*)(const char*)) dlsym(h, "ac_remove_image"); - - if (!p_ac_version || !p_ac_ping || !p_ac_free - || !p_ac_inspect_container || !p_ac_inspect_image - || !p_ac_find_container_by_label - || !p_ac_run || !p_ac_start || !p_ac_stop || !p_ac_delete - || !p_ac_exec_start || !p_ac_exec_wait - || !p_ac_exec_signal || !p_ac_exec_release - || !p_ac_logs_open || !p_ac_pull_image - || !p_ac_build_probe || !p_ac_build - || !p_ac_network_create || !p_ac_network_remove - || !p_ac_volume_create || !p_ac_volume_remove - || !p_ac_list_containers || !p_ac_list_images || !p_ac_remove_image) { - const char* err = dlerror(); - copy_err(errbuf, errlen, err ? err : "dlsym returned null"); - // Reset any partial resolutions so a future retry sees a clean - // slate, and release the dlopen handle so the dylib refcount - // drops back to zero. - p_ac_version = NULL; - p_ac_ping = NULL; - p_ac_free = NULL; - p_ac_inspect_container = NULL; - p_ac_inspect_image = NULL; - p_ac_find_container_by_label = NULL; - p_ac_run = NULL; - p_ac_start = NULL; - p_ac_stop = NULL; - p_ac_delete = NULL; - p_ac_exec_start = NULL; - p_ac_exec_wait = NULL; - p_ac_exec_signal = NULL; - p_ac_exec_release = NULL; - p_ac_logs_open = NULL; - p_ac_pull_image = NULL; - p_ac_build_probe = NULL; - p_ac_build = NULL; - p_ac_network_create = NULL; - p_ac_network_remove = NULL; - p_ac_volume_create = NULL; - p_ac_volume_remove = NULL; - p_ac_list_containers = NULL; - p_ac_list_images = NULL; - p_ac_remove_image = NULL; - dlclose(h); - return -1; - } - return 0; -} - -const char* ac_version_p(void) { - return p_ac_version ? p_ac_version() : NULL; -} - -const char* ac_ping_p(int32_t t) { - return p_ac_ping ? p_ac_ping(t) : NULL; -} - -void ac_free_p(void* p) { - if (p_ac_free) { - p_ac_free(p); - } -} - -const char* ac_inspect_container_p(const char* id) { - return p_ac_inspect_container ? p_ac_inspect_container(id) : NULL; -} - -const char* ac_inspect_image_p(const char* reference) { - return p_ac_inspect_image ? p_ac_inspect_image(reference) : NULL; -} - -const char* ac_find_container_by_label_p(const char* key, const char* value) { - return p_ac_find_container_by_label ? p_ac_find_container_by_label(key, value) : NULL; -} - -const char* ac_run_p(const char* spec_json) { - return p_ac_run ? p_ac_run(spec_json) : NULL; -} - -const char* ac_start_p(const char* id) { - return p_ac_start ? p_ac_start(id) : NULL; -} - -const char* ac_stop_p(const char* id, int32_t timeout_seconds) { - return p_ac_stop ? p_ac_stop(id, timeout_seconds) : NULL; -} - -const char* ac_delete_p(const char* id, int32_t force) { - return p_ac_delete ? p_ac_delete(id, force) : NULL; -} - -const char* ac_exec_start_p( - const char* id, - const char* opts_json, - int32_t stdin_read_fd, - int32_t stdout_write_fd, - int32_t stderr_write_fd -) { - return p_ac_exec_start - ? p_ac_exec_start(id, opts_json, stdin_read_fd, stdout_write_fd, stderr_write_fd) - : NULL; -} - -const char* ac_exec_wait_p(uint64_t handle, int32_t timeout_seconds) { - return p_ac_exec_wait ? p_ac_exec_wait(handle, timeout_seconds) : NULL; -} - -const char* ac_exec_signal_p(uint64_t handle, int32_t signal) { - return p_ac_exec_signal ? p_ac_exec_signal(handle, signal) : NULL; -} - -void ac_exec_release_p(uint64_t handle) { - if (p_ac_exec_release) { - p_ac_exec_release(handle); - } -} - -const char* ac_logs_open_p(const char* id) { - return p_ac_logs_open ? p_ac_logs_open(id) : NULL; -} - -const char* ac_pull_image_p(const char* reference) { - return p_ac_pull_image ? p_ac_pull_image(reference) : NULL; -} - -const char* ac_build_probe_p(void) { - return p_ac_build_probe ? p_ac_build_probe() : NULL; -} - -const char* ac_build_p(const char* spec_json) { - return p_ac_build ? p_ac_build(spec_json) : NULL; -} - -const char* ac_network_create_p(const char* spec_json) { - return p_ac_network_create ? p_ac_network_create(spec_json) : NULL; -} - -const char* ac_network_remove_p(const char* id) { - return p_ac_network_remove ? p_ac_network_remove(id) : NULL; -} - -const char* ac_volume_create_p(const char* spec_json) { - return p_ac_volume_create ? p_ac_volume_create(spec_json) : NULL; -} - -const char* ac_volume_remove_p(const char* name) { - return p_ac_volume_remove ? p_ac_volume_remove(name) : NULL; -} - -const char* ac_list_containers_p(void) { - return p_ac_list_containers ? p_ac_list_containers() : NULL; -} - -const char* ac_list_images_p(void) { - return p_ac_list_images ? p_ac_list_images() : NULL; -} - -const char* ac_remove_image_p(const char* ref) { - return p_ac_remove_image ? p_ac_remove_image(ref) : NULL; -} diff --git a/runtime/applecontainer/shim.h b/runtime/applecontainer/shim.h deleted file mode 100644 index ffd6493..0000000 --- a/runtime/applecontainer/shim.h +++ /dev/null @@ -1,73 +0,0 @@ -#ifndef AC_SHIM_H -#define AC_SHIM_H - -#include -#include - -// ac_load opens the bridge dylib via dlopen and resolves every exported -// symbol via dlsym. Returns 0 on success, non-zero on failure. -// -// `path` is the absolute filesystem path to libACBridge.dylib (the -// caller is expected to have extracted the embedded bytes to a stable -// location first — see embed_darwin_arm64.go). -// -// On failure the shim writes a null-terminated error message into -// `errbuf` (truncated to `errlen-1` chars). On success errbuf is left -// untouched. -// -// Idempotency: calling ac_load more than once is undefined; the Go -// side guards via sync.Once. -int ac_load(const char* path, char* errbuf, size_t errlen); - -// Wrappers that call through the resolved function pointers. Returning -// NULL means either (a) the dylib has not been loaded yet (programmer -// error — must call ac_load first) or (b) the underlying export -// returned NULL. -// -// Contract / encoding / blocking semantics for each wrapped export -// live in applecontainer-bridge/include/ac_bridge.h. The `_p` suffix -// is purely a Go-side reminder that these go through the dlsym -// indirection. -const char* ac_version_p(void); -const char* ac_ping_p(int32_t timeout_seconds); -void ac_free_p(void* p); - -const char* ac_inspect_container_p(const char* id); -const char* ac_inspect_image_p(const char* reference); -const char* ac_find_container_by_label_p(const char* key, const char* value); - -const char* ac_run_p(const char* spec_json); -const char* ac_start_p(const char* id); -const char* ac_stop_p(const char* id, int32_t timeout_seconds); -const char* ac_delete_p(const char* id, int32_t force); - -const char* ac_exec_start_p( - const char* id, - const char* opts_json, - int32_t stdin_read_fd, - int32_t stdout_write_fd, - int32_t stderr_write_fd -); -const char* ac_exec_wait_p(uint64_t handle, int32_t timeout_seconds); -const char* ac_exec_signal_p(uint64_t handle, int32_t signal); -void ac_exec_release_p(uint64_t handle); - -const char* ac_logs_open_p(const char* id); - -const char* ac_pull_image_p(const char* reference); - -const char* ac_build_probe_p(void); -const char* ac_build_p(const char* spec_json); - -// Compose orchestrator primitives. The Swift counterparts live in -// applecontainer-bridge/Sources/ACBridge/networks.swift, -// volumes.swift, list.swift. -const char* ac_network_create_p(const char* spec_json); -const char* ac_network_remove_p(const char* id); -const char* ac_volume_create_p(const char* spec_json); -const char* ac_volume_remove_p(const char* name); -const char* ac_list_containers_p(void); -const char* ac_list_images_p(void); -const char* ac_remove_image_p(const char* ref); - -#endif diff --git a/runtime/compose_primitives.go b/runtime/compose_primitives.go index 6114251..1173ad9 100644 --- a/runtime/compose_primitives.go +++ b/runtime/compose_primitives.go @@ -5,7 +5,7 @@ package runtime // compose orchestrator (compose/) drives. The methods themselves are // declared on the Runtime interface in runtime.go; this file holds // the input/output shapes so backends can translate without leaking -// Docker-API or Apple-bridge types into the orchestrator. +// Docker-API types into the orchestrator. // // Naming follows the existing Spec/Details pattern: backends accept // *Spec inputs and return their backend ID or a typed error. @@ -27,8 +27,8 @@ type NetworkSpec struct { Labels map[string]string // Driver selects the backend's network driver. Empty string means - // "backend default" (bridge on docker; vmnet-based default on - // apple). Non-default drivers are out of scope for v1. + // "backend default" (bridge on docker). Non-default drivers are + // out of scope for v1. Driver string // Options is the driver-options string map (compose's @@ -50,7 +50,7 @@ type VolumeSpec struct { Labels map[string]string // Driver selects the backend's volume driver. Empty = backend - // default (local on docker; the file-backed driver on apple). + // default (local on docker). Driver string // Options is the driver-options string map. Pass-through. @@ -63,8 +63,8 @@ type VolumeSpec struct { type LabelFilter struct { // Match is the AND set: every key must be present on the // resource AND its value must equal the requested value. - // Implementations that lack server-side filtering (apple, per - // design probe R1b) translate this client-side after enumeration. + // Implementations that lack server-side filtering translate this + // client-side after enumeration. Match map[string]string } diff --git a/runtime/errors.go b/runtime/errors.go index a89483f..9816194 100644 --- a/runtime/errors.go +++ b/runtime/errors.go @@ -96,10 +96,10 @@ func (e *DaemonUnavailableError) Unwrap() error { return e.Err } // component is missing or not running. Distinct from // DaemonUnavailableError because the build engine is typically a // separate process / VM that can be started independently (e.g. -// Apple's `container builder start`, Docker's BuildKit daemon). +// Docker's BuildKit daemon). type BuilderUnavailableError struct { // Hint is a backend-specific message telling the user how to - // remediate (e.g. "run `container builder start`"). + // remediate (e.g. "start the BuildKit daemon"). Hint string Err error } diff --git a/runtime/runtime.go b/runtime/runtime.go index 26dadc6..667781f 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -168,8 +168,7 @@ type Runtime interface { // ListContainers returns containers matching every label in the // filter. Empty filter is rejected — we never want to enumerate // all containers. Implementations without server-side filtering - // (e.g. applecontainer per design probe R1b) filter client-side - // after a full enumeration. + // filter client-side after a full enumeration. ListContainers(ctx context.Context, filter LabelFilter) ([]Container, error) // ListImages returns local images matching the filter. Used by @@ -239,8 +238,8 @@ type ContainerDetails struct { // from the backend's inspect (docker HostConfig). Lets callers // verify that RunSpec.Privileged/CapAdd/SecurityOpt — including the // values merged from feature metadata onto compose services — landed - // on the real container. Backends that don't surface these (e.g. - // applecontainer) leave them at zero values. + // on the real container. Backends that don't surface these leave + // them at zero values. Privileged bool CapAdd []string SecurityOpt []string @@ -352,11 +351,10 @@ type RunSpec struct { HealthCheck *HealthCheckSpec // Networks lists project networks the container joins. Empty - // means "backend default" — docker assigns the default bridge; - // apple assigns the built-in vmnet network. Used by the compose - // orchestrator to attach services to the project network it - // just created via CreateNetwork. Mutually exclusive with - // NetworkMode. + // means "backend default" — docker assigns the default bridge. + // Used by the compose orchestrator to attach services to the + // project network it just created via CreateNetwork. Mutually + // exclusive with NetworkMode. Networks []string // NetworkMode, PidMode and IpcMode carry the container's kernel @@ -388,23 +386,18 @@ type RunSpec struct { OverrideCommand bool // MemoryBytes is the hard memory limit for the container, in bytes. - // Zero means "unset": the backend's own default applies — for docker - // that's no cgroup limit; for apple it's the apiserver's per-VM - // default (1 GiB on 0.12.x). Negative values are rejected by the - // backend. + // Zero means "unset": the backend's own default applies — for + // docker that's no cgroup limit. Negative values are rejected by + // the backend. // - // On apple, this sizes the per-container VM at boot; the guest - // kernel sees exactly this much memory and the value cannot be - // resized without container recreation. On docker, this maps to - // HostConfig.Memory and is enforced by cgroups. + // On docker, this maps to HostConfig.Memory and is enforced by + // cgroups. MemoryBytes int64 // NanoCPUs is the CPU limit expressed in nano-units: 1_000_000_000 // = one full CPU, 2_500_000_000 = 2.5 CPUs. Matches docker's // HostConfig.NanoCPUs convention so a single field works across - // backends. Zero means "unset". Apple's apiserver takes an integer - // CPU count, so the value is rounded up to the next whole CPU at - // the bridge boundary (e.g. 1_500_000_000 → 2 cpus). + // backends. Zero means "unset". NanoCPUs int64 } diff --git a/test/integration/applecontainer_build_source_test.go b/test/integration/applecontainer_build_source_test.go deleted file mode 100644 index 1b46257..0000000 --- a/test/integration/applecontainer_build_source_test.go +++ /dev/null @@ -1,123 +0,0 @@ -//go:build integration && darwin && arm64 - -// Apple-container backend: build-source devcontainers (no features). -// Newly enabled by PR-G2's full BuildKit integration. Replaces the -// PR-H stub (TestAppleContainer_BuildSource_DocumentsLimitation) for -// the happy path; the limitation test stays as a builder-not-running -// contract assertion. - -package integration - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - devcontainer "github.com/crunchloop/devcontainer" -) - -// TestAppleContainer_BuildSource_FullLifecycle is the basic -// build-source Up/Exec/Down: devcontainer.json declares -// `build: { dockerfile: ... }`, the engine builds via PR-G2's -// BuildImage, runs the resulting image, exec confirms a baked-in -// marker file is present. -func TestAppleContainer_BuildSource_FullLifecycle(t *testing.T) { - if testing.Short() { - t.Skip() - } - - eng, _ := newAppleContainerEngine(t) - - ws := t.TempDir() - dcDir := filepath.Join(ws, ".devcontainer") - if err := os.MkdirAll(dcDir, 0o755); err != nil { - t.Fatal(err) - } - if err := writeFile(filepath.Join(dcDir, "Dockerfile"), - "FROM docker.io/library/alpine:latest\nRUN echo built-by-bucket-a > /bucket-a-marker\n"); err != nil { - t.Fatal(err) - } - if err := writeFile(filepath.Join(dcDir, "devcontainer.json"), - `{"build":{"dockerfile":"Dockerfile"}}`); err != nil { - t.Fatal(err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"cat", "/bucket-a-marker"}, - }) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if got := strings.TrimSpace(res.Stdout); got != "built-by-bucket-a" { - t.Errorf("marker contents = %q, want %q", got, "built-by-bucket-a") - } -} - -// TestAppleContainer_BuildSource_BuildArgs verifies build args -// from devcontainer.json reach the Dockerfile. -func TestAppleContainer_BuildSource_BuildArgs(t *testing.T) { - if testing.Short() { - t.Skip() - } - - eng, _ := newAppleContainerEngine(t) - - ws := t.TempDir() - dcDir := filepath.Join(ws, ".devcontainer") - if err := os.MkdirAll(dcDir, 0o755); err != nil { - t.Fatal(err) - } - if err := writeFile(filepath.Join(dcDir, "Dockerfile"), - "FROM docker.io/library/alpine:latest\nARG MYARG=unset\nRUN echo $MYARG > /arg-marker\n"); err != nil { - t.Fatal(err) - } - if err := writeFile(filepath.Join(dcDir, "devcontainer.json"), `{ - "build": { - "dockerfile": "Dockerfile", - "args": {"MYARG": "value-from-config"} - } - }`); err != nil { - t.Fatal(err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"cat", "/arg-marker"}, - }) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if got := strings.TrimSpace(res.Stdout); got != "value-from-config" { - t.Errorf("$MYARG = %q, want %q (build args not plumbed)", got, "value-from-config") - } -} diff --git a/test/integration/applecontainer_compose_native_test.go b/test/integration/applecontainer_compose_native_test.go deleted file mode 100644 index 7447183..0000000 --- a/test/integration/applecontainer_compose_native_test.go +++ /dev/null @@ -1,150 +0,0 @@ -//go:build integration && darwin && arm64 - -// End-to-end compose source on the apple-container backend. The -// load-bearing question this file answers: can compose.Orchestrator -// (PR13) drive runtime/applecontainer (PR15) through a real Up + Exec -// cycle against the apple/container apiserver running on macOS? -// -// Refuses gracefully when the daemon isn't reachable. Assumes the -// apple builder isn't running (no `container builder start`) since -// our compose fixture uses image-only services — no feature builds -// triggered. -// -// Documented constraint: apple's networking has no built-in -// service-name DNS (design probe 3). The orchestrator patches -// /etc/hosts post-level so depends_on-declared edges resolve. -// Intra-level peers without an explicit edge can still race; this -// test gates `app` on `db` via depends_on to stay inside the -// supported semantic. - -package integration - -import ( - "context" - "path/filepath" - "strings" - "testing" - "time" - - devcontainer "github.com/crunchloop/devcontainer" -) - -// writeAppleComposeWorkspace builds a 2-service compose fixture -// suitable for the apple backend: alpine `app` long-sleeping + -// alpine `db` long-sleeping, with depends_on. No features (apple -// builder may not be running locally), no published ports -// (apple's vmnet on macOS 15 doesn't reliably surface them to the -// host anyway — irrelevant for the in-VM peer-resolution test). -func writeAppleComposeWorkspace(t *testing.T) string { - t.Helper() - dir := t.TempDir() - - mustWrite(t, filepath.Join(dir, "docker-compose.yml"), ` -services: - app: - image: docker.io/library/alpine:3.20 - command: ["sh", "-c", "while sleep 1000; do :; done"] - depends_on: - - db - db: - image: docker.io/library/alpine:3.20 - command: ["sh", "-c", "while sleep 1000; do :; done"] -`) - mustWrite(t, filepath.Join(dir, ".devcontainer", "devcontainer.json"), `{ - "dockerComposeFile": "../docker-compose.yml", - "service": "app", - "workspaceFolder": "/workspaces/proj" - }`) - return dir -} - -func TestAppleContainer_Compose_Native_FullFlow(t *testing.T) { - if testing.Short() { - t.Skip() - } - - // Reuse the apple-container engine constructor but layer in the - // ComposeBackend flag. Skips if the apiserver isn't running. - _, rt := newAppleContainerEngine(t) - eng, err := devcontainer.New(devcontainer.EngineOptions{ - Runtime: rt, - ComposeBackend: devcontainer.ComposeBackendNative, - }) - if err != nil { - t.Fatalf("devcontainer.New: %v", err) - } - - ws := writeAppleComposeWorkspace(t) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - SkipLifecycle: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{ - Remove: true, - RemoveVolumes: true, - }) - }() - - if wsObj.Container == nil { - t.Fatal("Workspace.Container is nil") - } - if got := wsObj.Container.Labels[devcontainer.LabelDevcontainerID]; got != string(wsObj.ID) { - t.Errorf("dev.containers.id label = %q, want %q", got, wsObj.ID) - } - if _, ok := wsObj.Container.Labels["com.docker.compose.project"]; !ok { - t.Errorf("compose project label missing; container.Labels = %v", wsObj.Container.Labels) - } - - // Diagnostics before the assertion. - if dump, derr := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"cat", "/etc/hosts"}, - }); derr == nil { - t.Logf("/etc/hosts content:\n%s", dump.Stdout) - } - - // /etc/hosts patch must have landed: `db` resolves from inside `app`. - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"sh", "-c", "getent ahosts db | head -1"}, - }) - if err != nil { - t.Fatalf("Exec lookup: %v", err) - } - if res.ExitCode != 0 || res.Stdout == "" { - t.Errorf("db not resolvable from app (hosts-patch failed?): exit=%d stdout=%q stderr=%q", - res.ExitCode, res.Stdout, res.Stderr) - } - - // Sentinel marker present too — defensive check that the patch - // went through ours, not some other mechanism. - res, err = eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"grep", "-q", "devcontainer-go compose hosts patch", "/etc/hosts"}, - }) - if err != nil { - t.Fatalf("Exec grep marker: %v", err) - } - if res.ExitCode != 0 { - t.Errorf("hosts-patch marker not found in /etc/hosts (stderr=%q)", res.Stderr) - } - - // Workspace bind mount applied. - res, err = eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"pwd"}, - WorkingDir: wsObj.Config.ContainerWorkspaceFolder, - }) - if err != nil { - t.Fatalf("Exec pwd: %v", err) - } - if !strings.Contains(res.Stdout, wsObj.Config.ContainerWorkspaceFolder) { - t.Errorf("pwd = %q, want containerWorkspaceFolder = %q", - res.Stdout, wsObj.Config.ContainerWorkspaceFolder) - } -} diff --git a/test/integration/applecontainer_features_test.go b/test/integration/applecontainer_features_test.go deleted file mode 100644 index 3f165a8..0000000 --- a/test/integration/applecontainer_features_test.go +++ /dev/null @@ -1,107 +0,0 @@ -//go:build integration && darwin && arm64 - -// Apple-container backend: features probe (A2). The feature pipeline -// is engine-level Go code (feature/* + engine.up's layerFeatures path) -// that ultimately calls runtime.BuildImage — which apple-container -// now has since PR-G2. The pipeline should "just work" on this -// backend. -// -// This file ships ONE probe test. If it passes, features work on -// apple-container with no further changes. If it fails, it fails the -// suite loudly — feature support is a design-level contract on this -// backend, so a regression here is a real bug, not a "known TODO". - -package integration - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - devcontainer "github.com/crunchloop/devcontainer" -) - -// TestAppleContainer_Features_LocalFeatureInstalls probes the feature -// pipeline end-to-end: image-source + a local feature that drops a -// marker file. Mirrors TestImageSource_WithLocalFeature in the docker -// suite. -func TestAppleContainer_Features_LocalFeatureInstalls(t *testing.T) { - if testing.Short() { - t.Skip() - } - - eng, _ := newAppleContainerEngine(t) - - dir := t.TempDir() - dcDir := filepath.Join(dir, ".devcontainer") - if err := os.MkdirAll(dcDir, 0o755); err != nil { - t.Fatal(err) - } - if err := writeFile(filepath.Join(dcDir, "devcontainer.json"), fmt.Sprintf(`{ - "image": "%s", - "features": { "./local-feature": {} } - }`, "docker.io/library/alpine:latest")); err != nil { - t.Fatal(err) - } - - featureDir := filepath.Join(dcDir, "local-feature") - if err := os.MkdirAll(featureDir, 0o755); err != nil { - t.Fatal(err) - } - if err := writeFile(filepath.Join(featureDir, "devcontainer-feature.json"), `{ - "id": "stamp", - "version": "1.0.0", - "containerEnv": { "STAMP": "from-apple-feature" } - }`); err != nil { - t.Fatal(err) - } - if err := writeFile(filepath.Join(featureDir, "install.sh"), `#!/bin/sh -set -e -echo apple-feature-ran > /etc/feature-marker -`); err != nil { - t.Fatal(err) - } - if err := os.Chmod(filepath.Join(featureDir, "install.sh"), 0o755); err != nil { - t.Fatal(err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: dir, - Recreate: true, - SkipLifecycle: true, - }) - if err != nil { - t.Fatalf("feature install failed on apple-container: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"cat", "/etc/feature-marker"}, - }) - if err != nil { - t.Fatalf("Exec feature-marker: %v", err) - } - if !strings.Contains(res.Stdout, "apple-feature-ran") { - t.Errorf("feature did not run on apple-container image-source path: %q", res.Stdout) - } - - // STAMP env from the feature should reach Exec. - res, err = eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"printenv", "STAMP"}, - }) - if err != nil { - t.Fatalf("Exec printenv STAMP: %v", err) - } - if got := strings.TrimSpace(res.Stdout); got != "from-apple-feature" { - t.Errorf("STAMP = %q, want %q (feature containerEnv didn't reach Exec)", got, "from-apple-feature") - } -} diff --git a/test/integration/applecontainer_image_metadata_test.go b/test/integration/applecontainer_image_metadata_test.go deleted file mode 100644 index e5ea67b..0000000 --- a/test/integration/applecontainer_image_metadata_test.go +++ /dev/null @@ -1,156 +0,0 @@ -//go:build integration && darwin && arm64 - -// Apple-container backend: image-metadata fast path. Builds an image -// carrying a devcontainer.metadata label, then exercises both the -// "metadata declares remoteUser" path and the "user override wins" -// path. Mirrors image_metadata_test.go for the docker backend. - -package integration - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - devcontainer "github.com/crunchloop/devcontainer" - "github.com/crunchloop/devcontainer/runtime" - "github.com/crunchloop/devcontainer/runtime/applecontainer" -) - -// buildAppleLabeledImage builds a small image carrying a -// devcontainer.metadata label + a baked-in non-root user. Uses our -// PR-G2 BuildImage path under the hood. -func buildAppleLabeledImage(t *testing.T, rt *applecontainer.Runtime, label string) string { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - dir := t.TempDir() - // Use docker.io/library/alpine:latest explicitly — Apple's image - // resolver doesn't apply a default registry like Docker's does. - df := `FROM docker.io/library/alpine:latest -RUN adduser -D -s /bin/sh vscode -LABEL devcontainer.metadata='` + label + `' -` - if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(df), 0o644); err != nil { - t.Fatal(err) - } - - tag := "dc-it-ac-metadata-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-") + ":latest" - - if _, err := rt.BuildImage(ctx, runtime.BuildSpec{ - ContextPath: dir, - Dockerfile: "Dockerfile", - Tag: tag, - }, nil); err != nil { - var unavail *runtime.BuilderUnavailableError - if isUnavailErr(err, &unavail) { - t.Skipf("builder not running (run `container builder start`): %v", err) - } - t.Fatalf("BuildImage: %v", err) - } - return tag -} - -// isUnavailErr is a tiny generic-ish wrapper around errors.As so the -// per-test boilerplate stays compact. Returns true if err wraps a -// *BuilderUnavailableError. -func isUnavailErr(err error, dst **runtime.BuilderUnavailableError) bool { - if err == nil { - return false - } - // Walk the chain via Unwrap; avoid importing errors twice. - for cur := err; cur != nil; { - if v, ok := cur.(*runtime.BuilderUnavailableError); ok { - *dst = v - return true - } - type unwrapper interface{ Unwrap() error } - u, ok := cur.(unwrapper) - if !ok { - break - } - cur = u.Unwrap() - } - return false -} - -func TestAppleContainer_ImageMetadata_RemoteUserHonored(t *testing.T) { - if testing.Short() { - t.Skip("integration tests skipped with -short") - } - - eng, rt := newAppleContainerEngine(t) - - tag := buildAppleLabeledImage(t, rt, - `[{"id":"common-utils","version":"2"},{"remoteUser":"vscode","containerUser":"vscode"}]`) - - ws := writeWorkspace(t, `{"image":"`+tag+`"}`) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"whoami"}, - }) - if err != nil { - t.Fatalf("Exec whoami: %v", err) - } - if res.ExitCode != 0 { - t.Fatalf("whoami exit=%d stderr=%q", res.ExitCode, res.Stderr) - } - if got := strings.TrimSpace(res.Stdout); got != "vscode" { - t.Errorf("whoami = %q, want %q (image-metadata remoteUser must reach Engine.Exec)", got, "vscode") - } -} - -func TestAppleContainer_ImageMetadata_UserOverrideWins(t *testing.T) { - if testing.Short() { - t.Skip() - } - - eng, rt := newAppleContainerEngine(t) - - tag := buildAppleLabeledImage(t, rt, - `[{"remoteUser":"vscode","containerUser":"vscode"}]`) - - ws := writeWorkspace(t, `{"image":"`+tag+`","remoteUser":"root"}`) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"whoami"}, - }) - if err != nil { - t.Fatalf("Exec whoami: %v", err) - } - if got := strings.TrimSpace(res.Stdout); got != "root" { - t.Errorf("whoami = %q, want %q (devcontainer.json remoteUser must beat image metadata)", got, "root") - } -} diff --git a/test/integration/applecontainer_image_source_test.go b/test/integration/applecontainer_image_source_test.go deleted file mode 100644 index eb4602f..0000000 --- a/test/integration/applecontainer_image_source_test.go +++ /dev/null @@ -1,340 +0,0 @@ -//go:build integration && darwin && arm64 - -// Apple-container backend integration tests. PR-H ships the -// image-source full-lifecycle test — Up, Exec, Down — and documents -// the subset of M2/M3 fixtures we expect to pass on this backend -// today. Build / features / compose / UID-reconcile are out of scope -// (see design/runtime-applecontainer.md §8, §9, §13.8) and will -// land alongside their respective Runtime methods (PR-G2 for build). -// -// To run: `make bridge && go test -tags=integration ./test/integration/...` -// Daemon prerequisite: `brew install container && container system start`. - -package integration - -import ( - "context" - "errors" - "os" - "strings" - "testing" - "time" - - devcontainer "github.com/crunchloop/devcontainer" - "github.com/crunchloop/devcontainer/runtime" - "github.com/crunchloop/devcontainer/runtime/applecontainer" -) - -func newAppleContainerEngine(t *testing.T) (*devcontainer.Engine, *applecontainer.Runtime) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - rt, err := applecontainer.New(ctx, applecontainer.Options{PingTimeoutSeconds: 5}) - if err != nil { - var unavail *runtime.DaemonUnavailableError - if errors.As(err, &unavail) { - t.Skipf("apple-container daemon unreachable (`container system start` required): %v", err) - } - t.Fatalf("applecontainer.New: %v", err) - } - eng, err := devcontainer.New(devcontainer.EngineOptions{Runtime: rt}) - if err != nil { - t.Fatalf("devcontainer.New: %v", err) - } - return eng, rt -} - -// TestAppleContainer_ImageSource_FullLifecycle proves the engine -// integration: Up an `image:` devcontainer through the apple-container -// backend, Exec, Down. Mirrors the M2 image-source test against -// runtime/docker (image_source_test.go). -func TestAppleContainer_ImageSource_FullLifecycle(t *testing.T) { - if testing.Short() { - t.Skip("integration tests skipped with -short") - } - - eng, _ := newAppleContainerEngine(t) - ws := writeWorkspace(t, `{ - "image": "docker.io/library/alpine:latest", - "containerEnv": { - "CUSTOM_VAR": "hello-from-apple-container" - } - }`) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - t.Logf("Up: %s", ws) - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - downCtx, downCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer downCancel() - if err := eng.Down(downCtx, wsObj, devcontainer.DownOptions{Remove: true}); err != nil { - t.Errorf("Down: %v", err) - } - }() - - // Exec a simple command and assert stdout + the engine-injected - // containerEnv variable both make it through. - execCtx, execCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer execCancel() - res, err := eng.Exec(execCtx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"/bin/sh", "-c", "echo marker; echo CV=$CUSTOM_VAR"}, - }) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if res.ExitCode != 0 { - t.Errorf("Exec exit: want 0 got %d (stderr=%q)", res.ExitCode, res.Stderr) - } - if !strings.Contains(res.Stdout, "marker") { - t.Errorf("Exec stdout missing marker; got %q", res.Stdout) - } - if !strings.Contains(res.Stdout, "CV=hello-from-apple-container") { - t.Errorf("Exec stdout missing containerEnv injection; got %q", res.Stdout) - } -} - -// TestAppleContainer_BuildSource_DocumentsLimitation asserts that -// build-source devcontainers fail with our typed BuilderUnavailableError -// or the "not yet implemented" error from PR-G (whichever applies). -// This is a contract test for the partial PR-G state — when PR-G2 -// lands the real build path, this test should be removed or flipped -// to assert success. -// (removed) TestAppleContainer_BuildSource_DocumentsLimitation — -// PR-H stub that asserted build-source devcontainers failed with a -// "not yet implemented" error. PR-G2 implemented the build path, so -// the happy-path replacement is TestAppleContainer_BuildSource_FullLifecycle -// in applecontainer_build_source_test.go. The builder-down typed-error -// path is still covered by TestBuildImage_BuilderDownTypedError in -// the runtime package's unit tests. - -// TestAppleContainer_ReattachStopped covers the Up → Down(no remove) -// → Up flow: the second Up should find the stopped container by -// label and restart it rather than creating a fresh one. Exercises -// FindContainerByLabel + StartContainer-idempotency through the -// engine. -func TestAppleContainer_ReattachStopped(t *testing.T) { - if testing.Short() { - t.Skip("integration tests skipped with -short") - } - - eng, _ := newAppleContainerEngine(t) - ws := writeWorkspace(t, `{"image":"docker.io/library/alpine:latest"}`) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - first, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("first Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), first, devcontainer.DownOptions{Remove: true}) - }() - - if err := eng.Down(ctx, first, devcontainer.DownOptions{}); err != nil { - t.Fatalf("Down (no remove): %v", err) - } - - second, err := eng.Up(ctx, devcontainer.UpOptions{LocalWorkspaceFolder: ws}) - if err != nil { - t.Fatalf("second Up: %v", err) - } - // If reattach worked, `second` and `first` are the same container - // and the existing defer on `first` will clean up. But if the ID - // changed (the very bug this test is pinning), `first`'s teardown - // leaves `second` running and contaminates later tests — so - // schedule its teardown unconditionally. - defer func() { - _ = eng.Down(context.Background(), second, devcontainer.DownOptions{Remove: true}) - }() - if second.Container.ID != first.Container.ID { - t.Errorf("container id changed across reattach: first=%q second=%q", - first.Container.ID, second.Container.ID) - } - if second.Container.State != runtime.StateRunning { - t.Errorf("state after reattach = %q (want %q)", - second.Container.State, runtime.StateRunning) - } -} - -// TestAppleContainer_LifecycleAndIdempotency runs an image-source -// devcontainer with postCreate/postStart/postAttach commands and -// verifies the engine's marker-based idempotency works through the -// apple-container backend. Exercises eng.Exec extensively (each -// phase runs a shell command through the runtime's ExecContainer). -func TestAppleContainer_LifecycleAndIdempotency(t *testing.T) { - if testing.Short() { - t.Skip("integration tests skipped with -short") - } - - eng, _ := newAppleContainerEngine(t) - ws := writeWorkspace(t, `{ - "image": "docker.io/library/alpine:latest", - "postCreateCommand": "echo create >> /tmp/dc-counter", - "postStartCommand": "echo start >> /tmp/dc-counter", - "postAttachCommand": "echo attach >> /tmp/dc-counter" - }`) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - out := mustRead(t, ctx, eng, wsObj, "/tmp/dc-counter") - if got := strings.Count(out, "create"); got != 1 { - t.Errorf("after first Up: create count = %d, want 1\n%s", got, out) - } - if got := strings.Count(out, "start"); got != 1 { - t.Errorf("after first Up: start count = %d, want 1\n%s", got, out) - } - if got := strings.Count(out, "attach"); got != 1 { - t.Errorf("after first Up: attach count = %d, want 1\n%s", got, out) - } - - if err := eng.Down(ctx, wsObj, devcontainer.DownOptions{}); err != nil { - t.Fatalf("Down (no remove): %v", err) - } - wsObj2, err := eng.Up(ctx, devcontainer.UpOptions{LocalWorkspaceFolder: ws}) - if err != nil { - t.Fatalf("second Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj2, devcontainer.DownOptions{Remove: true}) - }() - - out = mustRead(t, ctx, eng, wsObj2, "/tmp/dc-counter") - if got := strings.Count(out, "create"); got != 1 { - t.Errorf("after restart: create count = %d, want 1 (idempotent)\n%s", got, out) - } - if got := strings.Count(out, "start"); got != 2 { - t.Errorf("after restart: start count = %d, want 2\n%s", got, out) - } - if got := strings.Count(out, "attach"); got != 2 { - t.Errorf("after restart: attach count = %d, want 2\n%s", got, out) - } -} - -// TestAppleContainer_WorkspaceMount_Writable proves the design §13.8 -// finding holds end-to-end through the engine: a host workspace -// directory bind-mounted into the container is writable from -// inside, even though we don't run updateRemoteUserUID on this -// backend. Virtiofs is identity-permissive (every container user -// appears to own the mount); write attempts succeed regardless of -// UID matching. -func TestAppleContainer_WorkspaceMount_Writable(t *testing.T) { - if testing.Short() { - t.Skip("integration tests skipped with -short") - } - - eng, _ := newAppleContainerEngine(t) - ws := writeWorkspace(t, `{"image":"docker.io/library/alpine:latest"}`) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - // The engine binds the workspace folder into the container at - // /workspaces/. Write a marker from inside. - const marker = "writable-marker-from-vm-99" - cmd := "echo " + marker + " > /workspaces/$(basename " + ws + ")/.test-write && cat /workspaces/$(basename " + ws + ")/.test-write" - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"/bin/sh", "-c", cmd}, - }) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if res.ExitCode != 0 { - t.Fatalf("write failed: exit=%d stderr=%q stdout=%q", res.ExitCode, res.Stderr, res.Stdout) - } - if !strings.Contains(res.Stdout, marker) { - t.Errorf("marker readback mismatch: want contains %q, got %q", marker, res.Stdout) - } - - // Confirm the file appears on the host with our content. This - // also confirms the host-side mount semantics survive the cycle. - data, err := os.ReadFile(ws + "/.test-write") - if err != nil { - t.Fatalf("host readback: %v", err) - } - if !strings.Contains(string(data), marker) { - t.Errorf("host readback content: want contains %q, got %q", marker, string(data)) - } -} - -// TestAppleContainer_ComposeSource_DocumentsLimitation asserts the -// design §9 contract: ComposeRuntime isn't implemented for this -// backend; the engine returns a clean error rather than crashing. -func TestAppleContainer_ComposeSource_DocumentsLimitation(t *testing.T) { - if testing.Short() { - t.Skip("integration tests skipped with -short") - } - - eng, _ := newAppleContainerEngine(t) - ws := writeWorkspace(t, `{ - "dockerComposeFile": "docker-compose.yml", - "service": "app" - }`) - composeYAML := "services:\n app:\n image: docker.io/library/alpine:latest\n command: ['sleep','60']\n" - if err := os.WriteFile(ws+"/.devcontainer/docker-compose.yml", []byte(composeYAML), 0o644); err != nil { - t.Fatalf("write compose: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - _, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err == nil { - t.Fatal("Up: want error for compose source on apple-container, got nil") - } - if !errors.Is(err, runtime.ErrNotImplemented) && - !strings.Contains(err.Error(), "not implemented") && - !strings.Contains(err.Error(), "compose") { - t.Errorf("error should mention compose or 'not implemented'; got %v", err) - } - t.Logf("expected compose-source rejection: %v", err) -} - -// writeFile is a small helper used across applecontainer_*_test.go -// files. Inlining os.WriteFile in every test body bloats the test -// without adding clarity. -func writeFile(path, content string) error { - return os.WriteFile(path, []byte(content), 0o644) -} - -// runtimeBuildSpec keeps BuildSpec construction terse in tests that -// only need ContextPath/Dockerfile/Tag. -func runtimeBuildSpec(contextDir, tag string) runtime.BuildSpec { - return runtime.BuildSpec{ContextPath: contextDir, Dockerfile: "Dockerfile", Tag: tag} -} diff --git a/test/integration/applecontainer_shutdown_action_test.go b/test/integration/applecontainer_shutdown_action_test.go deleted file mode 100644 index a414b23..0000000 --- a/test/integration/applecontainer_shutdown_action_test.go +++ /dev/null @@ -1,94 +0,0 @@ -//go:build integration && darwin && arm64 - -// Apple-container backend: shutdownAction semantics. Mirrors -// shutdown_action_test.go for the docker backend. - -package integration - -import ( - "context" - "testing" - "time" - - devcontainer "github.com/crunchloop/devcontainer" - "github.com/crunchloop/devcontainer/runtime" -) - -func TestAppleContainer_ShutdownAction_NoneLeavesRunning(t *testing.T) { - if testing.Short() { - t.Skip() - } - - eng, rt := newAppleContainerEngine(t) - ws := writeWorkspace(t, `{ - "image": "docker.io/library/alpine:latest", - "shutdownAction": "none" - }`) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - // Force teardown — Shutdown is a no-op on "none". - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - if err := eng.Shutdown(ctx, wsObj); err != nil { - t.Fatalf("Shutdown: %v", err) - } - - details, err := rt.InspectContainer(ctx, wsObj.Container.ID) - if err != nil { - t.Fatalf("InspectContainer: %v", err) - } - if details.State != runtime.StateRunning { - t.Errorf("container state after Shutdown(none) = %q, want %q", details.State, runtime.StateRunning) - } -} - -func TestAppleContainer_ShutdownAction_StopContainerStops(t *testing.T) { - if testing.Short() { - t.Skip() - } - - eng, rt := newAppleContainerEngine(t) - ws := writeWorkspace(t, `{ - "image": "docker.io/library/alpine:latest", - "shutdownAction": "stopContainer" - }`) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - if err := eng.Shutdown(ctx, wsObj); err != nil { - t.Fatalf("Shutdown: %v", err) - } - - // Allow the apiserver a moment to flip status. - for i := 0; i < 40; i++ { - details, err := rt.InspectContainer(ctx, wsObj.Container.ID) - if err == nil && details.State != runtime.StateRunning { - return - } - time.Sleep(100 * time.Millisecond) - } - t.Errorf("container still running 4s after Shutdown(stopContainer)") -} diff --git a/test/integration/applecontainer_uid_reconcile_test.go b/test/integration/applecontainer_uid_reconcile_test.go deleted file mode 100644 index 8caf164..0000000 --- a/test/integration/applecontainer_uid_reconcile_test.go +++ /dev/null @@ -1,119 +0,0 @@ -//go:build integration && darwin && arm64 - -// Apple-container backend: the inverted UID-reconcile contract. -// Design §13.8 declares we DO NOT run updateRemoteUserUID on this -// backend — virtiofs is identity-permissive, so the docker-style -// dance of rewriting /etc/passwd to match the host UID would be -// harmful without buying anything. This test pins that behavior: -// - Workspace folder lives at the host user's UID. -// - Container has a baked vscode user at a different UID. -// - After Up + Exec as vscode, /etc/passwd vscode's UID must be -// UNCHANGED (the baked image-default UID), AND the workspace -// mount must still be writable as vscode. - -package integration - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - devcontainer "github.com/crunchloop/devcontainer" - "github.com/crunchloop/devcontainer/runtime" -) - -func TestAppleContainer_UID_NotReconciled_MountStillWritable(t *testing.T) { - if testing.Short() { - t.Skip() - } - - eng, rt := newAppleContainerEngine(t) - - // Build a base image with vscode at UID 4321 (intentionally weird - // so it can't collide with the host's actual UID). - dir := t.TempDir() - df := `FROM docker.io/library/alpine:latest -RUN addgroup -g 4321 vscode \ - && adduser -D -u 4321 -G vscode -s /bin/sh vscode -` - if err := writeFile(filepath.Join(dir, "Dockerfile"), df); err != nil { - t.Fatal(err) - } - tag := "dc-it-ac-uid-baked:latest" - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - if _, err := rt.BuildImage(ctx, runtimeBuildSpec(dir, tag), nil); err != nil { - var unavail *runtime.BuilderUnavailableError - if errors.As(err, &unavail) { - t.Skipf("BuildImage (builder not running): %v", err) - } - t.Fatalf("BuildImage: %v", err) - } - - ws := writeWorkspace(t, `{ - "image": "`+tag+`", - "remoteUser": "vscode", - "containerUser": "vscode" - }`) - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - // Read vscode's UID from inside the container. Apple-container - // behavior we're pinning: this MUST still be 4321 — the baked - // image-default — not the host UID. If a future engine change - // adds UID reconciliation to the apple-container path, this - // assertion fails. - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"id", "-u", "vscode"}, - }) - if err != nil { - t.Fatalf("Exec id -u vscode: %v", err) - } - if got := strings.TrimSpace(res.Stdout); got != "4321" { - t.Errorf("vscode UID inside container = %q, want %q (design §13.8 says we don't reconcile UIDs on apple-container)", - got, "4321") - } - - // Verify the workspace mount is writable as vscode (virtiofs - // identity-permissive contract). Probe-3 from the M6 design - // validation already proved this at the runtime layer; here we - // re-prove it through the full Engine.Up bind-mount path. - const marker = "ac-uid-test-writable-99" - // The engine binds the workspace at /workspaces/. - cmd := "echo " + marker + " > /workspaces/$(basename " + ws + ")/.uid-test && cat /workspaces/$(basename " + ws + ")/.uid-test" - res, err = eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"/bin/sh", "-c", cmd}, - }) - if err != nil { - t.Fatalf("Exec write probe: %v", err) - } - if res.ExitCode != 0 { - t.Fatalf("write probe failed: exit=%d stderr=%q stdout=%q", res.ExitCode, res.Stderr, res.Stdout) - } - if !strings.Contains(res.Stdout, marker) { - t.Errorf("marker not readable; got %q", res.Stdout) - } - - // Host-side readback confirms the file made it through virtiofs - // with the OUR-UID owner from the host's perspective. - data, err := os.ReadFile(ws + "/.uid-test") - if err != nil { - t.Fatalf("host readback: %v", err) - } - if !strings.Contains(string(data), marker) { - t.Errorf("host readback content mismatch: %q", string(data)) - } -} diff --git a/test/integration/applecontainer_userenvprobe_test.go b/test/integration/applecontainer_userenvprobe_test.go deleted file mode 100644 index 0ae9f37..0000000 --- a/test/integration/applecontainer_userenvprobe_test.go +++ /dev/null @@ -1,159 +0,0 @@ -//go:build integration && darwin && arm64 - -// Apple-container backend: userEnvProbe behavior through the engine. -// Mirrors a representative subset of userenvprobe_test.go for the -// docker backend (PathFromBashrc + LifecycleSeesBashrcPath + None). -// The full 6-variant matrix on docker is overkill here; these three -// hit the load-bearing engine paths. - -package integration - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - devcontainer "github.com/crunchloop/devcontainer" - "github.com/crunchloop/devcontainer/runtime" -) - -const appleBashImage = "docker.io/library/bash:5.2-alpine3.20" - -func TestAppleContainer_UserEnvProbe_PathFromBashrc(t *testing.T) { - if testing.Short() { - t.Skip() - } - eng, _ := newAppleContainerEngine(t) - - ws := writeWorkspace(t, `{ - "image": "`+appleBashImage+`", - "postCreateCommand": "echo 'export EXTRA_PATH=/from/bashrc' > /etc/profile.d/dc-go-test.sh" - }`) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"printenv", "EXTRA_PATH"}, - }) - if err != nil { - t.Fatalf("Exec: %v", err) - } - if res.ExitCode != 0 { - t.Fatalf("printenv exit=%d stderr=%q", res.ExitCode, res.Stderr) - } - if got := strings.TrimSpace(res.Stdout); got != "/from/bashrc" { - t.Errorf("EXTRA_PATH = %q, want %q (probedEnv didn't inject from rc files)", got, "/from/bashrc") - } -} - -func TestAppleContainer_UserEnvProbe_None(t *testing.T) { - if testing.Short() { - t.Skip() - } - eng, _ := newAppleContainerEngine(t) - - ws := writeWorkspace(t, `{ - "image": "`+appleBashImage+`", - "userEnvProbe": "none", - "postCreateCommand": "echo 'export EXTRA_PATH=/from/bashrc' > /etc/profile.d/dc-go-test.sh" - }`) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"printenv", "EXTRA_PATH"}, - }) - // printenv exits non-zero when the var is unset; that's the - // success signal here. - if err != nil { - t.Fatalf("Exec: %v", err) - } - if res.ExitCode == 0 { - t.Errorf("EXTRA_PATH leaked with userEnvProbe=none (stdout=%q)", res.Stdout) - } -} - -// TestAppleContainer_UserEnvProbe_LifecycleSeesBashrcPath is the -// stricter variant: postCreate itself must see the probed env so a -// tool installed only via rc files is resolvable during the hook. Uses -// PR-G2 BuildImage to bake the fake tool + rc snippet into a base -// image. Skips if the builder isn't running. -func TestAppleContainer_UserEnvProbe_LifecycleSeesBashrcPath(t *testing.T) { - if testing.Short() { - t.Skip() - } - eng, rt := newAppleContainerEngine(t) - - dir := t.TempDir() - df := `FROM ` + appleBashImage + ` -RUN mkdir -p /opt/mytool/bin /etc/profile.d \ - && printf '#!/bin/sh\necho hello-from-mytool\n' > /opt/mytool/bin/mytool \ - && chmod +x /opt/mytool/bin/mytool \ - && printf 'export PATH=/opt/mytool/bin:$PATH\n' > /etc/profile.d/mytool.sh -` - if err := writeFile(dir+"/Dockerfile", df); err != nil { - t.Fatal(err) - } - tag := "dc-it-ac-userenv:latest" - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - if _, err := rt.BuildImage(ctx, runtimeBuildSpec(dir, tag), nil); err != nil { - var unavail *runtime.BuilderUnavailableError - if errors.As(err, &unavail) { - t.Skipf("BuildImage (builder not running): %v", err) - } - t.Fatalf("BuildImage: %v", err) - } - - ws := writeWorkspace(t, `{ - "image": "`+tag+`", - "postCreateCommand": "command -v mytool && mytool > /tmp/lifecycle-out" - }`) - - wsObj, err := eng.Up(ctx, devcontainer.UpOptions{ - LocalWorkspaceFolder: ws, - Recreate: true, - }) - if err != nil { - t.Fatalf("Up: %v (postCreate likely failed to resolve mytool — probe not merged into lifecycle env)", err) - } - defer func() { - _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) - }() - - res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{ - Cmd: []string{"cat", "/tmp/lifecycle-out"}, - }) - if err != nil { - t.Fatalf("read lifecycle output: %v", err) - } - if got := strings.TrimSpace(res.Stdout); got != "hello-from-mytool" { - t.Errorf("/tmp/lifecycle-out = %q, want %q", got, "hello-from-mytool") - } -} From 9092268d52033e1a2c5e5aca33fe32d913879abd Mon Sep 17 00:00:00 2001 From: bilby91 <2201079+bilby91@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:13:32 +0000 Subject: [PATCH 2/2] docs(design): mark compose-native's companion records as historical citations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design/compose-native.md cites three companion records that are not in the tree: design/runtime-applecontainer.md, deleted with the Apple Containers backend in this PR, and design/compose.md / design/status.md, which were named as companions but never landed (neither has a delete commit in git history). Adds one note in the header pointing at the tag where the Apple record is readable and stating that references to all three — including their section numbers — are historical citations rather than links. This is the tag-pointer mechanism the CHANGELOG already uses for the records #124 deleted, and it fixes all nine references at one site. Rewriting the nine cited sections was the alternative; it would have edited the historical body of a retained record, which design/README.md tells contributors not to do, and the §8 / §10.1 / §11 citations carry which-probe and which-mapping detail that a rewrite would lose. Co-Authored-By: Claude Opus 5 --- design/compose-native.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/design/compose-native.md b/design/compose-native.md index 9705bf2..c1a964e 100644 --- a/design/compose-native.md +++ b/design/compose-native.md @@ -8,6 +8,14 @@ orchestrator under `compose/` so the compose source path works against any `runtime.Runtime` backend — including `runtime/applecontainer`, which has no compose plugin and no Docker-API socket. +> **On the companion records cited below.** `design/runtime-applecontainer.md` was +> deleted along with the Apple Containers backend it described; it remains readable +> in git history at tag `v0.4.3`. `design/compose.md` and `design/status.md` are +> named here as companions but never landed in the repository. Per +> `design/README.md`, this document records the state of the world as of its date +> and is not kept in sync with `main`, so references to those three files — and to +> their section numbers — are historical citations, not links you can open. + Companion to `design/compose.md` (the existing shell-out path, kept as the historical record and the §13 "future Go-native" sketch that this design supersedes) and `design/runtime-applecontainer.md` (the second backend whose