Files
archy/docs/quadlet-compilation.md
T
archipelagoandClaude Opus 5 ecd9295e96 docs: fix the wrong journalctl scope and document the host-network port drop
**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>
2026-08-08 03:50:15 -04:00

5.1 KiB

Manifest → Quadlet unit

How an app manifest becomes a Podman Quadlet .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:

# 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.
  • 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. writetempfile + 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. enabledaemon-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):

# 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.