max / alloy
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 | + | } |