Skip to content

feat(monitoring): migrate the stack to rootless Podman (CT 4001) — ADR-0009 Phase 2b - #308

Merged
ChrisonSimtian merged 2 commits into
mainfrom
feat/monitoring-podman-host-303
Jul 27, 2026
Merged

feat(monitoring): migrate the stack to rootless Podman (CT 4001) — ADR-0009 Phase 2b#308
ChrisonSimtian merged 2 commits into
mainfrom
feat/monitoring-podman-host-303

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Collaborator

Closes #303. Phase 2b of #283the last stack in the "clean" tier. Cutover is done and live.

4001  podman-host  Running   ← 10 containers + shared network, all state migrated
4000  monitoring   Stopped   ← preserved: config + rootfs + its own copy

Verified after cutover, not just at start

  • 5/5 Prometheus scrape targets UP — the real test, since the by-name wiring (exportarr-*, otel-collector, snmp_exporter) fails silently rather than failing a unit
  • Grafana: 2 dashboards, 3 datasources resolving by name
  • Pulse: healthy, api_tokens.json + alerts intact
  • Tempo: 3.3 G of blocks present
  • All 5 orchestrator_* series arriving through the new collector
  • Reboot: 10/10 units back in 5s, 5/5 targets UP after warm-up

3.9 GB migrated in 78s (tempo 3.3G · pulse 603M · grafana 50M · loki 64K), streamed node-locally.

No more hard-coded addresses

Both consumers now use monitoring.homelab.chrison.internal (UniFi local DNS, attached to the DHCP reservation so name and lease are defined in one place):

  • OTEL_EXPORTER_OTLP_ENDPOINT — PowerOrchestrator redeployed via the Fallout target, not hand-edited
  • stacks/Core/pangolin.lxc.yaml — the Pulse resource. Not in the original plan; found by grepping for the old IP. Pulse is publicly exposed, so the migration would have silently broken that route.

Three findings worth review

The Pangolin resource reconciler is add-only. Editing target.ip in the shape updates desired state but never touches an existing resource, so pulse.lab.chrison.dev stayed pointed at the stopped CT 4000 and returned errors until I PATCHed target 15 via the integration API. Now HTTP 200 → monitoring.homelab.chrison.internal:7655. Any future host migration hits this — worth its own issue.

Two of my own assumptions were wrong, both caught by checking live containers instead of inferring:

  • pulse runs as root, not 1000. Its data dir's 1000 ownership had me put keep-id:uid=1000 on it, which would have mapped container-root to a subuid and left it unable to write its own data.
  • prometheus runs as nobody (65534) and needs keep-id plus an explicit data volume — podman's anonymous volume isn't owned by the mapped uid, so it panicked on /prometheus/queries.active: permission denied. Fixing that also fixed a pre-existing flaw: compose declared no volume, so the TSDB was destroyed on every recreate. Metrics now persist — a behaviour improvement, flagged rather than slipped in.

Engine fixes in the first commit

Both found by deploying a real, large, multi-image stack:

  • Chunked asset push. feat(engine): render asset trees onto a podman host (config.assets) #305 embedded assets in the single pct exec script; 286 KiB of base64 reset the SSH connection with nothing executed. Measured limit: 96 KiB works, 128 KiB doesn't — my 1 MiB cap was ~10× too generous. Now 32 KiB chunks sent before the deploy script.
  • storage.conf with ignore_chown_errors. Rootless can't unpack distroless images (uid 65532) inside an LXC's 65536-uid window, and widening can't fix it. Running them additionally needs keep-id:uid=65532.

134 engine tests pass.

Follow-up (documented in podman-host/README.md)

Once CT 4000 is destroyed: delete compose.yml, deploy/install.sh, secrets.env.local.template, and the now-duplicated config/ + grafana/ trees. The duplication is deliberate — it preserves the rollback path while CT 4000 exists.

🤖 Generated with Claude Code

…nitoring stack

