Skip to content

feat(observe): podman metrics + prune timer (ADR-0009 phase 3) - #313

Merged
ChrisonSimtian merged 2 commits into
mainfrom
feat/podman-observe-283
Jul 27, 2026
Merged

feat(observe): podman metrics + prune timer (ADR-0009 phase 3)#313
ChrisonSimtian merged 2 commits into
mainfrom
feat/podman-observe-283

Conversation

@ChrisonSimtian

Copy link
Copy Markdown
Collaborator

Advances #283 phase 3 ("observe"). The migration removed docker.sock and put nothing in its place, so the three rootless hosts had zero container-level visibility — systemd knew whether a unit was up, but nothing exported restart counts, image ages or per-container resource use.

Result: Prometheus now reports 7/7 targets UP, including both new podman exporters.

Engine (platform-wide)

  • Weekly podman-system-prune.timer as USER units, replacing the prune sidecar ADR-0009 retired. Deliberately not --volumes: an unattached volume may hold a stopped service's database, and losing it to a housekeeping timer would be unrecoverable. Images/containers/networks only, older than 168h.
  • Opt-in rootless API socket (config.userSocket). The ROOT podman.socket stays masked — this is a socket owned by the unprivileged podman user inside its own userns, which exists because an exporter has no other way to enumerate containers. Off by default, so a host that exports nothing keeps no API surface.
  • Both now run BEFORE app units start. Found the hard way: when podman-exporter failed to start, set -e aborted the script and the timer + socket steps never ran at all — platform housekeeping was hostage to an application unit. A unit that Requires=podman.socket also needs the socket up first. Regression-tested in both directions.

Stacks

podman-exporter quadlet on the Monitoring and Media hosts + a podman scrape job.

The exporter took three attempts, all recorded in the quadlet header so nobody repeats them:

  1. the image's own uid 65534crun: setgroups: Invalid argument (outside the subuid window an LXC's 65536-uid map allows);
  2. keep-id:uid=65534 fixed that but left the process with no home → died on stat /home/.config: no such file or directory looking for containers.conf;
  3. User=0 under the default mapping — container-root maps to the unprivileged podman user, which is exactly who owns the socket. No keep-id, no home hack, and no privilege gain.

CT 5114 also gained the DHCP reservation + DNS name it was missing (media.homelab.chrison.internal), so the new scrape target is a name — the IP I first wrote there would have re-introduced exactly what #303 removed.

Deliberately not done

  • The SmartHome host (CT 6004) is unscraped. It sits on IoT VLAN 1040, firewalled off from 1010 — verified with a TCP probe from CT 4001, not assumed. Reaching it needs a narrow UniFi allowance (1010 → 1040 on :9882), which loosens the IoT VLAN's isolation and is an owner decision.
  • Cockpit — the other half of phase 3 — is untouched. It defaults to :9090, which Prometheus already publishes on CT 4001, and it sits awkwardly against the repo's stated minimal UI reliance now that Grafana covers observability. Wants a decision rather than an assumption.

138 engine tests pass.

🤖 Generated with Claude Code

Advances #283 phase 3 ("observe"). The migration removed docker.sock and put
nothing in its place, so the three rootless hosts had ZERO container-level
visibility: systemd knew whether a unit was up, but nothing exported restart
counts, image ages or per-container resource use.

Engine (platform-wide):
- Weekly `podman-system-prune.timer` as USER units, replacing the prune sidecar
  ADR-0009 retired. Deliberately NOT --volumes: an unattached volume may hold a
  stopped service's database, and losing it to a housekeeping timer would be
  unrecoverable. Images/containers/networks only, older than 168h.
- Opt-in rootless API socket via `config.userSocket`. The ROOT podman.socket
  stays masked — this is a socket owned by the unprivileged `podman` user inside
  its own userns, which exists because an exporter has no other way to enumerate
  containers. Off by default, so a host that exports nothing has no API surface.
- Both now run BEFORE app units start. Found the hard way: when podman-exporter
  failed to start, `set -e` aborted the script and the timer + socket steps never
  ran at all — platform housekeeping was hostage to an application unit. A unit
  that Requires=podman.socket also needs the socket up first. Regression-tested.

Stacks: podman-exporter quadlet on the Monitoring and Media hosts, plus a
`podman` scrape job. Prometheus now reports 7/7 targets UP.

The exporter took three attempts, all recorded in the quadlet header:
  1. image's own uid 65534 → crun "setgroups: Invalid argument" (outside the
     subuid window an LXC's 65536-uid map allows);
  2. keep-id:uid=65534 fixed that but left no home → died on
     "stat /home/.config: no such file or directory" seeking containers.conf;
  3. User=0 under the DEFAULT mapping — container-root maps to the unprivileged
     `podman` user, which is exactly who owns the socket. No keep-id, no home
     hack, and no privilege gain.

CT 5114 also gained the DHCP reservation + DNS name it was missing
(media.homelab.chrison.internal), so the new scrape target is a NAME — the IP I
first wrote there would have re-introduced exactly what #303 removed.

NOT done, and deliberately:
- The SmartHome host (CT 6004) is unscraped. It sits on IoT VLAN 1040, firewalled
  off from 1010; verified with a TCP probe from CT 4001. Reaching it needs a
  narrow UniFi allowance (1010 -> 1040 on :9882), which loosens the IoT VLAN's
  isolation and is an owner decision.
- Cockpit (the other half of phase 3) is untouched: it defaults to :9090, which
  Prometheus already publishes on CT 4001, and it sits awkwardly against the
  repo's stated minimal-UI-reliance. Needs a decision, not an assumption.

138 engine tests pass.

Refs #283
…0009 phase 3)

Completes phase 3 of #283. Cockpit is the management UI ADR-0009 named, now on
all three rootless hosts.

Prometheus moves to host port 9091 so Cockpit can have its default 9090. Only the
PUBLISHED port changes — the container still listens on 9090, so Grafana's
datasource (http://prometheus:9090, over the quadlet network) needed no change at
all. Verified after: 7/7 scrape targets UP and "Prometheus Server is Healthy"
from inside the Grafana container.

Cockpit deliberately authenticates as the `podman` USER, not root: rootless
containers exist only inside that user's session, so a root Cockpit session shows
an empty container list. Both `podman` and `root` ship password-LOCKED on a
community-scripts CT, so the provisioner sets the user's password from
PODMAN_USER_PASSWORD (generated, in Bitwarden SM). cockpit-podman then reads the
same rootless socket the exporter uses — which is why cockpit requires userSocket.
Requesting cockpit without the secret FAILS loudly rather than installing a login
UI nobody can log into.

Ordering bug found live on CT 4001: enabling cockpit.socket before the app units
restart fails with `Result: resources`, because Prometheus still holds :9090 on
its old published port until it restarts. The package + password now land before
app units (so they exist even if a unit later fails) while the SOCKET is enabled
last, after units have rebound. Regression-tested in both directions.

DNS: all three podman hosts are now resolvable by name —
monitoring/media/smarthome.homelab.chrison.internal. The SmartHome host had no
name at all; added via UniFi's standalone static-DNS API rather than a DHCP
reservation, since that host is only ever reached by name.

141 engine tests pass.

Refs #283
@ChrisonSimtian
ChrisonSimtian merged commit 623b07e into main Jul 27, 2026
6 checks passed
@ChrisonSimtian
ChrisonSimtian deleted the feat/podman-observe-283 branch July 27, 2026 23:48
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