Skip to main content

max / alloy

Write the answer sheet from the recipe, and drive a preseeded install in a VM
Author: Max Johnson <me@maxj.phd> · 2026-09-03 17:45 UTC
Signed with PGP, not checked
Commit: 58428c8ef50b619b2af3987d95d3a3aa3dbc5d8c
Parent: 8a1ff88
7 files changed, +414 insertions, -7 deletions
@@ -3845,6 +3845,85 @@
3845 3845 echo "identity: no key baked in"; \
3846 3846 fi
3847 3847
3848 + # =====================================================================
3849 + # The answer sheet — the install questions the builder already answered.
3850 + # =====================================================================
3851 + # `alloy install` asks five questions, and on a machine the builder already
3852 + # knows about most of them have one right answer, decided when the medium was
3853 + # minted. These arguments are those answers, and the RUN below writes them to
3854 + # /usr/lib/alloy/answers.toml, which crates/alloy/src/preseed.rs reads once when
3855 + # the wizard opens. A step the sheet answers in full is skipped.
3856 + #
3857 + # This is the same idea as the pubkey above and lives beside it deliberately:
3858 + # what the builder knew, carried on the medium so the installer does not ask
3859 + # again. Alloy is distributed as a builder rather than as an image (wiki
3860 + # `alloy-distribution`), so the person minting is the person installing, and
3861 + # their recipe is their answer sheet. Per-host recipes live in build/hosts/.
3862 + #
3863 + # **No secrets, and this is the line that must not move.** Not the account
3864 + # password, not the LUKS passphrase. The identity block above states the
3865 + # invariant: the image "can be kept, copied or rebuilt without care and a leak
3866 + # of it costs nothing". A passphrase written here would be in every layer cache,
3867 + # every `podman save` and on every stick written from the medium. So the account
3868 + # and encryption steps are still asked, with everything except the secret
3869 + # already filled in, and `ALLOY_ENCRYPT=yes` answers only the checkbox.
3870 + #
3871 + # ALLOY_DISK is a rule and never a device path. A medium that erases
3872 + # /dev/nvme0n1 without asking is one wrong laptop away from erasing the wrong
3873 + # machine; a rule that stops matching falls back to asking, which is the
3874 + # behaviour the installer's tests pin.
3875 + ARG ALLOY_USERNAME=
3876 + ARG ALLOY_ENCRYPT=
3877 + ARG ALLOY_LOCATE_TIMEZONE=
3878 + ARG ALLOY_DISK=
3879 +
3880 + RUN set -eu; \
3881 + mkdir -p /usr/lib/alloy; \
3882 + sheet=/usr/lib/alloy/answers.toml; \
3883 + rm -f "$sheet"; \
3884 + # A yes/no argument that is neither is a typo, and a typo that defaulted
3885 + # quietly would decide encryption for a machine nobody asked. Refuse it.
3886 + bool() { \
3887 + case "$2" in \
3888 + yes|true|on) echo "$1 = true" >> "$sheet" ;; \
3889 + no|false|off) echo "$1 = false" >> "$sheet" ;; \
3890 + *) echo "$1 is '$2'; expected yes or no" >&2; exit 1 ;; \
3891 + esac; \
3892 + }; \
3893 + if [ -n "$ALLOY_HOSTNAME" ]; then \
3894 + echo "hostname = \"$ALLOY_HOSTNAME\"" >> "$sheet"; \
3895 + fi; \
3896 + if [ -n "$ALLOY_USERNAME" ]; then \
3897 + echo "$ALLOY_USERNAME" | grep -qE '^[a-z_][a-z0-9_-]{0,31}$' \
3898 + || { echo "ALLOY_USERNAME '$ALLOY_USERNAME' is not a username" >&2; exit 1; }; \
3899 + echo "username = \"$ALLOY_USERNAME\"" >> "$sheet"; \
3900 + fi; \
3901 + if [ -n "$ALLOY_DISK" ]; then \
3902 + case "$ALLOY_DISK" in \
3903 + single-internal|single-internal-nvme) ;; \
3904 + *) echo "ALLOY_DISK '$ALLOY_DISK' is not a rule; expected single-internal or single-internal-nvme. A device path is deliberately not accepted here" >&2; exit 1 ;; \
3905 + esac; \
3906 + echo "disk = \"$ALLOY_DISK\"" >> "$sheet"; \
3907 + fi; \
3908 + [ -z "$ALLOY_ENCRYPT" ] || bool encrypt "$ALLOY_ENCRYPT"; \
3909 + [ -z "$ALLOY_LOCATE_TIMEZONE" ] || bool locate_timezone "$ALLOY_LOCATE_TIMEZONE"; \
3910 + if [ -f "$sheet" ]; then \
3911 + chmod 0644 "$sheet"; \
3912 + # The hostname is written twice from one argument, here and into
3913 + # DEFAULT_HOSTNAME above, so the installer's default and its statement
3914 + # that the question is answered cannot disagree. Asserted rather than
3915 + # trusted, because they are two `sed`-shaped writes in two steps.
3916 + if [ -n "$ALLOY_HOSTNAME" ]; then \
3917 + grep -q "^hostname = \"$ALLOY_HOSTNAME\"$" "$sheet" \
3918 + && grep -q "^DEFAULT_HOSTNAME=$ALLOY_HOSTNAME$" /usr/lib/os-release \
3919 + || { echo "the answer sheet and os-release disagree about the hostname" >&2; exit 1; }; \
3920 + fi; \
3921 + echo "answers: $(wc -l < "$sheet") prefilled"; \
3922 + sed 's/^/answers: /' "$sheet"; \
3923 + else \
3924 + echo "answers: none, every question will be asked"; \
3925 + fi
3926 +
3848 3927 # =====================================================================
3849 3928 # The build stamp — which build of the product this image is.
3850 3929 # =====================================================================
@@ -22,3 +22,25 @@
22 22 # seeds its account step from the baked key, so a reinstalled fw12 comes up
23 23 # with fw13 already able to log in, with nothing typed at the machine.
24 24 ALLOY_SSH_KEY=@~/.ssh/id_ed25519.pub
25 +
26 + # The install questions the recipe answers, so a reinstall stops being five
27 + # screens of retyping what this file already knows. Read by the installer from
28 + # /usr/lib/alloy/answers.toml; see docs/IMAGE.md, "Per-machine mint recipes".
29 + #
30 + # The account and the passphrase are still asked, and that is deliberate rather
31 + # than unfinished: a secret written here would be in every layer cache and on
32 + # every stick. What is left to type is one password and one passphrase.
33 + ALLOY_USERNAME=max
34 +
35 + # A rule, never a device path. If fw12 ever comes back with something other
36 + # than one internal NVMe in it, this stops matching and the installer asks
37 + # instead of erasing whatever it found.
38 + ALLOY_DISK=single-internal-nvme
39 +
40 + # Encrypted, per the standing posture for a laptop that leaves the house. This
41 + # answers the checkbox only; the passphrase is typed at the machine.
42 + ALLOY_ENCRYPT=yes
43 +
44 + # No. The lookup sends this machine's address to a third party, and a machine
45 + # that comes up UTC can be fixed in `alloy settings` in a second.
46 + ALLOY_LOCATE_TIMEZONE=no
@@ -2,8 +2,14 @@
2 2
3 3 Scripts that boot an Alloy ISO headless and work the wizard from outside the
4 4 guest. They exist because `alloy install` is a ratatui program with no headless
5 - mode and no answer file, so the only way to test the thing an installer medium
6 - actually does is to answer its questions and read the screen back.
5 + mode, so the only way to test the thing an installer medium actually does is to
6 + answer its questions and read the screen back.
7 +
8 + A medium can carry an answer sheet (`/usr/lib/alloy/answers.toml`, written from
9 + a per-host recipe) and the installer skips the steps it answers in full. That is
10 + a smaller set of questions to drive, never none: the account password and the
11 + LUKS passphrase are secrets and are never on a medium. `install_preseeded.py` is
12 + the scenario for a medium that carries one.
7 13
8 14 Two halves, and they answer different questions. The tools (`run-vm.sh`,
9 15 `vm.py`, `serial.py`, `ssh_pty.py`, `tui.py`, `qmp.py`) are for working a
@@ -38,6 +44,15 @@
38 44 python3 tui.py 60 'step 1 of 6' ssh -tt -p 2222 installer@127.0.0.1
39 45
40 46 ./offline-first-boot.sh # the scripted scenario; see below
47 + TARGET_BUS=nvme ./run-vm.sh live # a target that reports `tran: nvme`
48 + python3 install_preseeded.py # install from a medium with an answer sheet
49 +
50 + `TARGET_BUS` decides how the target disk is attached, and it is not a
51 + performance knob. A recipe whose disk rule is `single-internal-nvme` cannot be
52 + exercised against a virtio disk, whose lsblk `tran` is null, and a rule that
53 + does not match falls back to asking, which looks exactly like the feature being
54 + broken. Default stays `virtio`, because every scenario written before this one
55 + assumes `vda`.
41 56
42 57 `vm.py shot` converts qemu's PPM to PNG with nothing but zlib and struct, so
43 58 the installer screen can be read directly with no image tooling installed.
@@ -87,20 +87,25 @@
87 87 ]
88 88
89 89
90 - def connect_ssh(key, port, timeout):
90 + def connect_ssh(key, port, timeout, first_step=r"step \d+ of 6"):
91 91 """Keep trying until the medium's sshd is up, or give up saying so.
92 92
93 93 A live ISO takes tens of seconds to reach multi-user, and the account this
94 94 logs in as is created by a unit inside that boot, so early attempts are
95 95 expected to fail. What is not expected is all of them failing, which is
96 - what the deadline turns into a message."""
96 + what the deadline turns into a message.
97 +
98 + `first_step` is what counts as the wizard having drawn itself. It is a
99 + pattern rather than `step 1 of 6` because a medium carrying an answer sheet
100 + opens on the first question it cannot answer, which is not step 1; a caller
101 + testing that asserts the number itself once the screen is up."""
97 102 deadline = time.time() + timeout
98 103 last = ""
99 104 while time.time() < deadline:
100 105 s = Session(ssh_argv(key, "installer", port),
101 106 log_path=os.path.join(SCRATCH, "install-drive.raw"))
102 107 try:
103 - s.wait_for(r"step 1 of 6", min(45.0, max(5.0, deadline - time.time())))
108 + s.wait_for(first_step, min(45.0, max(5.0, deadline - time.time())))
104 109 return s
105 110 except Timeout as e:
106 111 last = e.screen
@@ -366,7 +371,7 @@
366 371 args.pubkey = open(path).read().strip()
367 372
368 373 if args.via == "ssh":
369 - s = connect_ssh(args.key, args.port, args.connect_timeout)
374 + s = connect_ssh(args.key, args.port, args.connect_timeout, r"step 1 of 6")
370 375 else:
371 376 s = connect_serial(args.rows, args.cols, args.connect_timeout)
372 377 try:
@@ -32,11 +32,27 @@
32 32 fi
33 33 [ -f "$DISK" ] || qemu-img create -f qcow2 "$DISK" 40G >/dev/null
34 34
35 + # How the target disk is attached, which matters to more than performance.
36 + # `virtio` gives the guest a /dev/vda whose lsblk `tran` is null; `nvme` gives it
37 + # a /dev/nvme0n1 that reports `tran: nvme`. A recipe whose disk rule is
38 + # `single-internal-nvme` (build/hosts/fw12.env) can only be exercised against
39 + # the second, so a scenario testing a prefilled install asks for it. Default
40 + # stays virtio, because every scenario written before this one assumes vda.
41 + TARGET_BUS="${TARGET_BUS:-virtio}"
42 + case "$TARGET_BUS" in
43 + virtio) TARGET_ARGS=(-drive "file=$DISK,if=virtio,format=qcow2") ;;
44 + nvme) TARGET_ARGS=(
45 + -drive "file=$DISK,if=none,id=target,format=qcow2"
46 + -device "nvme,serial=alloyvmtest,drive=target"
47 + ) ;;
48 + *) echo "unknown TARGET_BUS '$TARGET_BUS'; expected virtio or nvme" >&2; exit 1 ;;
49 + esac
50 +
35 51 args=(
36 52 -enable-kvm -machine q35 -cpu host -m 4608 -smp 4
37 53 -drive if=pflash,format=raw,unit=0,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd
38 54 -drive "if=pflash,format=raw,unit=1,file=$VARS"
39 - -drive "file=$DISK,if=virtio,format=qcow2"
55 + "${TARGET_ARGS[@]}"
40 56 # Two forwards, and the second one is the firewall probe. User-mode
41 57 # networking gives the guest no inbound path except a hostfwd, so a port the
42 58 # host cannot reach at all proves nothing about the firewall: :2223 is a real
@@ -1,0 +1,148 @@
1 + #!/usr/bin/env python3
2 + """Install from a medium that carries its own answers, and check what it skipped.
3 +
4 + `install_drive.py` answers all five questions the way a person would. This one
5 + answers only the two that cannot be on a medium, and asserts that the installer
6 + never asked the rest. The difference between the two scripts is the whole
7 + feature: a medium minted with `--host fw12` carries
8 + `/usr/lib/alloy/answers.toml`, and `alloy install` skips every step that file
9 + answers in full.
10 +
11 + ## What it proves, in order
12 +
13 + 1. **The wizard opens on step 3 of 6.** The disk and the hostname were on the
14 + medium, so neither was asked. This is the assertion the feature exists for,
15 + and it is made against the title the installer draws rather than against a
16 + log line, because the title is what a person would have seen.
17 + 2. **The username arrived filled in** and the password did not.
18 + 3. **The review names the prefilled answers and where they came from.** A
19 + summary that quietly showed answers nobody typed would be worse than asking:
20 + the screen exists to be checked, and a reader cannot check what it does not
21 + attribute.
22 + 4. **The install completes**, so a skipped step is a step that was genuinely
23 + answered rather than one that was merely hidden.
24 +
25 + ## Why the target is an NVMe
26 +
27 + fw12's recipe says `ALLOY_DISK=single-internal-nvme`, and a rule that does not
28 + match falls back to asking, which would look exactly like the feature not
29 + working. `TARGET_BUS=nvme` gives the guest a disk that reports `tran: nvme`, so
30 + the machine under test has the shape the recipe describes. Run it with:
31 +
32 + TARGET_BUS=nvme ./run-vm.sh live
33 +
34 + The medium must be minted `--host fw12`, which is also what bakes in the pubkey
35 + this logs in with.
36 +
37 + Exits 0 when the install reports success, 1 when an assertion about the screen
38 + fails or the install does, and 3 when the run could not get far enough to have
39 + a verdict.
40 + """
41 +
42 + import argparse
43 + import os
44 + import re
45 + import sys
46 +
47 + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
48 + from install_drive import ENTER, TAB, connect_ssh, wait_for_verdict # noqa: E402
49 + from tui import Timeout # noqa: E402
50 +
51 + STEP = re.compile(r"step (\d+) of 6")
52 +
53 +
54 + def current_step(s):
55 + """The step number the title is showing, or None if no title is up."""
56 + hit = STEP.search(s.screen.text())
57 + return int(hit.group(1)) if hit else None
58 +
59 +
60 + def check(condition, message, s):
61 + """Assert something about the screen, and print the screen when it fails.
62 +
63 + A failed assertion here is a claim about what the installer displayed, so
64 + the display is the evidence and it goes in the output every time."""
65 + if condition:
66 + return
67 + print(s.screen.text())
68 + raise SystemExit("error: %s" % message)
69 +
70 +
71 + def main():
72 + p = argparse.ArgumentParser(description="install from a medium that answers its own questions")
73 + p.add_argument("--key", default=os.path.expanduser("~/.ssh/id_ed25519"),
74 + help="private key matching the pubkey baked into the medium")
75 + p.add_argument("--port", type=int, default=2222)
76 + p.add_argument("--password", default="alloytest")
77 + p.add_argument("--passphrase", default="alloytestalloytest")
78 + p.add_argument("--hostname", default="fw12", help="what the recipe baked in")
79 + p.add_argument("--user", default="max", help="what the recipe baked in")
80 + p.add_argument("--disk", default="/dev/nvme0n1",
81 + help="what the recipe's disk rule should have resolved to")
82 + p.add_argument("--connect-timeout", type=float, default=300.0)
83 + p.add_argument("--install-timeout", type=float, default=1800.0)
84 + args = p.parse_args()
85 +
86 + s = connect_ssh(args.key, args.port, args.connect_timeout)
87 + try:
88 + # 1. The headline. Two steps were answered by the medium, so the first
89 + # screen a person sees is the third.
90 + step = current_step(s)
91 + check(step == 3, "the wizard opened on step %s of 6, so the medium's answers were not "
92 + "used; step 1 means the disk rule did not resolve (is TARGET_BUS=nvme "
93 + "set?) and the status line says which" % step, s)
94 +
95 + # 2. The account, with the half that can be prefilled already there.
96 + screen = s.screen.text()
97 + check(args.user in screen,
98 + "the account step does not show the username %r the recipe baked in" % args.user, s)
99 + s.send(args.password)
100 + s.send(TAB)
101 + s.send(args.password)
102 + s.send(ENTER)
103 +
104 + # 3. Encryption. `ALLOY_ENCRYPT=yes` answers the checkbox and never the
105 + # passphrase, so this step is shown with one thing left to type.
106 + s.wait_for(r"step 4 of 6", 30)
107 + s.send(TAB)
108 + s.send(args.passphrase)
109 + s.send(TAB)
110 + s.send(args.passphrase)
111 + s.send(ENTER)
112 +
113 + # 4. The review, which has to say what was decided elsewhere.
114 + s.wait_for(r"step 5 of 6", 30)
115 + s.pump(0.5)
116 + screen = s.screen.text()
117 + check("from the medium" in screen,
118 + "the review does not attribute any answer to the medium", s)
119 + check(args.hostname in screen,
120 + "the review does not show the baked hostname %r" % args.hostname, s)
121 + check(args.disk in screen,
122 + "the review shows a target other than %r, so the disk rule resolved to the wrong "
123 + "disk" % args.disk, s)
124 + s.send(ENTER)
125 +
126 + # 5. The credits, which is the step that acts.
127 + s.wait_for(r"step 6 of 6", 30)
128 + s.send(ENTER)
129 + s.wait_for(r"Erase .* and install Alloy", 30)
130 + s.send(ENTER)
131 +
132 + print("==> installing; this is the long part", flush=True)
133 + ok = wait_for_verdict(s, args.install_timeout)
134 + print(s.screen.text())
135 + if not ok:
136 + print("\nerror: the installer finished without offering a reboot, "
137 + "which is how it says the install failed", file=sys.stderr)
138 + return 1
139 + return 0
140 + except Timeout as e:
141 + print("error: %s" % e, file=sys.stderr)
142 + return 3
143 + finally:
144 + s.close()
145 +
146 +
147 + if __name__ == "__main__":
148 + sys.exit(main())
@@ -1,0 +1,122 @@
1 + //! The answer sheet is written by one file and read by another, so they are
2 + //! pinned to each other here.
3 + //!
4 + //! `crates/alloy/src/preseed.rs` reads `/usr/lib/alloy/answers.toml` and knows
5 + //! which keys and which disk rules exist. The Containerfile writes that file
6 + //! from build arguments and validates the same vocabulary in shell. Neither can
7 + //! see the other, and a change to one alone is a silent break: the installer
8 + //! would ask every question on a medium whose builder thought it had answered
9 + //! them, which is precisely the failure the feature exists to remove.
10 + //!
11 + //! Read rather than executed, for the same reason `ssh_policy.rs` reads the
12 + //! sshd config: running the build to find out costs an hour, and what is being
13 + //! checked is an agreement between two texts.
14 +
15 + use std::path::PathBuf;
16 +
17 + fn tree(relative: &str) -> String {
18 + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
19 + .join("../..")
20 + .join(relative);
21 + std::fs::read_to_string(&path)
22 + .unwrap_or_else(|error| panic!("reading {}: {error}", path.display()))
23 + }
24 +
25 + /// The path the installer reads and the Containerfile writes.
26 + const SHEET: &str = "/usr/lib/alloy/answers.toml";
27 +
28 + #[test]
29 + fn the_medium_writes_the_sheet_where_the_installer_reads_it() {
30 + assert!(
31 + tree("crates/alloy/src/preseed.rs").contains(&format!("\"{SHEET}\"")),
32 + "the installer does not read {SHEET}",
33 + );
34 + assert!(
35 + tree("Containerfile").contains(SHEET),
36 + "the Containerfile does not write {SHEET}",
37 + );
38 + }
39 +
40 + /// Every key the sheet can carry has a build argument to carry it.
41 + ///
42 + /// `build/host-recipe.sh` refuses a recipe key that is not an `ARG` the
43 + /// Containerfile declares, so a missing one here is not a silent drop; it is a
44 + /// mint that fails. This test is the cheaper way to find that out.
45 + #[test]
46 + fn every_answer_has_an_argument_and_a_field() {
47 + let containerfile = tree("Containerfile");
48 + let preseed = tree("crates/alloy/src/preseed.rs");
49 + for (arg, field) in [
50 + ("ALLOY_HOSTNAME", "hostname"),
51 + ("ALLOY_USERNAME", "username"),
52 + ("ALLOY_DISK", "disk"),
53 + ("ALLOY_ENCRYPT", "encrypt"),
54 + ("ALLOY_LOCATE_TIMEZONE", "locate_timezone"),
55 + ] {
56 + assert!(
57 + containerfile.contains(&format!("ARG {arg}=")),
58 + "the Containerfile declares no {arg}",
59 + );
60 + assert!(
61 + preseed.contains(&format!("pub {field}:")),
62 + "the answer sheet has no {field} field for {arg}",
63 + );
64 + }
65 + }
66 +
67 + /// The disk vocabulary is the same in both places.
68 + ///
69 + /// This is the one that would fail quietly and dangerously. If the shell
70 + /// accepted a rule name the installer does not deserialize, the sheet would
71 + /// fail to parse and every question would be asked; if the installer accepted
72 + /// one the shell rejects, the mint would fail instead, which is loud. Both
73 + /// directions are checked because only one of them is loud.
74 + #[test]
75 + fn the_disk_rules_are_spelled_the_same_on_both_sides() {
76 + let containerfile = tree("Containerfile");
77 + let preseed = tree("crates/alloy/src/preseed.rs");
78 + for rule in ["single-internal", "single-internal-nvme"] {
79 + assert!(
80 + containerfile.contains(rule),
81 + "the Containerfile does not accept the rule {rule}",
82 + );
83 + assert!(
84 + preseed.contains(rule),
85 + "the installer does not document the rule {rule}",
86 + );
87 + }
88 + // The rule vocabulary is closed on purpose: a device path here is the
89 + // failure the whole design is arranged around, so the refusal is pinned
90 + // rather than left to the reviewer.
91 + assert!(
92 + containerfile.contains("A device path is deliberately not accepted here"),
93 + "the Containerfile no longer says why a device path is refused",
94 + );
95 + }
96 +
97 + /// fw12's recipe is the one this feature was built for, so it is checked.
98 + ///
99 + /// Not a style preference: an install medium minted from a recipe that names a
100 + /// key nothing reads is a medium that asks questions the builder answered, and
101 + /// the person finding that out is standing in front of a laptop with a USB
102 + /// stick in it.
103 + #[test]
104 + fn the_fw12_recipe_only_names_arguments_that_exist() {
105 + let containerfile = tree("Containerfile");
106 + for line in tree("build/hosts/fw12.env").lines() {
107 + let line = line.split('#').next().unwrap_or("").trim();
108 + if line.is_empty() {
109 + continue;
110 + }
111 + let key = line.split('=').next().unwrap_or("").trim();
112 + // ARCH is build/host-recipe.sh's own reserved key and never a build
113 + // argument; see that file's header.
114 + if key == "ARCH" {
115 + continue;
116 + }
117 + assert!(
118 + containerfile.contains(&format!("ARG {key}=")),
119 + "build/hosts/fw12.env sets {key}, which the Containerfile does not declare",
120 + );
121 + }
122 + }