Files
command-center/agent/managed-skills/compose-to-quadlet-cutover/SKILL.md
T

73 lines
4.5 KiB
Markdown

---
name: compose-to-quadlet-cutover
description: "Convert a compose-only service to a boot-persistent rootless Quadlet unit on the Dankclaw/Dankserver fleet — checklist plus the traps (CMD-SHELL healthchecks in distroless images, --format docker, EnvironmentFile HOME=/root, loopback publish posture)"
---
# Compose service → boot-persistent Quadlet unit (this fleet)
Use when a service in `dankclaw/compose.yaml` (or `dankserver/`) must survive a
reboot. Precedents to copy: `dankclaw/{caddy,gitea,searxng,open-webui,seerr}/quadlet/`
and `dankclaw/observability/quadlet/` (five units, 2026-09-11).
## Checklist
1. **Unit file** at `dankclaw/<svc>/quadlet/<name>.container`, mirroring the
compose service line for line: `Image=`, `ContainerName=<the compose name>`
(Caddy and scrape configs address containers by name), `Network=dankclaw_default`,
`User=0:0` when the image's own user cannot write a rootless-owned bind mount,
`Volume=` for each mount (`:ro` preserved), `Exec=` for the compose `command:`,
`PublishPort=`, `HealthCmd`/`HealthInterval`/`HealthTimeout`/`HealthRetries`/
`HealthStartPeriod`, `[Service] Restart=always` + `RestartSec=10`,
`[Install] WantedBy=default.target`. Never `systemctl --user enable` the
generated unit — `[Install]` plus linger IS the boot persistence.
2. **Exposure**: publish on `127.0.0.1` unless something off-host genuinely
needs it. Caddy reaches containers by name over the shared network, and the
homepage status timer probes loopback, so the 0.0.0.0 publish is almost
always gratuitous and is a guard-chain bypass.
3. **Handler** in `scripts/deploy-remote.sh`: `install_quadlet <dir> <name>` →
`systemctl --user restart <name>.service` → `wait_for_health` on the
loopback endpoint. Rebuild locally built images first
(`build_compose_image`), and seed any per-service env file (see traps).
4. **Planner**: map `dankclaw/<svc>/*` and `shared/<svc>/*` to the service in
`scripts/deploy-plan.py`; keep dependencies ordered in `SERVICE_ORDER`.
Add a `scripts/test-changed.sh` case *above* the generic
`dankclaw/*/quadlet/*.container` fallback.
5. **Retire the compose entry** in the same change — two owners for one
container name is how a stale duplicate comes back.
6. **Seam test** `tests/<svc>_quadlet.bats`: boot persistence, restart policy,
no wildcard publish, container name still matching Caddy/scrape references,
health command, deployer installs+restarts+waits, never `rm`, never
`enable`. Migrate any older assertion that read the compose file.
7. **Deploy** per service (`./scripts/deploy.sh dankclaw --service <name>`),
then prove: `is-active`/`is-enabled` = active/generated, container health
`healthy`, the endpoint answers, `list-dependencies default.target` shows the
unit, and off-host probes on the previously published ports now refuse.
## Traps that cost real time
- **`HealthCmd` = CMD-SHELL.** podman runs it as `/bin/sh -c`; a distroless
image has no shell, so the check fails forever. `--health-cmd` will not take
exec form either (a JSON array is stored as one literal argument). Put
`HEALTHCHECK ... CMD ["binary","arg"]` in the image and **build with
`--format docker`** — the default OCI format has no HEALTHCHECK field and
drops it with only a warning.
- **Never point a unit's `EnvironmentFile` at the repo root `.env`.** That file
is a gluetun env file carrying `HOME=/root`: systemd sets HOME for the
service, podman then looks for its config under `/root`, and the unit dies
with `stat /root/.config: permission denied`. Use a per-service file under
`/home/dank/Containers/<svc>/env`, seeded by the deploy handler with a
single-variable filter and validated so a missing secret fails loudly.
- **`Network=host` exporters** that a containerized Prometheus scrapes must
bind the host's LAN address (containers cannot reach the host loopback) plus
`127.0.0.1` for the homepage probe — two `--web.listen-address` flags, never
the wildcard.
- **alloy's UI** listens on the container's loopback and 1.x refuses the `ui`
config block, so the unit must pass `--server.http.listen-addr=0.0.0.0:12345`
before its port can be published; without it the service has no reachable
health endpoint and its status card reads down forever.
- **Locally built images** need `Pull=never` and `Image=localhost/<repo>/<name>:tag`;
registry-prefixed refs do not resolve from local storage on these hosts.
- Compose-managed containers are replaced in place by `podman run --replace
--rm`; verify afterwards that the container no longer carries
`io.podman.compose.project`.