The Quadlet library
Running a container on a golem box means writing a Podman quadlet — a
.container unit systemd turns into a service, plus the .volume and
.network units it references.
Quadlet (in lib/Quadlet.emet, resolved from the library search path named
by the repo-root emet.json) models those units as typed Emet
values, so a workload is a filled-in record rather than hand-joined Image=
and PublishPort= strings. It is Debian + systemd + Podman specific, and it
lowers entirely to the four glyphs, which is the
whole of what the agent enacts.
This is the middle of three layers: the four glyphs underneath, this shipped library, and a fleet’s own helpers on top of it (see the service abstraction guide).
The full examples below build on this import:
import Quadlet exposing ( image, tcp, env, label , Restart(..), Access(..), Relabel(..), Expose(..) , Network(..), networkUnit , fromVolume, volumeUnit , ContainerUnit(..), containerUnitGlyphs , Workload(..), workloadGlyphs )Image — a typed image reference
An image is a registry, a name, and one reference: a tag or a digest. image
and imageAt take the three parts explicitly; imageRef parses one Docker
reference string:
image "docker.io/library" "registry" "2" -- registry/library, :2 tagimageAt "docker.io" "app" "sha256:abc…" -- digest-pinnedimageRef "ghcr.io/dull/golem:v1" -- parsed from one string| Constructor | Signature | Builds |
|---|---|---|
image | String -> String -> String -> Image | a tagged image (registry name tag) |
imageAt | String -> String -> String -> Image | a digest-pinned image (registry name digest) |
imageRef | String -> Image | an Image parsed from a Docker reference string |
Ref is the tag-or-digest sum (Tag String / Digest String). Because the tag
and digest are different constructors, registry:2@sha256:… is unwritable.
Internally, imageLine renders an Image back to its Image= line —
registry/name:tag or registry/name@digest.
imageRef — parsing a reference string
imageRef splits a reference structurally, without network access, and always
succeeds — an input that fits no rule falls back to the defaults rather than
failing:
- a digest is the text after the first
@; - the leading
/-segment is the registry only if it contains.or:, or equalslocalhost; otherwise there is no explicit registry and the registry defaults todocker.io; - a
:after the last/is the tag; with none, the tag defaults tolatest.
imageRef "registry" -- docker.io/registry:latestimageRef "library/registry:2" -- docker.io/library/registry:2imageRef "ghcr.io/dull/golem:v1" -- ghcr.io/dull/golem:v1imageRef "10.0.2.2:5000/website:latest" -- 10.0.2.2:5000/website:latestimageRef "alpine@sha256:abc123" -- docker.io/alpine@sha256:abc123The reference is not validated against a registry and the digest grammar is not checked; parsing is purely structural.
Port and Proto — published ports
tcp 5000 5000 -- PublishPort=5000:5000/tcpudp 53 53 -- PublishPort=53:53/udp| Constructor | Signature | Builds |
|---|---|---|
tcp | Int -> Int -> Port | a TCP Port { host, container } |
udp | Int -> Int -> Port | a UDP Port |
Proto is the closed enum TCP | UDP, so …/tpc is a type error, not a
silently-broken unit. Firewall exposure is derived from these ports (see
Expose below), so a published port and the port you open cannot drift apart.
EnvVar — environment, as a named record
An environment pair is a record rather than a tuple so that the field names are visible at the call site:
env "RUST_LOG" "info" -- Environment=RUST_LOG=infoenv : String -> String -> EnvVar. Each EnvVar lowers to one
Environment=NAME=VALUE line. A value containing a space, a tab, a quote, or a
backslash is written double-quoted with its quotes and backslashes escaped, so
env "GREETING" "hello world" reaches systemd as one value. A value from
Secretspec.get is quoted the same
way and travels sealed in the manifest; golem writes it as plaintext into the
.container file, which is why that file is mode 0600.
envFiles — variables golem never carries
envFiles is a list of paths, one EnvironmentFile= line each. systemd reads
the file on the host at start:
envFiles = [ "/etc/golem/lila.env" ]Whatever puts that file on the box is out of band, the way /etc/golem/token is
— golem writes the unit that names the path and nothing else. A credential
delivered this way never enters the manifest at all, which is the difference
from env: an EnvVar ships its value (sealed) to every host the scroll
targets.
Label — metadata on the container
label "golem.fleet" "lichess" -- Label=golem.fleet=lichesslabel : String -> String -> Label. Each Label lowers to one
Label=NAME=VALUE line, quoted by the same rule as env. Podman puts these on
the container, where podman ps --filter label=… and anything reading container
metadata can see them.
Network — which podman networks the container joins
A container joins networks by name, and golem either writes the network’s quadlet or leaves it to whatever already created it. That split is the sum:
ManagedNetwork "lichess" -- Network=lichess.network, and golem writes the unitExistingNetwork "podman" -- Network=podman, and golem writes nothing| Constructor | Network= line | Also emits |
|---|---|---|
ManagedNetwork | <name>.network | a NetworkUnit, derived by derivedNetworkUnits |
ExistingNetwork | <name>, verbatim | nothing |
ExistingNetwork is the escape for a network podman already knows — the default
podman bridge, or one another tool created. ManagedNetwork is the one golem
owns end to end.
NetworkUnit — the .network quadlet
networkUnit "lichess" builds a NetworkUnit { name, driver = "bridge" }, the
only driver this library writes. networkUnitGlyphs lowers it to two glyphs: the
aardvark-dns package, which is what resolves container names on a podman
bridge, and the /etc/containers/systemd/<name>.network file podman’s generator
turns into a unit.
You rarely build these by hand. Workload derives one per ManagedNetwork it
names (derivedNetworkUnits), the same way it derives volume units from
fromVolume mounts.
Restart — the systemd restart policy
type Restart = Always | OnFailure | NoLowers to Restart=always / on-failure / no in the [Service] section.
Restart=alwyas is a type error.
Mount — a volume line, named or host-path
A .container does not have volumes; it has mount lines, each referencing
either a named volume or a host path. That difference is a sum, because the two
lower differently:
fromVolume "golem-registry-data" "/var/lib/registry" ReadWrite PrivatefromHost "/srv/site" "/usr/share/nginx/html" ReadOnly Shared| Constructor | Signature | Lowers to |
|---|---|---|
fromVolume | String -> String -> Access -> Relabel -> Mount | a Volume=<name>.volume:<at> line and a VolumeUnit — no host directory |
fromHost | String -> String -> Access -> Relabel -> Mount | a Volume=<source>:<at> line and a directory glyph for the source — no .volume unit |
Access is ReadWrite | ReadOnly ( / `:ro`); `Relabel` is `NoRelabel | Shared | Private` ( / :z / :Z). The :ro/:z/:Z suffixes
are computed from these enums, so Volume=…:Z:rw:garbage cannot be written.
VolumeUnit — the .volume quadlet
A named volume is its own quadlet. volumeUnit "name" builds a
VolumeUnit { name, driver = "local" }; volumeUnitGlyphs lowers it to one
/etc/containers/systemd/<name>.volume file. You rarely build these by hand —
Workload derives one per fromVolume mount (derivedVolumeUnits).
Workload — the ergonomic surface
Workload is the record most fleets author. It carries the runtime shape and
lowers, through workloadGlyphs, to a full quadlet plus its firewall openings.
Ten fields, every one of them required — a record literal in Emet states its
whole shape, so a workload that wants no labels writes labels = []:
| Field | Type | Becomes |
|---|---|---|
name | String | the container name, the unit file name, and the .service |
image | Image | the Image= line |
env | List EnvVar | one Environment= line each |
envFiles | List String | one EnvironmentFile= line each |
labels | List Label | one Label= line each |
ports | List Port | one PublishPort= line each, and the firewall openings |
networks | List Network | one Network= line each, plus a .network unit per ManagedNetwork |
volumes | List Mount | one Volume= line each, plus a .volume unit or a host directory |
restart | Restart | the Restart= line |
expose | Expose | the nftables drop-ins, derived from ports |
Filled in:
lila : Workloadlila = Workload { name = "lila" , image = image "docker.io" "lichess-org/lila" "latest" , env = [ env "RUST_LOG" "info" ] , envFiles = [ "/etc/golem/lila.env" ] , labels = [ label "golem.fleet" "lichess" ] , ports = [ tcp 9663 9663 ] , networks = [ ManagedNetwork "lichess" ] , volumes = [ fromVolume "lila-data" "/var/lib/lila" ReadWrite Private ] , restart = Always , expose = Internal }Expose answers one question — who may reach the ports this container
publishes? — and is derived from ports, so you cannot open a port the
container does not publish:
Expose | Firewall glyphs emitted |
|---|---|
Unexposed | none |
Internal | Nftables.nftablesBase, plus one <name>-<port>.nft drop-in per port, opening it to Fleet.internalNetwork |
Public | Nftables.nftablesBase, plus one public-<name>-<port>.nft drop-in per port, opening it to the world |
Each drop-in is a complete file under /etc/nftables.d/ — a whole
table inet golem block with its own accept rule — never a line appended to a
file other workloads share (ADR 0041). Every exposed workload also carries
nftablesBase: the nftables package, the drop-in directory, the entrypoint
conf that includes the glob, the 00-base chain, and the oneshot
golem-nftables.service that loads them. Repeating it is the point — the glyphs
are identical, so they share content ids and enact once. A scroll that writes
drop-ins wants notifies = [ "golem-nftables.service" ] so the ruleset is
reloaded once the files are in place.
A fleet that owns its own firewall takes the other road: Unexposed, then
containerUnitGlyphs (workloadUnit w) composed with rules of its own.
publishedPorts w hands back the ports those rules need, so the two still
read from one declaration. This is what Lichess.emet does — see the
lichess tour.
Lowering — a Workload to the four glyphs
workloadGlyphs produces, in order:
aptPackage { name = "podman" }— the runtime.- one
directoryglyph perfromHostmount source. - per derived
NetworkUnit:aptPackage { name = "aardvark-dns" }and the.networkquadletfile. - one
.volumequadletfileper derivedVolumeUnit. - the
.containerquadletfileat/etc/containers/systemd/<name>.container, mode0600. Its body isImage=andContainerName=, thenExec=if there is one, then one line each perPort,Network,Mount,EnvVar, env file, andLabel, in that order — thenRestart=under[Service]andWantedBy=under[Install]. systemdService { unit = "<name>.service" }— the unit Podman’s generator produces from the.container.- the firewall glyphs from
expose— the nftables base first, then the drop-ins, in that order because the base carries the/etc/nftables.ddirectory and glyphs enact in source order.
The registry Workload — one TCP port, one named volume, internally exposed —
lowers to eleven glyphs. From
cargo run -q -p emet -- build examples/registry/registry.emet --text:
main : List Scrollplanned scrolls (1): scroll `kaiju` (11 glyphs): * ensure apt package `podman` installed * ensure file `/etc/containers/systemd/golem-registry-data.volume` (mode 0644) * ensure file `/etc/containers/systemd/registry.container` (mode 0600) * enable + start systemd unit `registry.service` * ensure apt package `nftables` installed * ensure directory `/etc/nftables.d` (mode 0755) * ensure file `/etc/golem-nftables.conf` (mode 0755) * ensure file `/etc/nftables.d/00-base.nft` (mode 0644) * ensure file `/etc/systemd/system/golem-nftables.service` (mode 0644) * enable + start systemd unit `golem-nftables.service` * ensure file `/etc/nftables.d/registry-5000.nft` (mode 0644) ↻ kaiju notifies golem-nftables.serviceThe named-volume .volume unit (glyph 2), the whole firewall arrangement, and
the reload that loads it all fall out of the typed spec — the hand-rolled
registry quadlet this replaced had none of them.
ContainerUnit — one field per line of the file
ContainerUnit is the systemd-shaped layer underneath Workload: thirteen
fields that map one-to-one onto the .container file, with nothing derived.
workloadUnit builds one from a Workload, and containerUnitGlyphs writes it
out.
| Field | Type | Line |
|---|---|---|
name | String | ContainerName=, the file name, the .service |
image | Image | Image= |
exec | Maybe String | Exec=, or no line at all for Nothing |
publishPorts | List Port | PublishPort= |
networks | List Network | Network= |
networkUnits | List NetworkUnit | the .network files written alongside |
mounts | List Mount | Volume= |
volumeUnits | List VolumeUnit | the .volume files written alongside |
environment | List EnvVar | Environment= |
environmentFiles | List String | EnvironmentFile= |
labels | List Label | Label= |
restart | Restart | Restart= |
wantedBy | List String | WantedBy= |
The two pairs are the whole reason to reach for it. Workload derives
networkUnits from networks and volumeUnits from volumes, always on the
bridge and local drivers; ContainerUnit takes each side separately, so a
non-default driver, a unit shared with another container, or a mount whose unit
someone else writes are all sayable. exec and wantedBy are the other two
Workload fills in — the image’s own entrypoint, and both multi-user.target
and default.target.
batch : ContainerUnitbatch = ContainerUnit { name = "lila-batch" , image = image "docker.io" "lichess-org/lila" "latest" , exec = Just "/usr/bin/lila-batch --once" , publishPorts = [] , networks = [ ManagedNetwork "lichess" ] , networkUnits = [ networkUnit "lichess" ] , mounts = [ fromVolume "lila-data" "/var/lib/lila" ReadOnly Private ] , volumeUnits = [ volumeUnit "lila-data" ] , environment = [ env "RUST_LOG" "warn" ] , environmentFiles = [ "/etc/golem/lila.env" ] , labels = [ label "golem.fleet" "lichess" ] , restart = OnFailure , wantedBy = [ "multi-user.target" ] }containerUnitGlyphs writes exactly the podman package, the mount directories,
the network and volume unit files, the .container, and the service —
everything in the lowering above except the firewall, which belongs to expose
and so to Workload. Reach for ContainerUnit for the uncommon container;
Workload covers the common one.
Where to next
- The glyphs everything lands on: The four glyphs
- The shipped library that puts a
Workloadbehind a TLS front door: The Traefik library - A thin helper built on this library: A service abstraction
- The whole thing at fleet scale: A tour of the lichess fleet