Two engine defects that only a real, large, multi-image stack exposes. Both found
on CT 4001 (#303).

1. ASSETS BLEW PAST THE COMMAND-LENGTH LIMIT. #305 embedded the whole asset tree
   in the single `pct exec` deploy script. The monitoring tree is 286 KiB of
   base64 (a 100 KB generated snmp.yml + a 97 KB Grafana dashboard) and the SSH
   connection was reset outright — "Connection reset by peer", with NOTHING
   executed, not even the first useradd. Not a timeout, so yesterday's keepalives
   were irrelevant.

   Measured on this path: 96 KiB of command line works, 128 KiB does not. So my
   1 MiB cap in #305 was ~10x too generous, and the guard I wrote specifically to
   prevent a baffling failure produced one.

   Fixed architecturally rather than with a smaller number: assets now go out as
   separate 32 KiB chunks BEFORE the deploy script (first chunk truncates, rest
   append, decode once), so tree size stops mattering. The cap is demoted to a
   16 MiB sanity bound. A test asserts no single command exceeds 64 KiB.

2. ROOTLESS CANNOT UNPACK DISTROLESS IMAGES without ignore_chown_errors. The
   exportarr image owns files as uid 65532 (/home/nonroot), which is outside the
   subuid window the LXC's own 65536-uid map allows:
     "potentially insufficient UIDs or GIDs available in user namespace
      (requested 65532:65532 for /home/nonroot)"
   Widening the range can't fix it — mapping container uid 65532 needs ~65533
   subuids inside a 65536-wide window, leaving nothing for real accounts. The
   provisioner now writes ~/.config/containers/storage.conf with
   ignore_chown_errors=true, before anything pulls an image.

   Note that only fixes the PULL. RUNNING such an image as its own high uid
   additionally needs `UserNS=keep-id:uid=<uid>` on the quadlet, or crun refuses:
   "setgroups: Invalid argument". Verified both halves by hand before encoding.

134 engine tests pass (2 new; 3 obsolete ones replaced — they asserted assets
appear inside the deploy script, which is now wrong by design).

Refs #303, #305
…tover complete

Closes #303. Phase 2b of #283 — the last stack in the "clean" tier.

CT 4001 replaces the Docker host CT 4000: 10 containers + a shared network as
quadlets, 9 config/dashboard files as converge-rendered assets, 4 podman secrets.
CT 4000 is STOPPED but NOT destroyed.

3.9 GB of state migrated (tempo 3.3G, pulse 603M, grafana 50M, loki 64K), streamed
node-locally in 78s. Verified after cutover: Grafana's 2 dashboards + 3 name-
resolving datasources, Pulse healthy with its api_tokens/alerts intact, tempo's
3.3G of blocks present, and 5/5 Prometheus scrape targets UP — which is the real
test, since the by-name wiring (exportarr-*/otel-collector/snmp_exporter) fails
silently rather than failing a unit.

NO MORE HARD-CODED ADDRESSES. CT 4000 pinned 10.10.0.40 and two things referenced
it; both now use monitoring.homelab.chrison.internal (UniFi local DNS, attached to
the DHCP reservation so name and lease are defined in one place):
  - OTEL_EXPORTER_OTLP_ENDPOINT in secrets.env.template
  - stacks/Core/pangolin.lxc.yaml — the Pulse resource. This one was NOT in the
    original plan; found by grepping for the old IP. Pulse is publicly exposed via
    Pangolin, so the migration would have silently broken that route.
Verified resolvable from CT 2013 (Pangolin, different node) before switching it to
the name rather than the new IP.

PowerOrchestrator redeployed via the Fallout target so it picks the endpoint up
from secrets.env rather than being hand-edited; all five orchestrator_* series
confirmed arriving through the new collector.

Two corrections to earlier assumptions, both caught by checking the live
containers instead of inferring:
  - pulse runs as ROOT, not 1000. Its data dir being uid 1000 had me put
    keep-id:uid=1000 on it, which would have mapped container-root to a subuid and
    left it unable to write its own data. Removed — the default mapping is right.
  - prometheus runs as nobody (65534) and needs keep-id, plus an EXPLICIT data
    volume: podman's anonymous volume isn't owned by the mapped uid, so it panicked
    on "/prometheus/queries.active: permission denied". Fixing that also fixed a
    pre-existing flaw — compose declared no volume at all, so the TSDB was destroyed
    on every container recreate. Metrics now persist; a behaviour improvement, not
    just a port.

Runbook gained a wipe-before-extract step: by cutover time CT 4001 had generated
its own state, and a plain tar -x would have merged old over new.

Closes #303
@ChrisonSimtian
ChrisonSimtian merged commit 9276d8f into main Jul 27, 2026
6 checks passed
@ChrisonSimtian
ChrisonSimtian deleted the feat/monitoring-podman-host-303 branch July 27, 2026 08:42
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.

Phase 2b: migrate the Monitoring stack (CT 4000, 10 containers) to a rootless Podman host

1 participant