Add Apple Container as an alternative container backend - #2
Merged
Merged
Conversation
Workbench shelled out to `docker` for every container service. On Apple silicon, Apple's `container` tool runs Linux containers in lightweight VMs without the Docker Desktop dependency, and its CLI is largely argument-compatible with Docker. Introduce a ContainerBackend abstraction driven by a single ContainerRunner. The Docker path is unchanged; the Apple path adapts for the differences that break Docker's assumptions: - No `wait` subcommand: poll `container inspect` for run state, then report a best-effort exit code (inspect does not reliably expose the process exit code, so a clean stop with no code defaults to 0). - No host-gateway alias: containers reach host services (e.g. the OTLP trace collector) via the vmnet gateway IP instead of host.docker.internal. - Daemon/OS floor: require Apple silicon + macOS 26 and a running `container` system service, checked at startup. Selection is via a new global `container_backend` setting (docker | apple | auto, default auto — prefer Apple when installed on Apple silicon) plus an optional `apple.gateway_ip`. The active backend is surfaced per container service in the TUI detail pane and in `bench status` (TYPE column and --json). Adds docs/apple-container.md and updates configuration/troubleshooting docs.
Previously a container image with no runnable variant for the host
architecture (e.g. an amd64-only image on Apple silicon that Rosetta can't
run) failed to start and then looped through the restart policy — retrying
something that can never succeed and burying the real cause.
Detect the runtime's platform-mismatch output ("does not support required
platforms" / "exec format error" on Apple `container`; "no matching manifest"
/ platform-mismatch on Docker) in the run error, return a sentinel
runner.ErrUnsupportedPlatform, and have the supervisor treat it as terminal:
the service goes straight to Failed with a clear message and is not restarted,
regardless of restart policy.
Verified end-to-end on the Apple backend with an amd64-only image and
restart.policy: always — the service reaches Failed with zero restarts and the
error is logged once.
A readiness probe that needs to run a command inside its own container had
to be written as `kind: exec` with a hand-rolled `docker exec <name> ...`.
That hardcodes three things the config should not know: the runtime's CLI,
the container prefix, and the service key.
The CLI is the one that bites. `container_backend: auto` resolves to Apple
`container` on Apple silicon, so an existing config's `docker exec` probe
starts looking in the wrong runtime's namespace — every attempt reports
"No such container" until max_attempts is exhausted, while the service it
is probing is healthy the whole time. This silently contradicts the promise
in docs/apple-container.md that a container service runs unchanged on either
backend.
Add a container_exec kind that runs its command inside the owning service's
container, with workbench supplying the container and the CLI:
readiness:
kind: container_exec
command: pg_isready -U bench -d bench
Layered so each piece owns one decision:
- ContainerBackend.ExecArgs(id, cmd) builds the invocation. Both backends
spell it `exec <id> <cmd...>` today, but routing through the interface is
what keeps the probe portable if that stops being true.
- ContainerRunner.ExecCommand targets the container by name rather than id,
so it is valid before Start() assigns an id and stays valid across
restarts.
- runner.ContainerExecer is deliberately not implemented by ProcessRunner,
so the failed type assertion is how the probe reports "only valid for a
container service" instead of hanging. It is resolved before the probe
goroutine starts, so the probe never races the runLoop for ms.r.
- Validation rejects container_exec on a service with no container block,
since that can only ever be a config mistake.
Verified end-to-end on the Apple backend: the probe execs into the VM and
the service reaches ready, with pg_isready's own output visible in the
service log buffer under the `probe` stream.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Setup hooks need the same runtime choice as readiness commands. Preserve command-only configs as host exec hooks and report their deprecation.
Automatic selection can change existing projects when Apple Container is installed. Keep it opt-in so omitted configuration preserves Docker behavior.
ccakes
force-pushed
the
apple-container-backend
branch
from
August 25, 2026 23:04
cb8ee52 to
a78d169
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Workbench shelled out to
dockerfor every container service. On Apple silicon, Apple'scontainertool runs Linux containers in lightweight per-container VMs without the Docker Desktop dependency, and its CLI is largely argument-compatible with Docker.This adds Apple
containeras an alternative backend. Container services run unchanged on either runtime — only the new globalcontainer_backendsetting differs.What changed
ContainerBackendabstraction (internal/runner/backend.go,docker.go,apple.go) driven by a singleContainerRunner. The Docker path is byte-for-byte unchanged (dockerBackendstill emits the same--add-host host.docker.internal:host-gateway,docker wait,rm -f -v).global.container_backend: docker | apple | auto(defaultauto— prefer Applecontainerwhen installed on Apple silicon, else Docker) plus optionalglobal.apple.gateway_ip. Defaults,extendsmerge, and validation included.bench status(theTYPEcolumn readscontainer/appleorcontainer/docker, and--jsonincludes abackendfield).docs/apple-container.md; updatedconfiguration.mdandtroubleshooting.md.How the Apple backend handles Docker's gaps
waitsubcommand — pollcontainer inspectfor run state, then report a best-effort exit code.container inspectdoes not reliably expose the process exit code, so a clean stop with no code defaults to0(chosen to avoid restart loops underon-failure/always). The parser handles the real v1.1.0 shape wherestatusis an object ({"state": "running"}) as well as bare-string variants.192.168.64.1, configurable) instead ofhost.docker.internal.containersystem service, checked at startup with an actionable error.Testing
go build ./...,go vet ./...,golangci-lint run ./...clean;gofmt -l .empty.go test ./...passes, including-raceonrunner/supervisor.container inspectparsing (incl. the real v1.1.0status.stateshape and best-effort exit codes), backend resolution, config defaults/validation, and the Apple gateway-IP OTEL injection.bench status(table +--json) showscontainer/apple(auto-detected) andcontainer/docker(forced), and that validation rejects an invalid backend.Reviewer notes
autois host-dependent by design. On Apple silicon withcontainerinstalled it resolves toapple, which surprised an OTEL test (pinned to Docker to stay deterministic). The active backend is now visible everywhere to compensate. CI/teammates on Apple silicon will pick upappleunless they setcontainer_backend: docker.container build),container system dnsdomains (would need sudo + disable Private Relay), and auto-starting thecontainerdaemon.CHANGELOG.mdis intentionally not included (per repo convention it's maintained separately).