feat(engine): app: podman rootless quadlet platform (ADR-0009 Phase 0) - #294
Merged
Conversation
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>
This was referenced Jul 26, 2026
Closed
Closed
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>
This was referenced Jul 26, 2026
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
This was referenced Jul 26, 2026
Merged
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.
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, andCommunityScriptsCreatoralready runspct createas root over SSH — the only way to setkeyctlon an unprivileged LXC — and already rendersvar_unprivileged/nesting/keyctl/fusefromfeatures:. So creation is just declining podman-install.sh's two Portainer prompts, alongside the existingdockerentry.PodmanProvisionerowns the post-create half, converting the stock rootful Podman CT into the rootless quadlet host: masks the rootpodman.socket, adds the non-root user with an in-map subuid range + linger, renders the stack's quadlets, drivessystemctl --user, seeds podman secrets fromsecrets.env, and enablespodman-auto-update.timer(replacing Watchtower and itsdocker.sock).Acceptance (all met)
app: podmanLXC provisions end-to-end unattended--userunit, survives a rebootpodman:10000:50000, guarded against/proc/self/uid_mapsystemctl --userreliably driveable overpct execrunuser+ explicitXDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESSIdempotency confirmed: repeat converges report
NOCHANGE, and the marker is deterministic across full rebuilds (e71314bdca83both times).Seven defects found by live-provisioning, not by reading docs
Written up in full in
docs/plans/284-podman-platform.md./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/netdirectory (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.ct/podman.shsilently dropsvar_fuse— shape saidfuse: true, CT came upnesting=1,keyctl=1, twice. Features are now reconciled from the shape, merging so an undeclaredmount=nfsisn't stripped.pct statusreportsrunningthe instant a reboot is requested, before shutdown begins. A status-based wait returned immediately, the nextpct execattached mid-shutdown and blocked forever, which wedged the reboot so the CT never restarted. Two 10-minute hangs. Replaced with stop → wait forstopped→ start → pollsystemctl is-system-running, every probetimeout-wrapped.runuserkeeps cwd/root, which the rootless user can't read →cannot chdir to /root. Insidious becausesystemctl --userworked fine whilepodman secret createwould have failed.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 onsystemctl 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 afternetworking.service. 92s → 2s.NOCHANGEagainst 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.CREATE FAILEDfor 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 besystemctl 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
Shapegains a loader-setSourceDirso a provisioner can read sibling assets (the quadlet files) from the stack dir instead of requiring them inline.exists || create) — rotation stays an explicit operator action, never a silent converge side effect. Values go via stdin, never argv.🤖 Generated with Claude Code