Skip to content

feat(engine): app: podman rootless quadlet platform (ADR-0009 Phase 0) - #294

Merged
ChrisonSimtian merged 4 commits into
mainfrom
feat/podman-platform-284
Jul 26, 2026
Merged

feat(engine): app: podman rootless quadlet platform (ADR-0009 Phase 0)#294
ChrisonSimtian merged 4 commits into
mainfrom
feat/podman-platform-284

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Collaborator

Phase 0 of #283, implementing ADR-0009. Closes #284.

Builds the reusable platform so a stack can be provisioned as rootless Podman + quadlets, with systemd + git as the control plane. Verified live on a throwaway CT (9900 on desktop-01, destroyed — node back to its original 18 guests).

How it's composed

The create half needed no new code path: community-scripts ships ct/podman.sh, and CommunityScriptsCreator already runs pct create as root over SSH — the only way to set keyctl on an unprivileged LXC — and already renders var_unprivileged/nesting/keyctl/fuse from features:. So creation is just declining podman-install.sh's two Portainer prompts, alongside the existing docker entry.

PodmanProvisioner owns the post-create half, converting the stock rootful Podman CT into the rootless quadlet host: masks the root podman.socket, adds the non-root user with an in-map subuid range + linger, renders the stack's quadlets, drives systemctl --user, seeds podman secrets from secrets.env, and enables podman-auto-update.timer (replacing Watchtower and its docker.sock).

Acceptance (all met)

Criterion Result
Throwaway app: podman LXC provisions end-to-end unattended ✅ one shot, 6m20s, 3 applied / 0 failed
Hello-world quadlet deploys, starts as a --user unit, survives a reboot ✅ active 3s after boot
Nested subuid/subgid verified inside the LXC's uid_map podman:10000:50000, guarded against /proc/self/uid_map
systemctl --user reliably driveable over pct exec runuser + explicit XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS

Idempotency confirmed: repeat converges report NOCHANGE, and the marker is deterministic across full rebuilds (e71314bdca83 both times).

Seven defects found by live-provisioning, not by reading docs

Written up in full in docs/plans/284-podman-platform.md.

  1. Rootless networking needs /dev/net/tun, which an LXC lacks. pasta/slirp4netns open it to build the tap device, so every container start died. ADR-0009 anticipated no routable per-container IP, but not this. Fixed host-side by bind-mounting the /dev/net directory (binding the file fails with no parent to mount onto). The host node is mode 666, so an unprivileged CT can open it — no privileged container needed.
  2. ct/podman.sh silently drops var_fuse — shape said fuse: true, CT came up nesting=1,keyctl=1, twice. Features are now reconciled from the shape, merging so an undeclared mount=nfs isn't stripped.
  3. pct status reports running the instant a reboot is requested, before shutdown begins. A status-based wait returned immediately, the next pct exec attached mid-shutdown and blocked forever, which wedged the reboot so the CT never restarted. Two 10-minute hangs. Replaced with stop → wait for stopped → start → poll systemctl is-system-running, every probe timeout-wrapped.
  4. runuser keeps cwd /root, which the rootless user can't read → cannot chdir to /root. Insidious because systemctl --user worked fine while podman secret create would have failed.
  5. Quadlets started 92s late, every boot. Podman injects Wants=/After=podman-user-wait-network-online.service (Podman fail to autostart containers through quadlet/systemd, works when launched manually, error with pasta podman-container-tools/podman#22197), which spins on systemctl is-active network-online.target — never reached in a community-scripts Debian LXC (ifupdown is in use; only systemd-networkd wait-online units ship). It timed out, failed, and only then did the container start. This is the one I'd have shipped broken: it does survive a reboot, just by waiting out a failure, so a naive check passes. Fixed the cause with a oneshot ordered after networking.service. 92s → 2s.
  6. The marker hashed only its inputs, not the deploy recipe — so the fix for (5) reported NOCHANGE against the already-stamped CT and never landed. The marker now folds in a hash of the generated script, so a provisioner fix invalidates it on exactly the hosts that need it.
  7. SSH keepalives. A create holds one ssh session open for 30+ minutes on a cold template; without keepalives it was reset mid-create and converge reported CREATE FAILED for a create that had actually succeeded — the worst shape, since the next run must reconcile a CT the plan thought absent. Benefits every provisioner; the self-hosted runner on the node LAN is far less exposed than a laptop across VLANs, which is why it hadn't bitten before.

Two more are documented rather than coded, since they're authoring rules: quadlet Exec= goes through systemd specifier expansion so a literal % must be %%, and boot survival requires [Install] WantedBy=default.target (generated units can't be systemctl enabled).

