rescriptum
Writing answers
Guide
Writing answers
Writing answers
An answer is the document an installer receives: a Proxmox answer.toml, an Ubuntu
autoinstall user-data, a kickstart, a preseed, an Ignition config, an AutoYaST profile,
a Windows unattend.xml. rescriptum’s whole job is to pick the right one for the machine
asking and hand it back, assembled from however many layers you wrote.
The layout
One directory per identity. A machine is a directory named after it, holding one document per operating system:
answers/
├── 98fa9b50d810/ one machine
│ ├── proxmox.toml as Proxmox
│ └── debian.preseed …and the same hardware as Debian
├── aabbccddeeff/ another machine
│ └── ubuntu.yaml as Ubuntu
├── default/ when nothing else matches
│ └── proxmox.toml
└── groups/
├── rack-a/ shared by a rack, claims its members
│ ├── proxmox.toml
│ └── debian.preseed
└── rhel-compute/ claims machines by what they are
└── rhel.ks
- A machine is a directory named after it — a MAC address, in any separator style — holding that machine’s own configuration, or only the part of it that differs from its group.
- A group is a directory under
groups/and is shared. It claims machines by listing them inmembers, or by amatchblock tested against the request. default/answers when nothing else does. One document per format: a TOML default must not answer a client that asked for kickstart.
The extension decides; the name does not
Inside a directory, the extension is the format and the part before it means nothing.
proxmox.toml and answer.toml are the same document to the server; the name is there
for whoever opens the folder. rescriptum writes readable ones — proxmox.toml,
ubuntu.yaml, debian.preseed, boot.ipxe — and never renames yours.
The one rule that follows: a directory holds at most one document per format. Two
.toml in one directory is reported as a problem rather than resolved, because nothing
could pick between them that you would have predicted. Two different formats are not a
duplicate at all — that is the whole point of the directory.
Storage layout and URL are still deliberately kept apart: a folder can be reorganised, a URL baked into an ISO cannot. See formats.
:::note[Upgrading from a flat directory]
Answers used to be files at the top of the directory: 98fa9b50d810.toml beside
98fa9b50d810.preseed. Those are no longer served, and each one is reported by name
with its new path. rescriptum migrate shows what it would move; rescriptum migrate --apply moves them.
:::
The five things to know
| How an answer is picked | By name, by member list, or by what the machine is. Naming always wins; among selectors, more criteria wins; ties break on sorted name |
| One document per operating system | The extension is the format, the endpoint chooses between them, and a machine can exist as several operating systems at once |
| Groups and merging | Layers apply lowest to highest and the machine always wins. Maps merge; arrays replace |
| Templating | {{ serial }} filled from the request, so one group file covers a rack |
| Validating | render shows what a machine would get; check renders everything and reports what breaks |
Control keys
Four keys steer resolution and are stripped before the answer is sent, so the installer never sees them:
| Key | Does |
|---|---|
members | the machines this group answers for |
match | criteria tested against the request’s facts |
extends | the group this document layers on top of |
They travel in whatever the format allows — top-level keys in TOML, YAML and JSON, an
<answer-meta> element in XML, # answer: directives in kickstart and preseed. The
per-format spelling is in formats.
Worked examples
The repository’s examples/
directory carries a commented example of every supported format, all selected
differently — by hardware, by member list, by directory name — and they are exercised by
the test suite:
$ RESCRIPTUM_ANSWERS_DIR=examples rescriptum check
$ RESCRIPTUM_ANSWERS_DIR=examples rescriptum render --query "path=/rhel/ks&serial=7ABC123"
It is the only place the formats are shown composing together; start there when you are unsure what a real file looks like.
How an answer is picked
How an answer is picked
A document does not get looked up; it claims the request. Three ways to do that, ordered by how narrowly they target one machine.
1. By name
Name a directory after the machine’s MAC address, and put its documents in it:
answers/
├── 98-fa-9b-50-d8-10/
│ └── proxmox.toml
├── aabbccddeeff/
│ └── proxmox.toml
└── default/
└── proxmox.toml
When a request arrives, the server lowercases everything it carries and drops every
non-alphanumeric character, does the same to each directory’s name, and serves the first
whose name appears inside the request. So 98-fa-9b-50-d8-10, 98:fa:9b:50:d8:10 and
98fa9b50d810 all name the same machine — you never have to care which separator style
Proxmox happens to use this version, or how it structures its JSON.
The identity is the directory name; the filenames inside it choose nothing, they only carry the format in their extension.
That normalization is the whole trick, and it is why this survives Proxmox changing its body format between releases: it is a substring test over the bytes, not a schema.
Nothing prevents naming a directory after a serial number, an asset tag or a hostname instead. Any string that appears in what the machine sends will do.
2. By member list
A group claims a set of machines by listing them:
# 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"]
Member strings are normalized exactly like directory names, so separator style does not matter here either. A listed machine needs no directory of its own unless it has something to override — see grouping.
3. By what the machine is
A match block claims a machine by its properties rather than its identity:
# answers/groups/dell-r620/proxmox.toml
[match]
manufacturer = "Dell Inc."
product = "PowerEdge R620"
serial = "7ABC*" # * and ? work
Every criterion must hold for the group to claim the request. * matches any run of
characters and ? exactly one; both sides are normalized before comparing, so case and
separator style never matter.
A machine document may carry a match block too — useful for “whatever machine is
currently in this chassis slot”.
The facts a selector can test
Facts come from three places, deliberately layered from most to least structured.
Query parameters
?mac=…&uuid=…&serial=… — how every installer other than Proxmox identifies itself,
because iPXE substitutes the values into the URL before fetching. Reliable, arbitrary
keys, no guessing.
Three more are synthesized from the URL itself:
| Fact | Is |
|---|---|
path | the whole path, trimmed of slashes — rhel/ks |
file | its last segment — ks. This is what tells cloud-init’s user-data from its meta-data |
segment | every segment, as separate values — rhel and ks |
A POSTed JSON body
When the body really is JSON, it is flattened to both its full dotted paths and its bare leaf names:
{ "dmi": { "system": { "serial": "7ABC123" } } }
gives both dmi.system.serial and plain serial. The leaf form is the point.
Proxmox’s own documentation warns that the contents of dmi “might vary wildly,
depending on the system”, so a selector saying “a field called serial, wherever it
lives” survives a reorganisation that a fixed path would not.
Array indices become part of the path but not of the leaf name, so
network_interfaces.0.mac is also reachable as plain mac.
A body that is not JSON is not an error — it simply contributes nothing but the haystack.
The raw body
Normalized to lowercase alphanumerics: the substring haystack that makes matching by name
work. Query values and path segments are appended to it too, so a directory named after a
MAC resolves whether that MAC arrived in a POST body or a query string. Without that, a
GET — which has no body at all — could never match by name.
When several documents claim the same request
The rule is fixed, and a test pins it:
- Naming a machine always wins. However many criteria a selector carries, an identity match beats it — naming a machine is as specific as anyone can be.
- Among selectors, more criteria wins. Three matching criteria beat two; a more deliberate rule is a more specific one.
- Ties break on sorted name. Alphabetically first.
The answer never depends on filesystem order or on the order rows came out of a database. matchbox, the closest prior art, documents that its own resolution between competing groups “will not be deterministic”. This one is.
Only the first matching group applies. Composition is expressed with
extends, not by merging every group that happens to match —
the order between several matching groups would be arbitrary, and an arbitrary order is
how a machine quietly gets the wrong disk layout.
And if nothing matches
default.<ext> is served, if there is one for the format the endpoint asked for.
Otherwise the answer is 404 — logged as no answer file applies.
Try it before booting anything
render resolves exactly as the server would, from facts you supply:
$ rescriptum render 98:fa:9b:50:d8:10 # by identity
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10" # by label
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123" # including the endpoint
$ rescriptum render --body captured-request.json # a real captured body
A bare identifier claims nothing about what kind of identifier it is — it fills the
haystack and nothing else. That is enough for name matching, but a selector on serial
needs --query "serial=…" to have anything to test. check works the same way, which is
why a template needing a request-only fact is reported as a
problem.
To capture what your machines really send, see Capturing requests.
One document per operating system
One document per operating system
An installer fetching a URL expects one particular thing back. A kickstart client wants kickstart and would choke on TOML. That is the protocol, not a convention anyone chose. So:
- the endpoint declares the format —
/rhel/ksasks for kickstart; - the document carries it as its extension —
groups/rhel-compute/rhel.ks; - only documents of that format may answer.
The consequence that makes it click
A machine’s answer is specific to the operating system it is for. So this is not one machine and two files:
answers/
└── 98fa9b50d810/
├── proxmox.toml "that machine, as Proxmox"
└── debian.preseed "that machine, as Debian"
It is one piece of hardware with two answers, and both can exist at once. Which one a
request receives depends on the URL it arrived on — /proxmox/answer gets the TOML,
/debian/preseed gets the preseed. Neither is more “the” answer than the other.
Internally this is why a document is keyed by (identifier, format) rather than by identifier alone — and why one directory holds one document per format and no more.
Storage is not the URL
Documents are grouped by identity, never by format, and that is deliberate. Directories and database rows are a lookup space — they must stay free to be reorganised. A URL is a public contract baked into an ISO — it must not move because someone renamed a folder. An earlier design made the directory name be the URL segment and was discarded for exactly that reason.
The same rule is why the filename inside a machine’s directory carries no meaning:
proxmox.toml reads well and matches the /proxmox/ endpoint, but only the .toml is
load-bearing. Rename it answer.toml and nothing changes.
Which alias serves which extension is in the format reference; how to pick one for your media is in preparing installer media.
The formats
| Extension | For | Layering |
|---|---|---|
toml | Proxmox VE | structural merge |
yaml, yml | Ubuntu autoinstall, cloud-init | structural merge |
json, ign | Ignition, Flatcar, Fedora CoreOS | structural merge |
xml, autoyast, unattend | AutoYaST, Windows unattend.xml | structural merge, by element |
ks | kickstart — RHEL, CentOS, Fedora, Alma, Rocky | concatenation |
preseed, seed | Debian preseed | concatenation |
cfg, ipxe | boot scripts and other line-oriented config | concatenation |
The allowlist is deliberate: txt is not on it, so a stray notes file next to your
answers never becomes a candidate.
.autoyast and .unattend are XML under a name that says which one it is, so a store
holding both a SUSE profile and a Windows unattend can keep them apart. Plain .xml
still answers either, which is fine right up until you have both.
Structural merge
For toml, yaml, json and xml, layering is a real merge:
- Maps merge key by key, recursively — including TOML’s inline and dotted tables.
- Any other value is replaced outright by the higher layer.
- Arrays replace, they do not append. Appending would make a list impossible to shorten from a higher layer, and “this node has two disks, not four” has to be expressible.
The details, with examples, are in grouping.
Concatenation
For ks, preseed, cfg, seed and ipxe, layering is concatenation in layer
order, and the module says so rather than pretending otherwise. A directive in a later
layer follows an earlier one rather than removing it.
Whether that amounts to an override is the target format’s business: preseed’s last answer wins, kickstart’s does not always. Render the result and read it before trusting a rack to it.
One thing worth knowing before you write an essay at the top of a kickstart: ordinary
comments are served. Only # answer: directive lines are stripped. That is fine —
kickstart and preseed both allow comments — but the installer will see everything else.
XML
XML pairs siblings by element name plus a discriminating attribute — name, id,
key, alias or pass. That is what makes
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" …>
mergeable: overriding one pass leaves the others alone, and overriding one component
does not replace every other component in the file. Repeated siblings without a
discriminating attribute are treated as a list, and AutoYaST’s config:type="list" is
honoured.
What survives a merge: the <?xml?> declaration, the <!DOCTYPE>, namespaces, and
attributes. What does not: the original indentation and comment placement — the
output is re-rendered, not patched.
It understands no schema. Render and check before trusting a rack to it.
Where the control keys live
The control keys travel in whatever each format allows, and are stripped before the answer is sent.
TOML
extends = "base"
members = ["98:fa:9b:50:d8:10"]
[match]
product = "PowerEdge R6*"
YAML / JSON — the same three as top-level keys:
extends: base
members: ["98:fa:9b:50:d8:10"]
match:
file: "user-data"
product: "PowerEdge R6*"
XML — an <answer-meta> element, with extends as an attribute on it:
<answer-meta extends="base">
<member>52:54:00:11:22:33</member>
<match manufacturer="Dell Inc." product="PowerEdge R6*" />
</answer-meta>
Kickstart, preseed, and anything line-oriented — # answer: directives (// works
too, for formats that comment that way):
# answer: extends base
# answer: member 00:11:22:33:44:55, 00:11:22:33:44:56
# answer: match serial=7ABC* product=PowerEdge*
match takes space-separated key=pattern pairs, member a comma-separated list.
One answer, one format
Every layer of one answer must be the same format. A YAML machine document over a
TOML group is refused, not half-served, and extends resolves within one format for the
same reason — layering a preseed onto a TOML base is meaningless.
Grouping is otherwise untouched by any of this: a rack shares one group per format, and a machine that exists as two operating systems joins two of them.
default follows the same rule — a .toml in default/ answers a request that asked for
TOML, and never one that asked for kickstart.
Groups and merging
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.
Templating
Templating
Grouping removes the duplication between machines that agree. Templating removes the last reason to write a document per machine at all: the values that must differ.
# answers/groups/rack-a/proxmox.toml
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "…"]
[global]
fqdn = "node-{{ serial }}.example.com"
[network]
filter.ID_NET_NAME_MAC = "*{{ mac }}"
Five hundred machines, one document. Without this, a per-machine hostname means a document per machine — and five hundred documents that differ in one line each.
Placeholders work in every format: TOML, YAML, JSON, XML, kickstart, preseed.
In the structured formats, substitution happens on parsed string values — so a comment is just a comment. In the line-oriented ones (
ks,preseed,cfg,ipxe,seed) the document is an opaque string, so a placeholder written inside a comment is still a placeholder and still has to resolve. Mentioning{{ serial }}in a#line to explain it to the next reader will fail the render just as a real one would.
What you can put in one
| Placeholder | Filled from |
|---|---|
{{ mac }}, {{ serial }}, {{ uuid }}, … | any fact the request carries — query parameters, and the fields of a POSTed JSON body by leaf name or full path |
{{ dmi.system.serial }} | the same body, by its exact path |
{{ path }}, {{ file }}, {{ segment }} | the URL the request arrived on |
{{ group }} | the name of the group that applied |
{{ machine }} | the identifier of the machine document that matched |
Whitespace inside the braces is optional: {{serial}} and {{ serial }} are the same.
machine needs a machine document
{{ machine }} is the identifier of the machine document that matched — so it is only
available when the machine has a document of its own. A machine claimed by a group’s
members list, with no directory of its own, has no machine value and rendering fails with
template needs {{ machine }}, but this request carries no "machine".
In a group, use a request fact instead:
[global]
fqdn = "node-{{ mac }}.example.com" # works for every member
{{ machine }} is for a machine document that wants to name itself without repeating its
own MAC.
A missing value is an error
A placeholder the request cannot fill is a 500 with the reason, never an empty string:
$ rescriptum render 98:fa:9b:50:d8:10
error: template needs {{ serial }}, but this request carries no "serial"
This is deliberate. Serving node-.example.com installs a machine with a broken hostname
and nobody notices until later — possibly much later, on a machine that is already in
production. Failing the install is the cheaper outcome.
Control characters are refused outright for the same class of reason: a newline in a kickstart value would inject a directive into the file the installer executes.
$ rescriptum render --query "mac=aa:bb&serial=$(printf 'a\nb')"
error: value for "serial" contains a control character and will not be substituted
Substitution is escape-safe
Substitution happens on parsed values, never on raw document text. The value is put into the document’s own data model and the format’s serializer writes it out — so the serializer does the escaping.
A serial containing a quote cannot break the TOML it lands in:
$ rescriptum render --query 'mac=aa:bb&serial=a"b'"'"'c<d>e'
[global]
fqdn = """node-a"b'c<d>e.example.com"""
The TOML writer reached for a multi-line string on its own. The same value in an XML
document comes back entity-escaped, and in JSON, JSON-escaped. A test feeds
a"b'c<d>e&f through all four structured formats and reparses the output.
This is why templating is safe to feed from a request that a machine you have never met controls.
check and request-only facts
rescriptum check renders every machine from its identity alone — it has no request
to draw on, because there is no request. A template that needs serial, which only ever
arrives in a body or a query string, is therefore reported as a problem:
$ rescriptum check
FAIL group "rack-a" member "98fa9b50d811": template needs {{ serial }}, but this request carries no "serial"
That is honest — check genuinely cannot prove that answer renders — but it is noisy for
a set that deliberately templates on request facts. Verify those with render and
representative facts instead:
$ rescriptum render --query "mac=98:fa:9b:50:d8:11&serial=7ABC123"
The cost
None, when you are not using it. A group whose prepared string carries no {{ is served
as-is, without being parsed per request — the check for placeholders happens once, when
the store is read. Templating moves a group onto the merge-per-request path only for the
documents that actually contain one.
Next
- Validating — render with real facts, and check the whole set.
- Capturing requests — get a real body to render against.
Validating what will be served
Validating what will be served
Before answers composed, an admin wrote a complete document and validated it:
$ proxmox-auto-install-assistant validate-answer answer.toml
Once an answer is assembled from a group chain plus a machine document plus a template fill, the document the installer receives is one nobody has ever seen — and a bad merge surfaces as a failed unattended install at 3am. Two subcommands exist to close that gap, and any change to merging has to keep them working.
render — what this machine would get
$ rescriptum render 98:fa:9b:50:d8:10 # by identity
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10" # by label
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123" # including the endpoint
$ rescriptum render --body captured-request.json # a real captured body
It resolves exactly as the server does — same matching, same layering, same template fill — and prints the result. The document goes to stdout; the line explaining how it was reached goes to stderr:
$ rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a
[global]
…
so redirecting gives you just the document:
$ rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml
Add path=… to --query when you want to check what a particular endpoint would
answer — without it, resolution is unconstrained by format and may pick a document the
real URL would have excluded.
Exit status is 0 when something resolved, non-zero when nothing applied (the server would have returned 404) or when rendering failed.
check — render everything, report what breaks
$ rescriptum check
checking files:examples
10 group(s), 8 machine document(s)
group "rhel-compute" selects on serial=7ABC*
(verify with: rescriptum render --query "...")
group "ubuntu-web" selects on file=user-data product=PowerEdge R6*
(verify with: rescriptum render --query "...")
1 answer(s) validated by their installer's own tool
note: no schema validator exists for preseed answers
note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH
ok — everything renders
Well-formed and merging cleanly is not the same as valid for an
installer. Where a validator exists and is installed it was used above;
install proxmox-auto-install-assistant, xmllint or ksvalidator for the rest.
What it does:
- Reports load-time problems — a group extending one that does not exist, a cycle between groups, a document that will not parse.
- Renders every machine document, and every member of every group. That is what actually exercises the merge.
- Names the groups that select on a
matchblock and says it could not try them, rather than implying they were verified — a selector needs a real request. - Flags a group with neither
membersnormatchas reachable only viaextends, in case that was not the intention. - Calls the installer’s own validator where one exists and is on PATH, and says which formats it could not check.
Exit status is 0 when everything renders, 1 when anything failed — so it drops straight into CI.
The validators it knows
| Format | Tool |
|---|---|
toml | proxmox-auto-install-assistant validate-answer |
xml, autoyast, unattend | xmllint --noout |
ks | ksvalidator |
yaml, json, ign, preseed, cfg, ipxe | none exists — render and read it |
A missing tool is reported once as a note, never treated as a failure. A checker that refuses to run without optional tooling is a checker nobody runs.
check is not a schema checker itself: it proves your documents are well-formed and
merge cleanly. For anything it cannot call a validator for, pipe a rendered answer in
yourself:
$ rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml
$ proxmox-auto-install-assistant validate-answer /tmp/answer.toml
What check cannot prove
check renders each machine from its identity alone. It has no request, so it cannot
supply facts that only arrive with one — a serial from a POSTed body, a mac from a
query string. A template needing those is reported as a problem:
FAIL group "rack-a" member "98fa9b50d811": template needs {{ serial }}, but this request carries no "serial"
That is accurate — check genuinely cannot prove that answer renders — but it means a
set that deliberately templates on request facts will not come back clean. Verify those
with render --query and representative facts. See
templating.
In CI
If your answers live in git, this is worth a job of its own:
# .github/workflows/answers.yml
name: answers
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get rescriptum
run: |
curl -fsSL https://github.com/z29k/rescriptum/releases/latest/download/rescriptum-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1
- run: RESCRIPTUM_ANSWERS_DIR=answers ./rescriptum check
Add proxmox-auto-install-assistant to the runner and the same job schema-checks the
TOML too.
Before deploying
deploy.sh runs check
before it ships anything, and refuses to deploy if the answers do not come back clean.
Serving a broken answer set is worse than not deploying.