Skip to main content

max / alloy

8.6 KB · 198 lines History Blame Raw
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. fw12's recipe encrypts, so
24 this is also the first scenario to drive the encrypted path to its end: it
25 reads the recovery phrase off the screen and types it back, which is what
26 the installer waits for before it will say "finished".
27
28 ## Why the target is an NVMe
29
30 fw12's recipe says `ALLOY_DISK=single-internal-nvme`, and a rule that does not
31 match falls back to asking, which would look exactly like the feature not
32 working. `TARGET_BUS=nvme` gives the guest a disk that reports `tran: nvme`, so
33 the machine under test has the shape the recipe describes. Run it with:
34
35 TARGET_BUS=nvme ./run-vm.sh live
36
37 The medium must be minted `--host fw12`, which is also what bakes in the pubkey
38 this logs in with.
39
40 Exits 0 when the install reports success, 1 when an assertion about the screen
41 fails or the install does, and 3 when the run could not get far enough to have
42 a verdict.
43 """
44
45 import argparse
46 import os
47 import re
48 import sys
49
50 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
51 from install_drive import ENTER, TAB, connect_ssh, wait_for_verdict # noqa: E402
52 from tui import Timeout # noqa: E402
53
54 STEP = re.compile(r"step (\d+) of 6")
55
56 # The recovery phrase, as `render_recovery` draws it: eight lowercase words on
57 # their own indented line, in the action color, between the prose above and the
58 # field below. Anchored on the shape rather than on a line number, because the
59 # prose around it is the part likely to be reworded.
60 #
61 # The bounds are `[^A-Za-z]*` rather than `\s*` because this is read off a
62 # rendered TUI and every line carries the pane's own border characters. A
63 # whitespace-anchored pattern finds nothing here, which is a wrong answer that
64 # looks like a missing phrase.
65 PHRASE = re.compile(r"^[^A-Za-z]*([a-z]+(?: [a-z]+){7})[^A-Za-z]*$", re.M)
66
67 # Where the phrase is, said in the prose above it. Searching after this rather
68 # than over the whole screen keeps the pattern from matching an unlucky line of
69 # lowercase prose somewhere else on the page.
70 PHRASE_AFTER = "is not stored anywhere"
71
72
73 def finish_encrypted(s, timeout):
74 """Answer the recovery gate, which only an encrypted install reaches.
75
76 The install is over by the time this screen appears; what it gates is the
77 installer saying "finished, reboot". A user who reboots without these words
78 has a machine whose disk dies with its TPM, so the installer will not move
79 on until they are typed back, and a scenario that stops here would leave the
80 encrypted path asserted only as far as the last command.
81
82 `install_drive.py` defaults encryption off and never reaches this, which is
83 why this lives here rather than there."""
84 s.wait_for(r"Type it back to confirm you have it", timeout)
85 s.pump(0.5)
86 screen = s.screen.text()
87 check("Installation finished" in screen,
88 "the recovery screen says the install did not finish", s)
89 tail = screen.split(PHRASE_AFTER, 1)[-1]
90 hit = PHRASE.search(tail)
91 check(hit is not None, "no eight-word recovery phrase on the screen that asks for one", s)
92 s.send(hit.group(1))
93 s.send(ENTER)
94
95
96 def current_step(s):
97 """The step number the title is showing, or None if no title is up."""
98 hit = STEP.search(s.screen.text())
99 return int(hit.group(1)) if hit else None
100
101
102 def check(condition, message, s):
103 """Assert something about the screen, and print the screen when it fails.
104
105 A failed assertion here is a claim about what the installer displayed, so
106 the display is the evidence and it goes in the output every time."""
107 if condition:
108 return
109 print(s.screen.text())
110 raise SystemExit("error: %s" % message)
111
112
113 def main():
114 p = argparse.ArgumentParser(description="install from a medium that answers its own questions")
115 p.add_argument("--key", default=os.path.expanduser("~/.ssh/id_ed25519"),
116 help="private key matching the pubkey baked into the medium")
117 p.add_argument("--port", type=int, default=2222)
118 p.add_argument("--password", default="alloytest")
119 p.add_argument("--passphrase", default="alloytestalloytest")
120 p.add_argument("--hostname", default="fw12", help="what the recipe baked in")
121 p.add_argument("--user", default="max", help="what the recipe baked in")
122 p.add_argument("--disk", default="/dev/nvme0n1",
123 help="what the recipe's disk rule should have resolved to")
124 p.add_argument("--connect-timeout", type=float, default=300.0)
125 p.add_argument("--install-timeout", type=float, default=1800.0)
126 args = p.parse_args()
127
128 s = connect_ssh(args.key, args.port, args.connect_timeout)
129 try:
130 # 1. The headline. Two steps were answered by the medium, so the first
131 # screen a person sees is the third.
132 step = current_step(s)
133 check(step == 3, "the wizard opened on step %s of 6, so the medium's answers were not "
134 "used; step 1 means the disk rule did not resolve (is TARGET_BUS=nvme "
135 "set?) and the status line says which" % step, s)
136
137 # 2. The account, with the half that can be prefilled already there.
138 # Focus arrives on the password, not on the username, because the
139 # username is answered: see InstallView::focus_first_gap. So this
140 # types the password, tabs once to the confirmation, and submits,
141 # which also leaves the seeded key field alone.
142 screen = s.screen.text()
143 check(args.user in screen,
144 "the account step does not show the username %r the recipe baked in" % args.user, s)
145 s.send(args.password)
146 s.send(TAB)
147 s.send(args.password)
148 s.send(ENTER)
149
150 # 3. Encryption. `ALLOY_ENCRYPT=yes` answers the checkbox and never the
151 # passphrase, so this step is shown with one thing left to type and
152 # focus already on it, one slot past the checkbox.
153 s.wait_for(r"step 4 of 6", 30)
154 s.send(args.passphrase)
155 s.send(TAB)
156 s.send(args.passphrase)
157 s.send(ENTER)
158
159 # 4. The review, which has to say what was decided elsewhere.
160 s.wait_for(r"step 5 of 6", 30)
161 s.pump(0.5)
162 screen = s.screen.text()
163 check("from the medium" in screen,
164 "the review does not attribute any answer to the medium", s)
165 check(args.hostname in screen,
166 "the review does not show the baked hostname %r" % args.hostname, s)
167 check(args.disk in screen,
168 "the review shows a target other than %r, so the disk rule resolved to the wrong "
169 "disk" % args.disk, s)
170 s.send(ENTER)
171
172 # 5. The credits, which is the step that acts.
173 s.wait_for(r"step 6 of 6", 30)
174 s.send(ENTER)
175 s.wait_for(r"Erase .* and install Alloy", 30)
176 s.send(ENTER)
177
178 print("==> installing; this is the long part", flush=True)
179 # Encrypted installs stop on the recovery phrase before they will admit
180 # to being finished. Answer it, then read the footer as usual.
181 finish_encrypted(s, args.install_timeout)
182 ok = wait_for_verdict(s, 120)
183 print(s.screen.text())
184 if not ok:
185 print("\nerror: the installer finished without offering a reboot, "
186 "which is how it says the install failed", file=sys.stderr)
187 return 1
188 return 0
189 except Timeout as e:
190 print("error: %s" % e, file=sys.stderr)
191 return 3
192 finally:
193 s.close()
194
195
196 if __name__ == "__main__":
197 sys.exit(main())
198