73 lines
4.5 KiB
Markdown
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`.
|