Groups and merging
A rack of machines usually shares everything except its MAC addresses. Writing that out once per machine is how a fleet’s configuration drifts. So answers compose.
answers/
├── groups/
│ ├── base/
│ │ └── proxmox.toml shared by everything
│ └── rack-a/
│ └── proxmox.toml extends = "base"; members = [ … ]
├── 98-fa-9b-50-d8-10/
│ └── proxmox.toml one machine's overrides (optional)
└── default/
└── proxmox.toml only when nothing else matches
The shared part
# answers/groups/rack-a/proxmox.toml
members = [
"98:fa:9b:50:d8:10",
"98:fa:9b:50:d8:11",
"98:fa:9b:50:d8:12",
]
[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
[disk-setup]
filesystem = "zfs"
zfs.raid = "raid1"
disk-list = ["sda", "sdb"]
The difference
A machine that differs gets a document with only the difference in it:
# answers/98-fa-9b-50-d8-10/proxmox.toml
[global]
fqdn = "node01.example.com"
[disk-setup]
zfs.raid = "raid10" # this one has four disks
disk-list = ["sda", "sdb", "sdc", "sdd"]
…and receives the two merged, with its own values winning:
$ rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a
[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
fqdn = "node01.example.com"
[disk-setup]
filesystem = "zfs"
zfs.raid = "raid10"
disk-list = ["sda", "sdb", "sdc", "sdd"]
Merge rules
| Layers | group chain first, machine document last — the machine always wins |
| Maps | merge recursively, including TOML’s inline and dotted tables |
| Other values | replaced outright by the higher layer |
| Arrays | replace, they do not append |
| Text formats | concatenated in layer order — see formats |
Why arrays replace. Appending is the intuitive choice right until you need to shorten
a list. disk-list = ["sda", "sdb"] in a group and ["sda"] in a machine document has
exactly one sensible meaning — this one has a single disk — and appending cannot express
it. The same rule holds in every format, so you never have to remember which one you are
in.
extends
A group may extend another group, giving a chain — what every rack shares in one file, per-rack differences in another:
# answers/groups/base/proxmox.toml
[global]
mailto = "ops@example.com"
timezone = "Europe/Paris"
root-ssh-keys = ["ssh-ed25519 AAAA…REPLACE ops@example.com"]
# answers/groups/rack-a/proxmox.toml
extends = "base"
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"]
[disk-setup]
filesystem = "zfs"
Layers then apply base → rack-a → machine document.
extends in a machine document overrides membership. It is the escape hatch for a
machine that needs a group it is not listed in:
# answers/98-fa-9b-50-d8-99/proxmox.toml
extends = "rack-a" # even though rack-a does not list this MAC
[global]
fqdn = "spare01.example.com"
extends resolves within one format — layering a preseed onto a TOML base is
meaningless, and the merge would refuse it anyway.
Only the first matching group applies
If two groups both claim a machine, one of them applies —
the most specific, ties
broken on sorted name. Compose with extends rather than relying on several groups
matching at once: the order between them would be arbitrary, and an arbitrary order is
how a machine quietly gets the wrong disk layout.
When a group is broken
Cycles and missing parents are detected when the store is read, reported in the log once, and the broken group is dropped rather than half-applied:
2026-08-24T08:43:36Z - warning: group "rack-a": extends unknown group "base"
One bad group does not stop the other racks from installing. A machine that
needed that group gets a loud 500 rather than a half-built answer — serving a
configuration whose base is missing would install the machine half-configured, and nobody
would find out until it was running.
rescriptum check reports the same problems, which is a better place to find out than
the log at 3am.
Grouping is the fast path
Measured at 2,000 machines, 3,000 requests at 100 concurrent:
| Layout | Throughput |
|---|---|
| 2,000 machine documents, no group | 12,132 req/s |
| one group of 2,000 members, no machine documents | 13,036 req/s |
| 2,000 machine documents plus a group (a merge per request) | 8,816 req/s |
A group with no machine overrides and no placeholders is rendered once, when the store is read, and served afterwards as a prepared string. The common datacenter case parses nothing per request. Adding a per-machine override buys a merge per request — worth it where it is needed, and worth avoiding where it is not.
The other half of the same argument is what a read costs. The whole store is re-read at
most once a second, and with a directory per identity that read is a readdir per machine
on top of the file it already opened — measured at 2,000 machines on an M1 Pro, 28 ms
before the layout changed and 63 ms after. It is amortised over a second’s worth of
requests either way, and the throughput figures above did not move measurably; but a group
that needs no per-machine directory avoids that cost too.
Next
- Templating —
{{ serial }}removes the remaining reason for a directory per machine. - Validating — a merged answer is a document nobody wrote; look at it before a rack does.
rescriptum