**container-lifecycle.md** told operators to read the reconciler's decisions with `journalctl --user -u archipelago`. That returns nothing: `archipelago.service` is a SYSTEM unit (`WantedBy=multi-user.target`) that merely runs as `User=archipelago`. It's `sudo journalctl -u archipelago`. Easy to get wrong because the companion Quadlet units next door genuinely are `--user`, so both forms appear in the docs and only one is right per unit — spelled that out inline. Swept the rest of docs/: no other instance. **quadlet-compilation.md** — added the `Network=host` case. Podman rejects `PublishPort` with host networking (crash-loop, exit 125), so the renderer drops declared ports rather than emitting them (`render_host_network_omits_publish_ports`). A developer reading the directive list would otherwise expect a mapping that never appears. Everything else in both docs verified against quadlet.rs / prod_orchestrator.rs / boot_reconciler.rs: the unit dir, the DO-NOT-EDIT header, Pull=never, DropCapability=ALL, Secret=…,type=env, TimeoutStartSec=0, RestartSec=10, WantedBy=default.target, the render/write_if_changed/enable_now/disable_remove four-step, uid 1000, adopt_existing, the user-stopped.json / user-uninstalled.json desired-state gates, and the 30s tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
5.1 KiB
Markdown
127 lines
5.1 KiB
Markdown
# Manifest → Quadlet unit
|
|
|
|
How an app manifest becomes a Podman [Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html)
|
|
`.container` unit that systemd owns, where the unit lands, and how to inspect one.
|
|
Source of truth: `core/archipelago/src/container/quadlet.rs`.
|
|
|
|
## Why Quadlet
|
|
|
|
Containers used to be fire-and-forget `tokio::spawn` blocks. If the daemon
|
|
crashed mid-spawn or the kernel reaped a parent cgroup, the container vanished
|
|
from `podman ps` and only a manual `podman run` brought it back. Quadlet removes
|
|
that whole class of failure: the unit lives on disk, **systemd owns
|
|
start/restart, and archipelago is just the provisioner**. This is the path that
|
|
runs the companion UI containers today (`archy-bitcoin-ui`, `archy-lnd-ui`,
|
|
`archy-electrs-ui`), and the validated path being flipped to default for apps.
|
|
|
|
## What gets generated
|
|
|
|
`Quadlet::from_manifest(manifest, name)` translates a manifest into a unit, and
|
|
`render()` produces the file. Every unit carries a header making clear it is not
|
|
hand-edited:
|
|
|
|
```ini
|
|
# Generated by archipelago. DO NOT EDIT.
|
|
# Edits are overwritten on the next reconcile.
|
|
|
|
[Unit]
|
|
Description=<app description>
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
Requires=<dependency>.service # one per declared dependency
|
|
After=<dependency>.service
|
|
|
|
[Container]
|
|
ContainerName=<name>
|
|
Image=<image ref>
|
|
Pull=never # image must be present locally already
|
|
Network=<host | pasta | slirp4netns | bridge name>
|
|
User=<uid> # when the manifest pins one
|
|
DropCapability=ALL # security default
|
|
AddCapability=<cap> # only capabilities the manifest opts into
|
|
PublishPort=<bind>:<host>:<container>/<proto>
|
|
Environment=<KEY>=<value> # non-secret env only
|
|
Secret=<secret_name>,type=env,target=<KEY> # secrets by REFERENCE, never value
|
|
Volume=<source>:<target><opts>
|
|
ReadOnly=true # when security.readonly_root
|
|
NoNewPrivileges=true # when security.no_new_privileges
|
|
HealthCmd=<cmd> # from the health_check block
|
|
|
|
[Service]
|
|
TimeoutStartSec=0
|
|
Restart=<always | on-failure> # from the restart policy
|
|
RestartSec=10 # 10s backoff caps a crash loop
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
```
|
|
|
|
Two things to note in that mapping:
|
|
|
|
- **Secrets go in by reference, never by value.** A `secret_env` entry renders as
|
|
`Secret=<name>,type=env,target=<KEY>`, so podman injects the value at run time
|
|
from the node's secret store. The plaintext never appears in the unit file. See
|
|
[App secrets](secrets.md).
|
|
- **`Pull=never` is deliberate.** The provisioner does not pull images from here;
|
|
the image must already be local (pre-pulled or built). A missing image surfaces
|
|
immediately instead of retrying silently behind systemd's restart loop.
|
|
- **`PublishPort` is dropped entirely under `Network=host`.** Podman rejects the
|
|
combination and the container crash-loops on exit 125, so declared ports are
|
|
omitted rather than rendered. With host networking the container is already on
|
|
the host's ports; a manifest that declares both is not an error, the mapping is
|
|
just silently unnecessary.
|
|
|
|
## Where units land
|
|
|
|
Rootless, per-user, under the archipelago service user (uid 1000, with linger
|
|
enabled so the units run without an active login):
|
|
|
|
```
|
|
~/.config/containers/systemd/<name>.container
|
|
```
|
|
|
|
Quadlet's systemd generator translates `<name>.container` into a
|
|
`<name>.service` unit at **daemon-reload** time. Everything is `systemctl --user`
|
|
— the system bus is never touched from this path.
|
|
|
|
## Lifecycle: render → write → enable → disable
|
|
|
|
The module does four things and nothing else:
|
|
|
|
1. **render** — manifest → unit text (above).
|
|
2. **write** — `tempfile + rename` so a partially-written unit is never visible to
|
|
systemd, and `write_if_changed` compares bytes first: if the rendered unit
|
|
matches what is on disk, nothing is touched — no daemon-reload, no restart
|
|
cascade. This is what makes a reconcile tick cheap and non-disruptive.
|
|
3. **enable** — `daemon-reload` then start the `.service`.
|
|
4. **disable** — stop and remove.
|
|
|
|
## Inspecting a unit
|
|
|
|
Run these **as the archipelago service user** (the units are in its user bus):
|
|
|
|
```bash
|
|
# the generated unit
|
|
cat ~/.config/containers/systemd/archy-bitcoin-ui.container
|
|
|
|
# what systemd made of it
|
|
systemctl --user cat archy-bitcoin-ui.service
|
|
systemctl --user status archy-bitcoin-ui.service
|
|
journalctl --user -u archy-bitcoin-ui.service
|
|
|
|
# after editing a unit by hand for debugging (it will be overwritten on reconcile)
|
|
systemctl --user daemon-reload
|
|
```
|
|
|
|
Because the unit is regenerated on every reconcile, the way to change a
|
|
container's shape is to change its **manifest** (and, for a catalog-covered app,
|
|
regenerate and re-sign the catalog), never to edit the `.container` file — the
|
|
`DO NOT EDIT` header is literal.
|
|
|
|
## Related
|
|
|
|
- [Container lifecycle](container-lifecycle.md) — the reconciler that drives this
|
|
- [App Manifest Specification](app-manifest-spec.md) — the manifest fields mapped above
|
|
- [App secrets](secrets.md) — how `Secret=` references resolve
|
|
- [ADR-001: Podman over Docker](adr/001-podman-over-docker.md)
|