Skip to content

feat(podman-host): SmartHome rootless Podman + quadlet host (CT 6004, ADR-0009 Phase 1) - #4

Merged
ChrisonSimtian merged 2 commits into
mainfrom
feat/podman-host-285
Jul 26, 2026
Merged

feat(podman-host): SmartHome rootless Podman + quadlet host (CT 6004, ADR-0009 Phase 1)#4
ChrisonSimtian merged 2 commits into
mainfrom
feat/podman-host-285

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Contributor

Phase 1 of the Podman migration — Homelab #285, on the platform landed in Homelab #284 / ADR-0009.

What this adds

The stack's container host — deliberately generic, not a Leapmotor box. Leapmotor Mate is simply the first service to live on it; further SmartHome services join as extra quadlets in podman-host/quadlets/ rather than as new one-app Docker CTs. Mate was chosen as the pilot because it's the smallest live stack (one container, no NFS mount, all state on local disk), so the fiddly rootless plumbing proves itself where the blast radius is smallest.

CT 6004 In-block for this stack (6000–6099) — unlike the CT 4100 it replaces, which was adopted in-place out-of-block. Getting Mate back inside the block is part of the point.
VLAN 1040 (IoT) Internal-only, no tunnel ingress — Mate holds dedicated-Leapmotor-account credentials
Rootless user podman subuid 10000:50000, verified against the LXC's own uid map

Secrets

MATE_AUTH_PASSWORD is wired as a podman secret (registered in Bitwarden SM and the superproject's secrets.env.template), not an env file.

MATE_SECRET_KEY is deliberately not set. Mate auto-generates it into its data dir when the variable is absent — which is exactly what CT 4100 does today, since its .env has the key blank — and it must stay paired with the migrated database or the stored account credentials become undecryptable. Setting it here would silently break the migration.

The cutover needs a short downtime — an upstream constraint, not a Podman one

Leapmotor allows ~one active session per account, so the old and new Mate cannot both run logged-in simultaneously; they'd keep kicking each other out. The host and unit are built side-by-side safely (image pulled, unit valid), but the handover itself is exclusive.

Second constraint: 22 MB of live state in CT 4100's /opt/leapmotor/data — SQLite telemetry history, uploaded mTLS certs, and the generated secret key. It moves as one piece. MQTT settings live in that same SQLite DB (configured via Mate's UI), so they come across with the data and neither the broker nor Home Assistant needs re-wiring.

Full runbook in podman-host/README.md. Rollback is pct start 4100 + docker start leapmotor-mate — the migration duplicates state rather than moving it, so the old CT keeps its own untouched copy.

CT 4100 is left STOPPED, not destroyed. Deleting it is a deliberate manual call once the new host has proven itself.

Note on applying this

The engine has no per-member converge filter, so converge stacks/SmartHome --apply would also touch two things this change has no business touching: a tag drift-fix on CT 4100 and a SetConfig on the adopted, describe-only HA VM 2000. CT 6004 was therefore applied from an isolated stack dir containing only this member. Worth a follow-up (--only <member>) if this pattern recurs.

🤖 Generated with Claude Code

… ADR-0009 Phase 1)

Phase 1 of the Podman migration (Homelab#285), on the platform landed in
Homelab#284. Adds the stack's container HOST — deliberately generic, not a
Leapmotor box. Leapmotor Mate is simply the first service to live on it,
chosen as the pilot because it's the smallest live stack (one container, no
NFS, state on local disk) so the fiddly rootless plumbing proves itself where
the blast radius is smallest. Future SmartHome services join as extra quadlets
rather than as new one-app Docker CTs.

- podman-host.lxc.yaml — CT 6004, IN-BLOCK for this stack (6000–6099), unlike
  the CT 4100 it replaces which was adopted in-place out-of-block. Getting Mate
  back inside the block is part of the point.
- podman-host/quadlets/leapmotor-mate.container — converted from
  leapmotor-mate/compose.yml. Bind mounts under the rootless user's home rather
  than named volumes, because the migration seeds 22 MB of EXISTING state and a
  host path is far easier to seed, inspect and back up.
- MATE_AUTH_PASSWORD wired as a podman secret (registered in Bitwarden SM +
  the superproject's secrets.env.template). MATE_SECRET_KEY deliberately NOT
  set: Mate auto-generates it into its data dir when unset — which is exactly
  what CT 4100 does today (its .env has the key blank) — and it must stay
  paired with the migrated database or the stored credentials become
  undecryptable.

Cutover needs a SHORT downtime, and that's an upstream constraint rather than a
Podman one: Leapmotor allows ~one active session per account, so the old and new
Mate cannot both run logged-in at once. The host and unit are built side-by-side
safely; the handover itself is exclusive. Runbook in podman-host/README.md, with
rollback = start CT 4100 again (the copy duplicates state, never moves it).

CT 4100 is left STOPPED, not destroyed — deleting it is a deliberate manual call
once the new host has proven itself.

Refs Homelab#285, Homelab#283, ADR-0009
Both surfaced immediately on the first real deploy; neither is findable by
reading the compose file it was converted from.

1. Podman does NOT create missing bind-mount source directories (Docker does),
   so the unit crash-looped: "Error: statfs /home/podman/leapmotor/certs: no
   such file or directory", six restarts then "start request repeated too
   quickly". Fixed with an ExecStartPre mkdir so the unit is self-sufficient —
   a first start creates empty dirs (Mate boots to its setup wizard) and the
   migration later seeds them with CT 4100's real state.

2. HealthCmd cannot carry a quoted command, so the healthcheck is REMOVED.
   Quadlet takes systemd's word-split value and rejoins it, destroying inner
   quoting however it's written. Two attempts, both verified broken live:
     - bare inner quotes  -> podman received an unterminated string; the check
       failed every 30s with `/bin/sh: Syntax error: Unterminated quoted string`
       and the container sat permanently "unhealthy" while the app served 200;
     - escaped \" quotes  -> the backslashes survived literally.
   The compose healthcheck needs a quoted python one-liner (the image ships no
   curl/wget), so it cannot be expressed as a quadlet. Restart=always already
   covers process death; what's lost is hung-but-alive detection, and a
   permanently-unhealthy container is worse than none. Restoring one needs a
   QUOTE-FREE command (e.g. `HealthCmd=python /opt/health.py`), which requires
   the provisioner to render non-quadlet assets — a platform change, not a
   quadlet tweak.

Verified on CT 6004 (hpe-01, 10.40.207.170): unit active, container up, podman
secret seeded, app HTTP 200, and boot survival 4s after an LXC restart with
podman-auto-update.timer active.

Refs Homelab#285
@ChrisonSimtian
ChrisonSimtian merged commit 5e42a78 into main Jul 26, 2026
@ChrisonSimtian
ChrisonSimtian deleted the feat/podman-host-285 branch July 26, 2026 10:32
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