Skip to content

feat(container): support podman via its Docker-compatible socket - #444

Open
grinnellian wants to merge 36 commits into
majorcontext:mainfrom
grinnellian:feat/podman-rebase
Open

feat(container): support podman via its Docker-compatible socket#444
grinnellian wants to merge 36 commits into
majorcontext:mainfrom
grinnellian:feat/podman-rebase

Conversation

@grinnellian

Copy link
Copy Markdown

This reopens the work from #435, opened by an over-eager Claude. Now deeply reviewed by human eyes; sorry about the prior.

Four questions first, because these are the parts I don't think I should decide on my own.

1. gVisor under podman — is a warning enough?

Podman's compat API reports every OCI runtime listed in containers.conf as available whether or not the binary is installed, so a reported runsc proves nothing. That means moat cannot verify the isolation it promises when it runs on podman. This PR warns once, names the command to check, and turns a creation failure under runsc into a targeted diagnostic instead of a raw OCI error — but it still proceeds. For a tool whose whole pitch is the sandbox, that may be the wrong default.

I can see three answers: require an explicit acknowledgement for podman + sandbox, verify runsc out of band, or leave it and document the caveat. I didn't want to pick unilaterally, and I'd rather drop podman from the supported matrix than have it quietly weaken the guarantee. Happy to implement whichever you prefer.

2. Should podman exist as a runtime value at all?

It isn't a separate Runtime implementation but ratherDockerRuntime pointed at a podman socket, and a run still records Runtime: "docker". There's value in asserting engine identity: a mis-set DOCKER_HOST would otherwise run on Docker silently. But if you'd rather this were just DOCKER_HOST plus documentation, that's a much smaller change and I'd understand the call.

3. Would you rather this were split?

It's roughly 1,200 lines across three separable pieces: per-run engine pinning (which fixes Rancher Desktop independently of podman), podman detection and labeling, and the moat stop behavior change. I kept it as one PR because the first piece doesn't fully justify itself without the second but they should separately reviewable, and I'll split it on request.

4. Is the moat stop change in scope here?

It changes behavior for non-podman users too. Previously a not-found container only warned and still recorded the run stopped, which can mark a run stopped while its container keeps running on another engine. It now fails loudly — but only when a different, live podman endpoint is reachable, so a container that is simply gone still stops cleanly. If you'd rather that were its own PR, or an issue first, I'll pull it out.

What this does

Podman's Docker-compatible API works with moat's existing Docker runtime unmodified, so this adds podman without a new Runtime implementation:

  • Auto-detection — podman machine sockets (macOS) and rootless/rootful sockets (Linux) are probed exactly like Rancher Desktop's, only when the default Docker socket is unreachable and DOCKER_HOST is unset.
  • Explicit selection--runtime podman, MOAT_RUNTIME=podman, or runtime: podman. Forcing podman errors if a usable podman engine can't be reached, so it can't silently succeed against real Docker.
  • Engine identity is recorded, not guessed — asked of the daemon at run creation and persisted, so moat list / moat status label the engine without inferring it from a socket path.
  • Per-run endpoint pinning — each run records the endpoint it was created on, so lifecycle commands reconnect to the right engine when several are present.

What the self-review changed

The things worth calling out, since they were wrong before:

  • The version floor was wrong. The branch claimed podman 4.1+. --add-host host-gateway landed in podman 4.7.0, and the requirement is Linux-only — synthHostStrategy emits that sentinel only for a Docker-API runtime on a Linux host, so the macOS rows were citing a constraint that never applied to them.
  • Three doc claims contradicted the code, including one telling users to recover with moat destroy --force after that teardown had been given its own flag.
  • The moat stop guard was too broad — it fired whenever a podman socket file existed, so docker system prune on a podman-equipped host broke moat stop. It now requires a reachable, distinct engine, compared by filesystem identity rather than by path string (with podman-docker installed, /run/docker.sock is a symlink to the podman socket, so string comparison sees two engines where one exists).
  • Engine identity was inferred from the socket path; it's now recorded authoritatively at creation.
  • Test seams were consolidated out of the shipped binary, the pool's negative cache got a TTL, and an identity probe that ran on every startup was removed.

