Skip to content

Add Apple Container as an alternative container backend - #2

Merged
ccakes merged 5 commits into
mainfrom
apple-container-backend
Aug 25, 2026
Merged

ccakes merged 5 commits into
mainfrom
apple-container-backend

Conversation

@ccakes

@ccakes ccakes commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

Workbench shelled out to docker for every container service. On Apple silicon, Apple's container tool 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 container as an alternative backend. Container services run unchanged on either runtime — only the new global container_backend setting differs.

What changed

  • ContainerBackend abstraction (internal/runner/backend.go, docker.go, apple.go) driven by a single ContainerRunner. The Docker path is byte-for-byte unchanged (dockerBackend still emits the same --add-host host.docker.internal:host-gateway, docker wait, rm -f -v).
  • Config: global.container_backend: docker | apple | auto (default auto — prefer Apple container when installed on Apple silicon, else Docker) plus optional global.apple.gateway_ip. Defaults, extends merge, and validation included.
  • Backend visibility: the active backend shows per container service in the TUI detail pane and in bench status (the TYPE column reads container/apple or container/docker, and --json includes a backend field).
  • Docs: new docs/apple-container.md; updated configuration.md and troubleshooting.md.

How the Apple backend handles Docker's gaps

  • No wait subcommand — poll container inspect for run state, then report a best-effort exit code. container inspect does not reliably expose the process exit code, so a clean stop with no code defaults to 0 (chosen to avoid restart loops under on-failure/always). The parser handles the real v1.1.0 shape where status is an object ({"state": "running"}) as well as bare-string variants.
  • No host-gateway alias — containers reach host services (e.g. the OTLP trace collector) via the vmnet gateway IP (default 192.168.64.1, configurable) instead of host.docker.internal.
  • Daemon/OS floor — the Apple backend requires Apple silicon + macOS 26 and a running container system service, checked at startup with an actionable error.

Testing

  • go build ./..., go vet ./..., golangci-lint run ./... clean; gofmt -l . empty.
  • go test ./... passes, including -race on runner/supervisor.
  • New unit tests: per-backend arg builders, container inspect parsing (incl. the real v1.1.0 status.state shape and best-effort exit codes), backend resolution, config defaults/validation, and the Apple gateway-IP OTEL injection.
  • Manually verified bench status (table + --json) shows container/apple (auto-detected) and container/docker (forced), and that validation rejects an invalid backend.

Reviewer notes

  • auto is host-dependent by design. On Apple silicon with container installed it resolves to apple, 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 up apple unless they set container_backend: docker.
  • Apple exit codes are best-effort — see above. This mainly affects failure-based restart policies on the Apple backend.
  • Out of scope: image building (container build), container system dns domains (would need sudo + disable Private Relay), and auto-starting the container daemon.
  • CHANGELOG.md is intentionally not included (per repo convention it's maintained separately).

ccakes and others added 5 commits July 23, 2026 12:36
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
ccakes force-pushed the apple-container-backend branch from cb8ee52 to a78d169 Compare August 25, 2026 23:04
@ccakes
ccakes merged commit 332e393 into main Aug 25, 2026
4 checks passed
@ccakes
ccakes deleted the apple-container-backend branch August 25, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant