Skip to content

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 tag
imageAt "docker.io" "app" "sha256:abc…" -- digest-pinned
imageRef "ghcr.io/dull/golem:v1" -- parsed from one string
ConstructorSignatureBuilds
imageString -> String -> String -> Imagea tagged image (registry name tag)
imageAtString -> String -> String -> Imagea digest-pinned image (registry name digest)
imageRefString -> Imagean 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 equals localhost; otherwise there is no explicit registry and the registry defaults to docker.io;
  • a : after the last / is the tag; with none, the tag defaults to latest.
imageRef "registry" -- docker.io/registry:latest
imageRef "library/registry:2" -- docker.io/library/registry:2
imageRef "ghcr.io/dull/golem:v1" -- ghcr.io/dull/golem:v1
imageRef "10.0.2.2:5000/website:latest" -- 10.0.2.2:5000/website:latest
imageRef "alpine@sha256:abc123" -- docker.io/alpine@sha256:abc123

The 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/tcp
udp 53 53 -- PublishPort=53:53/udp
ConstructorSignatureBuilds
tcpInt -> Int -> Porta TCP Port { host, container }
udpInt -> Int -> Porta 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=info

env : 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=lichess

label : 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 unit
ExistingNetwork "podman" -- Network=podman, and golem writes nothing
ConstructorNetwork= lineAlso emits
ManagedNetwork<name>.networka NetworkUnit, derived by derivedNetworkUnits
ExistingNetwork<name>, verbatimnothing

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 | No

Lowers 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 Private
fromHost "/srv/site" "/usr/share/nginx/html" ReadOnly Shared
ConstructorSignatureLowers to
fromVolumeString -> String -> Access -> Relabel -> Mounta Volume=<name>.volume:<at> line and a VolumeUnit — no host directory
fromHostString -> String -> Access -> Relabel -> Mounta 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 = []:

FieldTypeBecomes
nameStringthe container name, the unit file name, and the .service
imageImagethe Image= line
envList EnvVarone Environment= line each
envFilesList Stringone EnvironmentFile= line each
labelsList Labelone Label= line each
portsList Portone PublishPort= line each, and the firewall openings
networksList Networkone Network= line each, plus a .network unit per ManagedNetwork
volumesList Mountone Volume= line each, plus a .volume unit or a host directory
restartRestartthe Restart= line
exposeExposethe nftables drop-ins, derived from ports

Filled in:

lila : Workload
lila =
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:

ExposeFirewall glyphs emitted
Unexposednone
InternalNftables.nftablesBase, plus one <name>-<port>.nft drop-in per port, opening it to Fleet.internalNetwork
PublicNftables.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:

  1. aptPackage { name = "podman" } — the runtime.
  2. one directory glyph per fromHost mount source.
  3. per derived NetworkUnit: aptPackage { name = "aardvark-dns" } and the .network quadlet file.
  4. one .volume quadlet file per derived VolumeUnit.
  5. the .container quadlet file at /etc/containers/systemd/<name>.container, mode 0600. Its body is Image= and ContainerName=, then Exec= if there is one, then one line each per Port, Network, Mount, EnvVar, env file, and Label, in that order — then Restart= under [Service] and WantedBy= under [Install].
  6. systemdService { unit = "<name>.service" } — the unit Podman’s generator produces from the .container.
  7. the firewall glyphs from expose — the nftables base first, then the drop-ins, in that order because the base carries the /etc/nftables.d directory 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 Scroll
planned 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.service

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

FieldTypeLine
nameStringContainerName=, the file name, the .service
imageImageImage=
execMaybe StringExec=, or no line at all for Nothing
publishPortsList PortPublishPort=
networksList NetworkNetwork=
networkUnitsList NetworkUnitthe .network files written alongside
mountsList MountVolume=
volumeUnitsList VolumeUnitthe .volume files written alongside
environmentList EnvVarEnvironment=
environmentFilesList StringEnvironmentFile=
labelsList LabelLabel=
restartRestartRestart=
wantedByList StringWantedBy=

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 : ContainerUnit
batch =
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