Known gaps

  • e2e coverage for the destroy running-run guard is unit-level only. I didn't add an e2e case I couldn't run.
  • Windows isn't auto-detected — there are no podman socket candidates for it. A manual DOCKER_HOST isn't blocked by anything in moat, but I haven't verified it end to end and haven't claimed it works.
  • Version floors are external facts. moat enforces no version gate in code; the table reflects podman's release notes and what I actually exercised (6.0.0 / 6.0.1, macOS arm64), not a matrix I've tested.

Notes

  • The CI e2e job logs into Docker Hub with repository secrets, which GitHub doesn't pass to fork PRs — so it will likely fail at that step here, before any test runs. Not related to these changes, but I'd rather flag it than have it look like a red check I ignored.
  • The two CHANGELOG entries currently link to feat: support podman via its Docker-compatible socket #435; I'll point them at this PR's number once it has one.
  • internal/providers/claude.TestResolveClaudeCredential_SkipsMigrationInReadOnlyDir fails in any root container (root bypasses the 0500 permission it relies on). It fails identically on main — flagging so it isn't mistaken for fallout from this branch.

Verified on the branch: unit suite green under -race, go vet clean including under the e2e build tag, and golangci-lint at exactly the same nine pre-existing prealloc findings as main — no new lint.

As with #435, and to keep that disclosure attached to this one: this branch was written by an AI agent (Claude) working for me, and I've reviewed (and actually run) it before submitting. Happy to answer for any of it.

Podman's compat API (v1.44 verified) works with moat's Docker runtime
unmodified, so podman is served by the existing DockerRuntime pointed at
a podman socket rather than a new Runtime implementation:

- Auto-detect podman sockets alongside Rancher Desktop's in
  alternativeDockerSockets(): podman machine API sockets on macOS
  ($TMPDIR/podman/*-api.sock), rootless ($XDG_RUNTIME_DIR) and rootful
  (/run/podman) sockets on Linux.
- Accept --runtime podman / MOAT_RUNTIME=podman / runtime: podman, which
  probes podman sockets (or verifies an explicit DOCKER_HOST actually
  answers as podman) and errors with start hints otherwise.
- Add DockerRuntime.IsPodmanEngine, identifying podman by the
  'Podman Engine' entry in /version Components.
Covers install and machine setup (including the podman 6 libkrun/krunkit
provider pitfall on macOS), Linux socket activation, DOCKER_HOST usage,
auto-detection, --runtime podman, and verified caveats: podman's compat
API reports containers.conf OCI runtimes (runsc) as available even when
not installed, root-user base image requirement, and podman >= 4.1 for
the host-gateway sentinel.
Label the Available entry 'docker (podman)' when the connected engine is
podman's compat API (confirmed via ping + IsPodmanEngine), and stop
printing an unconditional 'gVisor: available' there — podman's /info
lists every containers.conf OCI runtime (including runsc) whether or not
it is installed, so the doctor line now says the report is unverified.
Review findings on the podman branch:

- Forced docker (--runtime docker / MOAT_RUNTIME=docker / runtime: docker)
  no longer falls back to podman sockets; its probe covers genuine Docker
  engines only (Rancher Desktop). Auto-detect and existing-run
  reconnection still probe all candidates.
- Socket probing reports why a found socket was unusable (e.g. gVisor
  required but unavailable) instead of claiming no socket was found.
- IsPodmanEngine returns (bool, error) and no longer caches transient
  /version failures as 'not podman'.
- Under podman with sandbox enabled, warn once that the engine-reported
  runsc listing is unverified (podman lists configured OCI runtimes even
  when not installed).
- Test hermeticity: rootful podman socket path is now a test seam;
  added fake-engine tests for both DOCKER_HOST verification directions.
Review findings on the podman branch: doctor no longer fails open when
the docker ping times out (unknown engine identity gets an honest
'unverified' gVisor annotation instead of resurfacing the trusting
report), and when docker is unreachable but a podman socket exists on
disk, doctor points at it ('use --runtime podman') without dialing it
or mutating DOCKER_HOST.
Runs record only Runtime="docker" in metadata, so lifecycle commands in
a fresh process re-probed the default Docker socket; a run created on a
podman (or Rancher Desktop) socket while a real Docker daemon was also
reachable would reconnect to the wrong engine — container-not-found on
stop/logs and a leaked container on the actual engine.

Run metadata now records the DOCKER_HOST endpoint the run was created
against (docker_host, additive field), and reconnection routes such runs
through a host-pinned Docker runtime (RuntimePool.GetDockerAt) without
mutating the process-wide DOCKER_HOST.
- GetDockerAt: take a context, construct and ping outside the pool
  mutex (a wedged endpoint no longer serializes startup N x 5s or
  starves Default/Get/Close), negatively cache failed hosts, and
  append a podman-aware recovery hint (machine start command +
  metadata.json breadcrumb) when a recorded podman endpoint is dead.
- NewDockerRuntimeWithHost: layer WithHost over FromEnv so TLS and
  API-version env config survive reconnects to tcp:// endpoints.
- ForEachAvailable: visit host-pinned runtimes so clean/status see
  images, containers, and networks on a podman engine (skipping
  same-endpoint duplicates via new DaemonHost accessor).
- MOAT_RUNTIME=podman auto-probe verifies candidates answer as podman
  (IsPodmanEngine) instead of trusting the candidate list.
- Linux rootless detection falls back to /run/user/<uid> when
  XDG_RUNTIME_DIR is unset (sudo/cron/CI).
- 'no container runtime available' error now suggests --runtime podman.
- Pin previously-untested behavior: forced docker never falls back to
  a podman socket (mutation-verified gap), concurrent GetDockerAt does
  not block the pool, negative-cache and ctx-cancellation paths.
- doctor: list a found podman socket whenever the connected engine is
  not itself podman (previously only when engine identity was unknown).
- installation: state real auto-detection precedence (Apple containers
  win on macOS 26+ Apple Silicon; podman is reached explicitly or when
  Docker's default socket is dead), scope libkrun-default to podman 6.x
  and the machine-socket glob to podman 5.x+, and replace the verify
  block with live-verified output.
- README: correct Apple containers requirement to macOS 26+ on the
  enumeration lines this branch touches.
Endpoint pinning was one-directional: a run created on the default
Docker socket recorded docker_host="" (raw DOCKER_HOST env), and on
reconnect "" meant 'the current process's default docker-type engine'.
With MOAT_RUNTIME=podman exported, 'moat stop' on such a run asked
podman, got not-found, warned, marked the run stopped, and tore down
its proxy registration while the container kept running on Docker.

Run creation now records the runtime's resolved DaemonHost() (never
empty for docker-type runtimes), so reconnection is pinned in both
directions; empty now only ever means a legacy run, which keeps the
old pool-default routing. The reconnect routing decision is collapsed
into one shared helper (runtimeForEndpoint) used by both runtimeForRun
and run reconciliation, so the two paths cannot drift.
The forced-docker-excludes-podman test and its auto-detect companion
skipped whenever a live default dockerd was reachable — which includes
GitHub's ubuntu-latest runners, so the regression guard for the
forced-docker hardening ran on effectively no machine. Add a
newDefaultDockerRuntime seam (mirroring podmanRootfulSocket) so the
tests deterministically force the default-socket-unreachable
precondition and execute everywhere; production behavior unchanged.
Add the missing companion that pins the podman auto-probe rejecting a
non-podman engine found on a candidate socket. Both mutation-verified.
shortTempDir works around macOS's unix-socket path-length limit the
skip had been masking.
A run started with --runtime podman uses the Docker runtime over a
podman socket, so its recorded runtime type is 'docker' — 'moat list'
and 'moat status' showed 'docker' for a run the user explicitly asked
to run on podman. Label it 'docker (podman)' when the recorded
DOCKER_HOST endpoint is a podman socket, matching 'moat doctor'.
Derived from the recorded endpoint string with no live engine call, so
listing stays cheap and side-effect-free.
For a run with no recorded engine endpoint (created before per-run
docker_host tracking), 'moat stop' resolving to a docker-type engine
that reports the container as not-found is ambiguous: the container may
be genuinely gone, or still running on a different engine than the one
resolved (e.g. started on Docker, stopped under MOAT_RUNTIME=podman).
Previously moat only warned and recorded the run stopped, silently
orphaning a live container and tearing down its proxy registration.

Stop now fails loudly in that case, reverts the run to its prior state,
and points at recovery: retry with the engine the run was created on,
or 'moat destroy --force' if the container is genuinely gone. When the
endpoint IS recorded we are pinned to the right engine, so not-found
means genuinely gone and cleanup proceeds unchanged; Apple's
StopContainer swallows not-found, so this only affects docker.

'moat destroy --force' now also tears down a still-running run (skipping
the stop-it-first guard) so a run that can't be stopped cleanly is never
wedged. Verified live: wrong-engine stop leaves the real container up
and the run running; correct-engine retry and --force both recover.
Two follow-ups from adversarial verification of the podman labeling:

- 'moat status' listed 'docker' twice in the Runtimes header when a
  podman run was active, because ForEachAvailable now also visits the
  host-pinned podman runtime (Type() 'docker'). Dedupe the header by
  runtime type; the per-run RUNTIME column still distinguishes the
  engine. Image enumeration is unchanged (still visits every engine).
- The 'docker (podman)' label matched a bare 'podman' substring in the
  endpoint, so a docker socket under a path merely containing 'podman'
  (e.g. a user home) could be mislabeled. Match the socket basename
  instead — podman sockets are always named podman.sock or
  podman-machine-<name>-api.sock.
Stop set StateStopping before resolving the run's runtime, but the
resolution-error path returned without restoring it, so the run was
stuck in Stopping and every subsequent Stop hit the 'already stopped'
early return and silently no-opped while the container may still exist.

Pre-existing flaw, but endpoint pinning promotes runtime-resolution
failure to a routine event (a pinned podman endpoint whose machine is
stopped fails GetDockerAt and is negative-cached), so restore the prior
state before returning, mirroring the ambiguous-not-found branch below.
The 'docker (podman)' label matched 'podman' in the socket basename
only, but a custom-named podman machine's socket on macOS is
$TMPDIR/podman/<name>-api.sock — e.g. dev-api.sock — which loses the
'podman' filename prefix while keeping the podman parent directory.
Such runs silently displayed plain 'docker'.

Match when the basename contains 'podman' OR the immediate parent
directory is exactly 'podman'; a docker socket under a path merely
containing 'podman' elsewhere (e.g. a user home) still doesn't match.
…podman

MOAT_RUNTIME=podman with DOCKER_HOST at a non-podman engine fails hard,
but the mirror case — MOAT_RUNTIME=docker with DOCKER_HOST at a podman
engine — was accepted with no identity check at all. Keep it accepted
(an explicit DOCKER_HOST is the user's deliberate endpoint choice, and
'docker' also names the client implementation actually in use, which
works unmodified against podman's compat API), but surface the mismatch
with a warning that points at --runtime podman.

The asymmetry is deliberate: 'podman' is purely an identity claim, so a
mismatch there is a contradiction and still fails. The safety concern
is backstopped by the unverified-gVisor warn-once and by engine-side
creation failure when runsc is absent. Identity detection is
best-effort: if IsPodmanEngine errors, say nothing rather than
speculate, matching doctor's unknown-identity stance.
'podman runtime requested ... but the podman socket was found but
unusable' read badly; now: 'a podman socket was found but is unusable'.
Live verification on Homebrew podman 6.0.1_1 contradicted the flat
'Podman 6.x defaults to the libkrun provider' claim: podman machine init
came up applehv/vfkit first try. The pitfall was observed on 6.0.0, so
the default is build-dependent. Keep the if-you-see-this-error guidance;
state the provider default as conditional.
Two lint failures in code this branch adds — both would fail CI's lint
job (upstream main lints clean at 0 issues):

- govet shadow in newPodmanRuntimeWithPing: the Ping error shadowed the
  outer err from NewDockerRuntime. Renamed to pingErr, matching the
  pingErr convention already used elsewhere in detect.go.
- gosec G112 in the fake Docker API test server: no ReadHeaderTimeout.
  Set to 10s, matching the rest of the codebase. (_test.go files have
  govet disabled but not gosec, so this one fires in tests.)
The podman changes carried roughly twice the commentary the surrounding
code does: 44% of added non-test lines were comments against a 19.5%
repo baseline, and 15.9% in tests against 7.8%. Now 32.6% and 12.1%,
within the range of recent feature work in this repo.

What went: rationale that belongs in a PR description rather than the
source — the design argument for the forced-runtime asymmetry, the
narration of which files a future ctx refactor would touch, and test
docs written as review responses. The DockerHost field carried a
17-line essay where its neighbour Runtime gets two.

What stayed: the non-obvious why. FromEnv-before-WithHost ordering and
what breaks if reversed, the basename-vs-parent socket match and the
custom-machine path that motivates it, why a matching socket path isn't
proof of engine identity, and the test seams that defeat a live dockerd
on CI runners.

Comments only — no code lines touched.
TestStopBenignNotFoundWhenEndpointRecorded set DockerHost to the real
docker socket. That routes through GetDockerAt, which builds a live
client for the endpoint and so bypasses the injected flexibleRuntime
entirely — stopFn was never called. The test passed only because a real
daemon answered "no such container", and failed outright anywhere
without Docker. CI has a daemon, so it would have stayed green forever
while asserting nothing.

Seed the pool with the stub for the pinned endpoint
(NewRuntimePoolWithDockerHost) and point the run at a path nothing
listens on, so resolution has to come from the seam. Assert StopContainer
actually ran, so the test can't go vacuous again.
The new loud failure fired whenever a docker-type run had no recorded
endpoint and its container came back not-found. Every run created before
endpoint recording existed has no docker_host and nothing backfills it,
so any pre-existing run whose container was removed out of band (docker
rm, a prune, a daemon reset) started hard-erroring instead of stopping
cleanly — a Docker-path regression landing on users who will never
install podman.

Gate it on a podman socket actually being present, which is the only
situation where "gone" and "running on the other engine" are genuinely
ambiguous. Hosts without podman keep the previous behavior. The check
stats the filesystem rather than dialing, to keep an error path cheap; a
stale socket file can still trigger the loud fail, which errs toward the
safe side.
--force meant one thing: skip the volume-mode extraction-snapshot guard.
It had quietly acquired a second, sharper meaning — tear down a still-
running run without stopping it — so anyone passing --force for the
data-loss guard silently also lost the running-run guard. It was also the
documented escape hatch for Stop's loud not-found, leaving the two
changes propping each other up.

Running-run teardown moves to --force-running. The flags are independent
in both directions, with a test that pins that.
…tically

podmanSocketCandidates globs $TMPDIR/podman/*-api.sock, and filepath.Glob
returns sorted results — so with machines "dev" and "podman-machine-default"
both running, dev-api.sock won on alphabetical order alone. IsPodmanEngine
can't disambiguate, because both really are podman.

Follow what podman itself would talk to: CONTAINER_CONNECTION when set,
otherwise the default connection recorded in podman-connections.json.
Non-default machines stay in the list as fallbacks, and an absent,
unreadable, or unmatched default leaves the order untouched. Machine
sockets now carry their machine name in the candidate description.

Verified on macOS by cross-compiling the package's test binary and running
it natively — the darwin-only paths never execute in CI's Linux runner.
…t order

podmanGvisorWarnOnce is process-global, so the warning could only ever be
seen by whichever test ran first — and nothing asserted it at all. Extract
the warn into warnPodmanGvisorUnverified, add a reset helper, and cover
both halves of the contract: the text names the cause and the escape
hatch, and it fires exactly once however many runtimes are built. A second
test guards the reset seam itself, so the first can't quietly stop
asserting.
The README's "macOS 15+" for Apple containers is stale — runtime detection
itself reports 26+ — but that is not a podman change and does not belong
in this branch. Reverted here and carried on its own branch
(fix/readme-apple-macos-version) so it can be reviewed on its own merits.
The podman additions to the same lines stay.
…cision

Three different stories were in play: the CHANGELOG asserted a flat
"Requires Podman >= 4.1", the installation guide said macOS auto-detection
needs the 5.x socket layout, and the PR described testing on 6.0.0. State
each precisely and separate the functional floor (4.1+ for host-gateway)
from the macOS auto-detection floor (5.x layout) from what was actually
exercised (6.0.0 and 6.0.1 on macOS arm64).

Also documents why "podman" exists as a runtime value when it isn't a
separate runtime — auto-detection only probes alternatives when the
default Docker socket is unreachable, so on a host running both engines it
always picks Docker, and the value additionally asserts engine identity
where a mis-set DOCKER_HOST would silently run on Docker — plus the new
--force-running flag and macOS machine selection.
Six package-level mutable vars had accumulated purely so tests could
redirect them — podmanRootfulSocket, newDefaultDockerRuntime,
xdgRuntimeDirFallback and podmanConnectionsPath among them. That put test
scaffolding in the shipped binary and left the package unsafe for
t.Parallel() without saying so. Collapse them into a single unexported
detectEnviron value with an export_test.go swap/restore helper, and state
the parallelism constraint where it can be seen. Defaults resolve exactly
as before.

The pool gets three fixes in the same pass. Its negative cache for a
failed endpoint had no TTL, reasoning by analogy to the unavailable map —
but that map caches runtime *types*, which don't come and go, whereas a
podman machine restarting mid-session is routine and moat has a
long-lived daemon; entries now expire after 30s against an injectable
clock. Close() and ForEachAvailable deduped only values that asserted to
*DockerRuntime, so a runtime seeded into both maps was closed twice and
visited twice; both now dedupe by identity. And ForEachAvailable's comment
claimed it made podman visible to clean/status, which overstates it: the
map is populated lazily by GetDockerAt, so an engine with no persisted
runs is still invisible, and the ordering that makes it work at all is an
unenforced cross-package contract. Say that instead.

Finally, tryDockerSocketCandidates had three hand-placed cancel() calls
and handed the verify callback a context the ping had already spent.
Extract the per-candidate body so defer can own the lifetime, give verify
its own timeout, and close the runtime on the rejection paths it was
leaking.
Forcing MOAT_RUNTIME=docker onto a podman socket used to pay a blocking
ServerVersion call on every startup where DOCKER_HOST was set, purely so
it could maybe print a warning — a tax on the common remote-Docker and
Rancher Desktop paths for a message they never see. Meanwhile the same
mismatch behaved three different ways depending on how it was reached:
hard error one way, warning another, silence on auto-detection.

Settle on two rules. Asking for podman and not getting it is an error,
because "podman" names the engine and getting Docker instead is simply
wrong. Every other identity fact is reported rather than warned about —
list and status show the engine per run, doctor reports the engine behind
the endpoint it can reach. Add EngineName as a thin cached wrapper over
IsPodmanEngine so identity costs one probe per runtime whenever something
actually needs it, and drop the eager one.

The gVisor caveat gets teeth. Podman's compat API lists every OCI runtime
in containers.conf whether or not the binary exists, so a reported runsc
proves nothing — moat can't verify the isolation it promises. Warning
about that is right, but the old text left the user nowhere to go. It now
names the check (podman machine ssh -- which runsc on macOS, which runsc
on Linux), and a container creation that fails under runsc on a podman
engine is wrapped with the likely cause instead of surfacing a raw OCI
error. The wrap preserves the original through %w so errdefs
classification still works, and it reuses the cached identity rather than
putting a network probe on an error path.
Because a podman run still records its Runtime as "docker", the display
label was recovered afterwards by sniffing the socket path — checking
whether path.Base(DockerHost) contained "podman", or its parent directory
was named "podman". That is a heuristic, applied to a URL, using path
functions, to recover something moat could simply have asked. It
mislabels a machine whose name lacks "podman" and podman over tcp://, and
it left list and status quietly disagreeing with doctor, which does ask
the engine.

Ask once, at creation, while a live connection is in hand: EngineName
returns an authoritative "docker" or "podman" and it is persisted
alongside the run. Engine is display-only and DockerHost keeps its own
job of reconnecting to the right endpoint; neither is derived from the
other. Identification is bounded and never fails Create — a run that
can't be labelled is still a run.

A run recorded before the field existed has no Engine and renders as
plain "docker". Empty means unknown, not "not podman", so nothing falls
back to sniffing the path again.
The ambiguous-not-found guard fired whenever a podman socket file existed
on disk. On any host with podman installed that turned the ordinary cases
into errors: a container removed by docker system prune, lost to a
reboot, started with --rm, or deleted by hand would fail moat stop with a
paragraph telling the user to run moat destroy --force-running. The
container wasn't orphaned, it was just gone. The guard is protecting
against a real bug — recording a run stopped while its container runs on
another engine — but it was catching the common case to reach the rare
one.

Ambiguity needs two engines, so require one: a podman endpoint that
answers a ping, identifies as podman, and is not the endpoint just
queried. That last clause matters on its own — where podman is the only
engine there is nothing to confuse it with, and the old condition would
have hard-failed that too.

Distinctness is decided by filesystem identity rather than by comparing
endpoint strings. With the podman-docker package installed,
/run/docker.sock is a symlink to /run/podman/podman.sock, so string
comparison sees two endpoints where one engine exists — and /var/run is
commonly a symlink to /run besides. A stat failure never skips a
candidate: guessing wrong in that direction hides an orphan.
The 4.1 floor was wrong. Podman accepts the host-gateway sentinel in
--add-host from 4.7.0, not 4.1, so every host between those versions was
being told it was supported. The floor is also Linux-only:
synthHostStrategy emits that sentinel just for a Docker-API runtime on a
Linux host, and macOS reaches the host through MOAT_EXTRA_HOSTS instead —
so the macOS rows were citing a constraint that never applied to them.
macOS is gated by the machine socket layout alone.

Three claims contradicted the code. The changelog told users to recover
with moat destroy --force, which stopped being true once the running-run
teardown got its own flag; destroy.go says plainly that the two are
independent. It also described moat stop as failing whenever a run has no
recorded endpoint, which is no longer the trigger. And both the changelog
and the runtimes doc said the recorded engine shows up in moat doctor —
doctor live-probes the endpoint it can reach and has no per-run display
at all, so that conflated two different things.

The runtimes doc claimed forcing podman only errors against a confirmed
non-podman engine; it also errors when DOCKER_HOST is unreachable, when
the engine can't be identified, when a socket is found but unusable, and
when no socket is found at all.

Also trims the two podman changelog bullets back toward the length of
their neighbours, moving the detail they were carrying into the reference
docs, and records what auto-detection does not cover: Windows has no
podman candidates, so nothing finds it there.
Both entries still linked to majorcontext#435, the earlier attempt that was closed
before review. Repoint them at majorcontext#444, which is where this work actually
lands.
@grinnellian grinnellian changed the title Feat/podman rebase feat(container): support podman via its Docker-compatible socket Jul 25, 2026
grinnellian added a commit to grinnellian/moat that referenced this pull request Aug 2, 2026
…ontext#444

PR majorcontext#435 is closed; work continues on feat/podman-rebase as upstream
PR majorcontext#444. This fork-meta plan stands as a historical record.

Co-Authored-By: Claude <noreply@anthropic.com>
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