Testing

123 unit tests pass (24 new), covering idempotency, the userns guard, the rootless posture, secret handling, and regression guards for defects 1–6.

Notes for review

  • Shape gains a loader-set SourceDir so a provisioner can read sibling assets (the quadlet files) from the stack dir instead of requiring them inline.
  • Podman secrets are add-only (exists || create) — rotation stays an explicit operator action, never a silent converge side effect. Values go via stdin, never argv.
  • Nothing outside the engine + schema catalogue changes; no existing stack is migrated here. Pilot (Leapmotor CT 4100) is Pilot: migrate Leapmotor (CT 4100) to rootless Podman + quadlet (Phase 1) #285.

🤖 Generated with Claude Code

ChrisonSimtian and others added 4 commits July 26, 2026 16:16
Phase 0 of #283 (issue #284): the reusable platform for provisioning a stack
as rootless Podman + quadlets, with systemd + git as the control plane.

Create half needs no new path: community-scripts ships ct/podman.sh, and
CommunityScriptsCreator already runs `pct create` as root over SSH (the API
token can't set keyctl on an unprivileged LXC) and already renders
var_unprivileged/nesting/keyctl/fuse from the shape's `features:`. So the
create half is just declining podman-install.sh's two Portainer prompts,
alongside the existing docker entry.

PodmanProvisioner owns the post-create half — converting the stock *rootful*
Podman CT into the rootless quadlet host ADR-0009 decided on:

- masks the ROOT podman.socket that podman-install.sh enables (the rootless
  model owns no socket — the thing this migration exists to remove);
- adds a non-root user with a subuid/subgid range that fits inside the LXC's
  OWN userns map, verified at apply time against /proc/self/uid_map. This is
  the nested-userns trap #284 flagged: an unprivileged LXC only has uids
  0..65535, so the host convention of 100000:65536 points outside the map and
  podman fails at first run. Default is 10000:50000, guarded not assumed;
- `loginctl enable-linger` + a bounded wait for /run/user/<uid>/bus, so user
  units start at boot and the first `systemctl --user` doesn't race it;
- renders the stack's quadlet files into ~<user>/.config/containers/systemd/
  and drives `systemctl --user` over pct exec via runuser + an explicit
  XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS (pct exec has no login session, no
  tty, no PAM env; this avoids depending on machinectl/systemd-container);
- seeds podman secrets from secrets.env, add-only, values via stdin not argv;
- enables podman-auto-update.timer, replacing Watchtower and its docker.sock.

Idempotent via a managed marker hashed over the quadlet CONTENT (so editing a
.container re-deploys) and stamped LAST, so a partial failure re-runs.

Shape gains a loader-set SourceDir so a provisioner can read sibling assets
from the stack dir instead of requiring them inline.

Live acceptance (throwaway CT, hello-world quadlet across a reboot) is still
outstanding — the 18 new unit tests cover idempotency, the userns guard, the
rootless posture and secret handling.

Refs #284, #283, ADR-0009

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…away CT

Six defects the unit tests could not have found, each caught by actually
provisioning CT 9900 on desktop-01 (destroyed after). All are now encoded in
the provisioner + regression tests, and written up in docs/plans/284.

1. Rootless networking needs /dev/net/tun, which an LXC does not have. pasta
   (and slirp4netns) open it to build the tap device, so EVERY container start
   died with "pasta failed ... Failed to open() /dev/net/tun". ADR-0009
   anticipated rootless having no routable IP but not this prerequisite. Added
   host-side: `lxc.mount.entry: /dev/net dev/net none bind,create=dir` (bind the
   DIR — binding the file fails with no /dev/net parent to mount onto). The host
   node is mode 666, so an unprivileged CT can open it — no privileged container.

2. ct/podman.sh silently drops var_fuse: the shape declared fuse: true and the
   CT came up `nesting=1,keyctl=1`, twice. The shape is the source of truth, so
   features are now reconciled via `pct set --features`, merging rather than
   replacing (an undeclared mount=nfs is not ours to remove).

3. `pct status` reports "running" the instant a reboot is requested — before
   shutdown even begins. A status-based wait therefore returned immediately, the
   next `pct exec` attached to a container mid-shutdown and blocked FOREVER,
   which wedged the reboot so the CT never restarted. Two 10-minute converge
   hangs came from this. Replaced with explicit stop → wait for stopped → start
   → poll `systemctl is-system-running`, every probe timeout-wrapped.

4. `runuser` keeps the caller's cwd and the rootless user cannot read /root, so
   podman failed with "cannot chdir to /root". Insidious: systemctl --user was
   fine (systemd sets its own cwd) while `podman secret create` would fail.
   The script now cd's to / first.

5. Quadlets did not start at boot for 92 SECONDS. Podman injects
   Wants=/After=podman-user-wait-network-online.service into every generated
   unit (podman-container-tools/podman#22197); that helper spins on `systemctl is-active
   network-online.target`, which a community-scripts Debian LXC never reaches
   (ifupdown is in use; only systemd-networkd wait-online units ship). It timed
   out, failed, and only then did the container start — CT booted 18:12:04, unit
   active 18:13:36. "Survives a reboot" only by waiting out a failure, which is
   why a naive check passes. Fixed the cause with a oneshot ordered after
   networking.service that makes the target reachable; masking the helper would
   also work but discards the readiness guarantee. Now 2s, not 92s.

6. The marker hashed only its INPUTS, so changing the deploy recipe did not
   re-converge: the fix for (5) reported NOCHANGE against the already-stamped CT
   and never landed. The marker now folds in a hash of the generated script, so
   a provisioner fix invalidates it on exactly the hosts that need it.

Also learned (documented, not code): quadlet Exec= goes through systemd
specifier expansion so a literal % must be %% ("Invalid slot" otherwise), and
boot survival requires [Install] WantedBy=default.target in the quadlet since
generated units cannot be `systemctl enable`d.

Refs #284, #283, ADR-0009

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A community-scripts create holds ONE ssh session open for the whole template
download + apt install — 30+ minutes on a cold template. Without keepalives
something in between resets the idle-looking connection.

Observed 2026-07-26 provisioning the throwaway podman CT 9900 from a laptop
across VLANs: "Read from remote host ... Connection reset by peer" after 31
minutes. The CT was actually created but the result never came back, so
converge reported CREATE FAILED for a create that had in fact worked — the
worst kind of failure, since the next run then has to reconcile a CT the plan
thought didn't exist.

30s × 10 tolerates ~5 minutes of silence. Benefits every provisioner, not just
podman; the canonical path (self-hosted runner on the node LAN) is far less
exposed to this than a laptop is, which is why it hadn't bitten before.

Refs #284

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was only recorded in a commit message, so the doc's list was incomplete and
its count wrong. Also flags which gotchas the engine handles for you vs which
are rules a quadlet author must follow (nothing enforces #5/#6 yet).

Refs #284

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChrisonSimtian
ChrisonSimtian merged commit 6f723e8 into main Jul 26, 2026
@ChrisonSimtian
ChrisonSimtian deleted the feat/podman-platform-284 branch July 26, 2026 08:08
ChrisonSimtian added a commit that referenced this pull request Jul 26, 2026
Closes the settings half of #296.

Settings had diverged into three different configurations across four repos,
none matching what CLAUDE.md claimed. `Homelab` had squash DISABLED, so the
documented merge command failed outright ("Squash merges are not allowed on
this repository") — found only when merging #294. SmartHome and BuildLab were
still at GitHub defaults from their extraction (#280/#281): merge commits
allowed, merged branches never deleted (Homelab accumulated 42 stale remote
branches before a prune — exactly what delete_branch_on_merge prevents).

Decision: rebase by default, squash as the escape hatch, merge commits never,
delete-on-merge everywhere. Both rebase and squash give a linear history — the
real axis is commit granularity, so the author picks per PR: keep the commits
when each tells part of the story, squash when the branch is WIP-heavy.

The convention is a SCRIPT, not a paragraph (scripts/align-repo-settings.sh).
A fresh GitHub repo starts at defaults and this project creates one per stack
extraction, so a prose rule drifts on the next extraction by construction.
Idempotent, read-then-write (a no-op run makes zero mutating calls), and it
skips archived repos — Komodo and ServArr are read-only by design, which is
correct state rather than drift.

Applied: 6 live repos aligned, 2 archived skipped, re-run reports all OK.

Two portability bugs worth noting, both hit while writing it: `mapfile` needs
bash 4 (macOS ships 3.2), and a literal U+2192 arrow directly after `$have_rebase`
got its first UTF-8 byte absorbed into the identifier by byte-oriented bash,
failing with "unbound variable" — same class as the CP1252 provisioner corruption
found during the BuildLab VM work.

Refs #296

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChrisonSimtian added a commit that referenced this pull request Jul 26, 2026
Closes #299. engine-ci only ran `dotnet restore` + `dotnet build`, so a green
check meant "it compiles" and nothing more — the engine's 123 tests (idempotency,
the nested-userns guard, the rootless posture) had never executed in CI at all.

Driven through Fallout rather than raw `dotnet`, so the repo keeps ONE build
entrypoint (ADR-0001) and CI can't drift from what runs locally — the same
reason power-orchestrator-ci calls `./build.sh TestPowerOrchestrator` (which,
checked while here, does already run its tests).

- build/Build.cs: `TestEngine` target, DependsOn(CompileEngine). No --no-build,
  because CompileEngine builds the engine project only — not the test project
  (unlike PowerOrchestrator, where Compile builds the whole solution).
- Zero-tests guard: `dotnet test` exits 0 when it discovers nothing, so a
  renamed or moved test project would leave the gate green while testing
  nothing. The target parses the run total and throws if it's zero. Verified:
  exit 255 with a filter matching no tests, exit 0 normally.
- engine-ci.yml: `./build.sh TestEngine`, and the paths trigger now includes
  Infrastructure/engine.Tests/** (a test-only PR previously didn't run CI at
  all), Infrastructure/schema/** (the catalogue drift-guard test reads it),
  build/** and global.json.
- engine-ci now needs secrets.PACKAGES_PAT rather than GITHUB_TOKEN: going
  through Fallout means restoring Fallout.* from the Fallout-build org's
  GitHub Packages feed, and the default GITHUB_TOKEN cannot read packages
  across orgs.

The new gate immediately caught a real defect on its first run — a test I added
in #294 asserted a MISSING secret using MATE_AUTH_PASSWORD as the example key,
and that key became REAL an hour earlier (#285). SecretsEnv.Load(null)
deliberately folds in process env vars, so the test passed or failed depending
on whether the developer had sourced secrets.env. Now uses a sentinel key per
the HL<issue>_TEST_* convention already established in ConvergeCoreTests, with
a comment explaining why it must never be a realistic name.

.fallout/build.schema.json regenerates to include TestEngine (and
PublishValidator, which had never been captured).

Closes #299
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.

Podman platform groundwork: app: podman engine path + quadlet render/deploy (Phase 0)

1 participant