The Quadlet library
Running a container on a golem box means writing a Podman quadlet — a
.container unit systemd turns into a service, plus any .volume units it
mounts. 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 — it adds no
engine kind.
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).
import Quadlet exposing ( image, tcp, Restart(..), Access(..), Relabel(..) , fromVolume, Expose(..), Workload(..), workloadGlyphs )Image — a typed image reference
An image is a registry, a name, and a tag xor a digest — never both. 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.
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:
type Workload = Workload { name : String , image : Image , env : List EnvVar , ports : List Port , volumes : List Mount , restart : Restart , expose : Expose }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.
Lowering — a Workload to the four glyphs
workloadGlyphs produces, in order:
aptPackage { name = "podman" }— the runtime.- one
directoryglyph perfromHostmount source. - one
.volumequadletfileper derivedVolumeUnit. - the
.containerquadletfileat/etc/containers/systemd/<name>.container— itsImage=, onePublishPort=perPort, oneVolume=perMount, oneEnvironment=perEnvVar, andRestart=. 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.
Derived vs. explicit volumes
Workload derives one VolumeUnit per fromVolume mount, always with
driver = "local". When you need a non-default driver or options, drop to
ContainerUnit — the faithful .container model Workload lowers through —
and set volumeUnits explicitly. ContainerUnit also carries exec (a
Maybe String for the Exec= line) and wantedBy (the install target),
which Workload fills with defaults. Reach for it for the uncommon container;
Workload covers the common one.
Where to next
- The glyphs everything lands on: The four glyphs
- A thin helper built on this library: A service abstraction
- The whole thing at fleet scale: A tour of the lichess fleet