Skip to main content

max / alloy

20.0 KB · 415 lines History Blame Raw
1 #!/usr/bin/env bash
2 #
3 # check-host.sh — ask a running Alloy machine the questions that used to cost a
4 # sitting, and answer them as pass or fail rows.
5 #
6 # Why this exists. On 2026-08-31 eighteen GoingsOn tasks were closed carrying
7 # `dropped:2026-08-31`, and every one of them was a verification that needed a
8 # person in front of a machine: press the Print binds, press the brightness
9 # keys, confirm three LUKS keyslots, confirm a .local name resolves. None was
10 # blocked on code. They piled up because the only machine running Alloy could
11 # not be reached from the machine doing the work.
12 #
13 # So the rule this script serves, from wiki `alloy-fleet-cutover`: no task whose
14 # output is an observation a person has to transcribe. Anything mechanical is a
15 # row here, and what is left for a person is only what genuinely needs eyes.
16 #
17 # Usage:
18 # build/check-host.sh # this machine
19 # ssh fw12 'bash -s' < build/check-host.sh
20 # sudo build/check-host.sh # adds the rows that need root
21 # ssh -t fw12.local 'sudo bash -s' < build/check-host.sh # both, with a prompt
22 #
23 # Four rows need root and skip without it: the image identity (`bootc status`
24 # refuses an unprivileged caller), the SELinux labels, the LUKS keyslot count,
25 # and the firewall's trusted-zone membership. Alloy's sudo asks for a password,
26 # so a BatchMode ssh run answers thirteen of seventeen rows and says so.
27 # build/check-host.sh --self-test # checks the harness; touches nothing
28 #
29 # Exit codes, following build/check-installed.sh:
30 #
31 # 0 every row that could run passed.
32 # 1 at least one row failed. The row says what and, where there is one, the
33 # repair.
34 # 3 the question cannot be asked here: this is not an Alloy machine.
35 #
36 # SKIP is not a pass and not a failure. A row skips when the machine cannot be
37 # asked -- no graphical session over ssh, no root for a LUKS dump, no NVIDIA
38 # card -- and the count is printed at the end so a run that answered half the
39 # questions cannot read as a clean bill.
40 #
41 # What deliberately is NOT here: anything whose verdict is a judgement. Glyph
42 # crispness, the dark-mode L stops and whether 1.75x looks right are eyeball
43 # work, and a script that pretended to answer them would be worse than silence.
44
45 set -uo pipefail
46
47 PASSED=0
48 FAILED=0
49 SKIPPED=0
50
51 row() { # row STATUS NAME DETAIL...
52 local status="$1" name="$2"; shift 2
53 case "$status" in
54 PASS) PASSED=$((PASSED + 1)) ;;
55 FAIL) FAILED=$((FAILED + 1)) ;;
56 SKIP) SKIPPED=$((SKIPPED + 1)) ;;
57 esac
58 printf '%-4s %-16s %s\n' "$status" "$name" "$*"
59 }
60
61 have() { command -v "$1" >/dev/null 2>&1; }
62 is_root() { [ "$(id -u)" -eq 0 ]; }
63
64 # The user whose configuration is being asked about, which is not root even
65 # when the run is. sudo leaves HOME=/root, so a row that reads $HOME asks
66 # whether root has a sway config and answers no on a machine that is correctly
67 # set up. That is how the privileged run on fw12 reported zero screenshot binds
68 # on 2026-09-03 while the unprivileged one found all four.
69 target_user() { echo "${SUDO_USER:-$(id -un)}"; }
70 # passwd first, $HOME only when passwd cannot answer. The order matters and is
71 # the reason $HOME is not simply used: under sudo it is /root, which is how a
72 # privileged run once reported a correctly configured machine as having no sway
73 # config. But an unnamed uid has no passwd entry at all -- a container run with
74 # `--user 1000:1000` against an image whose accounts the installer has not
75 # created yet -- and there the choice is $HOME or nothing.
76 target_home() {
77 local home
78 home="$(getent passwd "$(target_user)" 2>/dev/null | cut -d: -f6)"
79 [ -n "$home" ] || home="${HOME:-}"
80 echo "$home"
81 }
82 # A graphical session is not reachable over a plain ssh connection, which is
83 # how this script is usually run. Rows that need one skip rather than fail.
84 has_session() { [ -n "${WAYLAND_DISPLAY:-}" ] && have swaymsg; }
85
86 self_test() {
87 local out fails=0
88 out="$(PASSED=0; FAILED=0; SKIPPED=0; row FAIL x y >/dev/null; echo "$FAILED")"
89 [ "$out" = "1" ] || { echo "self-test: a FAIL row did not count as a failure" >&2; fails=1; }
90 out="$(PASSED=0; FAILED=0; SKIPPED=0; row SKIP x y >/dev/null; echo "$FAILED")"
91 [ "$out" = "0" ] || { echo "self-test: a SKIP row counted as a failure" >&2; fails=1; }
92 [ "$fails" -eq 0 ] && echo "self-test: ok"
93 exit "$fails"
94 }
95
96 [ "${1:-}" = "--self-test" ] && self_test
97
98 # ---------------------------------------------------------------- identity ---
99 # Exit 3 rather than fail: a machine that is not Alloy is outside what any row
100 # below can answer, and saying so is the honest result.
101 . /etc/os-release 2>/dev/null || true
102 [ "${ID:-}" = "alloy" ] || { printf 'error: not an Alloy machine (ID=%s)\n' "${ID:-unknown}" >&2; exit 3; }
103 row PASS os "alloy ${VERSION_ID:-?} build ${IMAGE_VERSION:-unstamped}"
104
105 # ------------------------------------------------------------------- image ---
106 # `bootc status` is the row; parsing its JSON for the image name is a
107 # convenience, so an output shape this does not recognise degrades to a plainer
108 # detail rather than to a verdict.
109 # `bootc status` needs root: it opens the deployment for write before it will
110 # answer. Reported as SKIP without it rather than as a failure, which is what
111 # the first real run of this script got wrong on fw12 2026-09-03.
112 if ! have bootc; then
113 row FAIL image "no bootc on an image-based system"
114 elif ! is_root; then
115 row SKIP image "needs root: bootc status refuses an unprivileged caller"
116 else
117 if bootc status >/dev/null 2>&1; then
118 booted="$(bootc status --format=json 2>/dev/null \
119 | tr -d ' \n' | grep -o '"image":"[^"]*"' | head -1 | cut -d'"' -f4)"
120 row PASS image "${booted:-bootc answers; image name not parsed from this output shape}"
121 else
122 row FAIL image "bootc is present and \`bootc status\` fails"
123 fi
124 fi
125
126 # ------------------------------------------------- labels, and what it costs --
127 # Delegated: build/check-installed.sh owns this question, including the
128 # semanage-lock filter and its own self-test. It needs root to read the labels.
129 if is_root && [ -x "$(dirname "$0")/check-installed.sh" ]; then
130 if out="$("$(dirname "$0")/check-installed.sh" 2>&1)"; then
131 row PASS labels "/etc and /usr match the policy, and a DynamicUser unit starts"
132 else
133 row FAIL labels "$(printf '%s' "$out" | head -3 | tr '\n' ' ') -- repair: restorecon -R /etc"
134 fi
135 else
136 row SKIP labels "needs root and build/check-installed.sh beside this script"
137 fi
138
139 # ------------------------------------------------------------- screenshots ---
140 # The four binds, from the config the compositor is actually running where
141 # there is one, and from the file otherwise. Pressing them is a person's job;
142 # that the bind exists and its command resolves is not.
143 if have alloy-shot; then
144 if has_session; then
145 binds="$(swaymsg -t get_config 2>/dev/null | grep -c 'exec alloy-shot')"
146 else
147 binds="$(grep -c 'exec alloy-shot' "$(target_home)/.config/sway/config" 2>/dev/null || echo 0)"
148 fi
149 if [ "${binds:-0}" -ge 4 ]; then
150 row PASS shot-binds "$binds alloy-shot binds, and the binary resolves"
151 else
152 row FAIL shot-binds "expected 4 alloy-shot binds (output, region, window, annotate), found ${binds:-0}"
153 fi
154 else
155 row FAIL shot-binds "alloy-shot is not on PATH, so all four Print binds are dead"
156 fi
157
158 # -------------------------------------------------------------- brightness ---
159 # Two separate failures wear the same face. The bind can be missing, or the
160 # session can lack permission to write the backlight, and only the second one
161 # survives a reinstall unnoticed.
162 if have swayosd-client; then
163 row PASS brightness-bind "swayosd-client resolves for the XF86MonBrightness binds"
164 else
165 row FAIL brightness-bind "swayosd-client is not on PATH, so the brightness keys do nothing"
166 fi
167 bl="$(find /sys/class/backlight -mindepth 1 -maxdepth 1 2>/dev/null | head -1)"
168 if [ -z "$bl" ]; then
169 row SKIP backlight "no backlight device on this machine"
170 elif [ -w "$bl/brightness" ]; then
171 row PASS backlight "$(basename "$bl") is writable by this user"
172 else
173 row FAIL backlight "$(basename "$bl")/brightness is not writable, so the keys cannot take effect"
174 fi
175
176 # -------------------------------------------------------------------- wifi ---
177 # Three states wear the same face on screen, and only one of them is fine.
178 # Measured on fw12 2026-09-03: the kernel had the interface and NetworkManager
179 # had no plugin to present it with, so `alloy net` showed loopback alone and
180 # nothing anywhere reported an error. No unit failed and the console was
181 # correct, which is why this is a row rather than something a user reports.
182 if nmcli -t -f DEVICE,TYPE device 2>/dev/null | grep -q ':wifi$'; then
183 seen="$(nmcli -t -f DEVICE,TYPE device 2>/dev/null | grep ':wifi$' | cut -d: -f1 | tr '\n' ' ')"
184 row PASS wifi "NetworkManager presents ${seen%% }"
185 elif ! ls /usr/lib64/NetworkManager/*/libnm-device-plugin-wifi.so >/dev/null 2>&1; then
186 row FAIL wifi "the image carries no NetworkManager wifi plugin, so no wireless device can ever appear"
187 elif ls -d /sys/class/net/*/wireless >/dev/null 2>&1; then
188 row FAIL wifi "the kernel has a wireless interface and NetworkManager does not present it; check wpa_supplicant and rfkill"
189 else
190 row SKIP wifi "no wireless interface on this machine"
191 fi
192
193 # ---------------------------------------------------------------- firewall ---
194 # The combination is the risk, not the firewall: tailscaled writes its own
195 # rules, and firewalld starting without tailscale0 in the trusted zone drops
196 # every inbound tailnet connection, which on a headless box is the login.
197 # systemctl answers unprivileged; `firewall-cmd --state` does not, and reading
198 # its refusal as "not running" reported a stopped firewall on a machine where
199 # firewalld was enabled and active (fw12, 2026-09-03).
200 if have firewall-cmd; then
201 if systemctl is-active firewalld >/dev/null 2>&1; then
202 if ! is_root; then
203 row SKIP firewall "running; the trusted-zone check needs root"
204 elif firewall-cmd --zone=trusted --query-interface=tailscale0 >/dev/null 2>&1; then
205 row PASS firewall "running, tailscale0 in the trusted zone"
206 else
207 row FAIL firewall "running, and tailscale0 is NOT trusted; inbound tailnet traffic is being dropped"
208 fi
209 elif systemctl is-enabled firewalld >/dev/null 2>&1; then
210 row FAIL firewall "firewalld is enabled and not running"
211 else
212 row FAIL firewall "firewalld is installed and neither enabled nor running"
213 fi
214 else
215 row SKIP firewall "no firewall-cmd in this image"
216 fi
217
218 # ------------------------------------------------------------ export wrapper --
219 # `alloy pkg` writes per-box wrappers under ~/.local/bin. A wrapper that is not
220 # on PATH is the failure that looks like nothing at all.
221 # The directory belongs to the user being checked; the PATH being searched
222 # belongs to whoever is running this. Under sudo those are different people, so
223 # the second half is not answerable and says so rather than reporting root's
224 # PATH as if it were the user's.
225 export_dir="$(target_home)/.local/bin"
226 if [ ! -d "$export_dir" ]; then
227 row SKIP export-path "nothing has been exported on this machine yet"
228 elif is_root && [ -n "${SUDO_USER:-}" ]; then
229 row SKIP export-path "$export_dir exists; whether it is on $(target_user)'s PATH cannot be read from a root shell"
230 else
231 case ":$PATH:" in
232 *":$export_dir:"*) row PASS export-path "$export_dir exists and is on PATH" ;;
233 *) row FAIL export-path "$export_dir exists and is NOT on PATH, so every exported wrapper is invisible" ;;
234 esac
235 fi
236
237 # ------------------------------------------------------------------- fonts ---
238 # Two halves: fontconfig resolves the face, and the face covers the glyphs the
239 # TUI draws with. A fallback that answers fc-match still draws broken tables.
240 if have fc-match; then
241 fam="$(fc-match -f '%{family}' 'Quasi Mono' 2>/dev/null)"
242 case "$fam" in
243 *Quasi*) row PASS font-match "Quasi Mono resolves to $fam" ;;
244 *) row FAIL font-match "Quasi Mono falls back to ${fam:-nothing}; the font layer did not take" ;;
245 esac
246 # U+2500, the box-drawing horizontal every table border is made of.
247 if fc-list ':charset=2500' family 2>/dev/null | grep -qi quasi; then
248 row PASS font-borders "the Quasi face covers U+2500, so table borders draw"
249 else
250 row FAIL font-borders "no Quasi face covers U+2500; borders will render from a fallback"
251 fi
252 else
253 row FAIL font-match "no fc-match, so nothing can resolve a font"
254 fi
255
256 # -------------------------------------------------------------------- luks ---
257 # Three keyslots is the shape the installer writes: the passphrase, the
258 # recovery key and the TPM binding. One means two of those are missing.
259 if ! is_root; then
260 row SKIP luks "needs root to dump the header"
261 elif ! have cryptsetup; then
262 row SKIP luks "no cryptsetup"
263 else
264 dev="$(lsblk -rno NAME,FSTYPE | awk '$2=="crypto_LUKS"{print "/dev/"$1; exit}')"
265 if [ -z "$dev" ]; then
266 row SKIP luks "no LUKS device; this machine was installed unencrypted"
267 else
268 dump="$(cryptsetup luksDump "$dev" 2>/dev/null)"
269 slots="$(printf '%s' "$dump" | grep -cE '^[[:space:]]+[0-9]+: luks2')"
270 if [ -z "$dump" ]; then
271 row SKIP luks "$dev did not dump; header unreadable"
272 elif [ "${slots:-0}" -eq 0 ]; then
273 # A dump with no recognised slot line is a format this does not read, not
274 # a device with no keyslots -- which cannot exist and would unlock nothing.
275 row SKIP luks "$dev dumped, no keyslot line recognised (LUKS1, or a changed format)"
276 elif [ "$slots" -ge 3 ]; then
277 row PASS luks "$dev has $slots keyslots"
278 else
279 row FAIL luks "$dev has $slots keyslots, expected 3 (passphrase, recovery, TPM)"
280 fi
281 fi
282 fi
283
284 # ------------------------------------------------------------------ udisks ---
285 # Whether a session user can mount a stick without a polkit rule of ours. The
286 # answer is a property of the shipped policy, so it is readable without root.
287 if have pkaction; then
288 # The label is polkit's own wording; an unrecognised one leaves impl empty
289 # and the row skips, because guessing here would report a prompt that is not
290 # there or miss one that is.
291 impl="$(pkaction --action-id org.freedesktop.udisks2.filesystem-mount --verbose 2>/dev/null \
292 | awk -F: '/implicit active/{gsub(/[[:space:]]/,"",$2); print $2; exit}')"
293 case "$impl" in
294 yes) row PASS udisks "filesystem-mount is allowed for an active session" ;;
295 "") row SKIP udisks "udisks2 policy not present" ;;
296 *) row FAIL udisks "filesystem-mount implicit active is '$impl', so mounting a stick prompts" ;;
297 esac
298 else
299 row SKIP udisks "no pkaction"
300 fi
301
302 # -------------------------------------------------------------------- mdns ---
303 # Publishing a .local name and resolving one are different capabilities, and
304 # only the second makes `ssh max.local` work from this machine.
305 if have resolvectl; then
306 if resolvectl query "$(hostname).local" >/dev/null 2>&1; then
307 row PASS mdns "this machine resolves its own .local name"
308 else
309 row FAIL mdns "cannot resolve $(hostname).local; mDNS resolution is off even if publishing works"
310 fi
311 else
312 row SKIP mdns "no resolvectl"
313 fi
314
315 # ------------------------------------------------------------------ portal ---
316 # The portal is what tells a GTK or Electron client which way the theme went.
317 if has_session && have gdbus; then
318 if gdbus call --session --dest org.freedesktop.portal.Desktop \
319 --object-path /org/freedesktop/portal/desktop \
320 --method org.freedesktop.portal.Settings.ReadOne \
321 org.freedesktop.appearance color-scheme >/dev/null 2>&1; then
322 row PASS portal-theme "the portal answers color-scheme"
323 else
324 row FAIL portal-theme "the portal does not answer color-scheme, so clients will not follow the theme"
325 fi
326 else
327 row SKIP portal-theme "needs a graphical session; not answerable over ssh"
328 fi
329
330 # ------------------------------------------------------------------ linger ---
331 # The silent-loss class. On a machine whose evidence tier runs as user timers,
332 # a missing linger produces a healthy-looking box where nothing ever runs.
333 if have loginctl; then
334 user="${SUDO_USER:-$(id -un)}"
335 if loginctl show-user "$user" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
336 row PASS linger "$user has Linger=yes, so user timers run without a session"
337 else
338 row SKIP linger "$user has no linger; only a finding on a machine with user timers"
339 fi
340 else
341 row SKIP linger "no loginctl"
342 fi
343
344 # ------------------------------------------------------------------ nvidia ---
345 if lspci 2>/dev/null | grep -qi 'nvidia'; then
346 if have nvidia-smi && nvidia-smi -L >/dev/null 2>&1; then
347 row PASS nvidia "$(nvidia-smi -L 2>/dev/null | head -1)"
348 else
349 row FAIL nvidia "an NVIDIA card is on the bus and the driver does not answer"
350 fi
351 else
352 row SKIP nvidia "no NVIDIA card on this machine"
353 fi
354
355 # ---------------------------------------------------------------- toolchain ---
356 # The silent class, like linger above: nothing fails, and the machine builds
357 # every release on a compiler the tree did not ask for.
358 #
359 # `rust-toolchain.toml` is honoured by rustup's proxy and by nothing else, so a
360 # build host whose $HOME has no rustup runs Fedora's rust and says so nowhere.
361 # Measured 2026-09-04 in a container from the fw13 image: rustc 1.98.0 against a
362 # tree pinning 1.97.1, `cargo test` green, not one word of warning. The cost is
363 # not a broken build but a quiet one -- rustfmt output moves between compiler
364 # versions, which is the whole reason the pins exist, and the sweep's fmt cell
365 # then measures which machine formatted last.
366 #
367 # Two of the three publish gates are louder than that, and only by accident:
368 # Fedora's rust package carries neither rustfmt nor clippy, so on the fw13 image
369 # `cargo fmt` and `cargo clippy` are `no such command` rather than the wrong
370 # version. Measured in the rehearsal-4 run. That makes the publish recipe fail at
371 # its first gate on a rustup-less host, which is the good outcome; `cargo test`
372 # and `cargo publish --dry-run` both ran green there on 1.98.0, which is the bad
373 # one. So the components are asked for separately below: rustup being installed
374 # and on the right channel does not mean the toolchain carries them, and a
375 # `--profile minimal` install without them fails a release rather than a build.
376 #
377 # Only a build host is asked. A laptop with no tree is not failing anything by
378 # not having a toolchain, so the absence of either the tree or rustc is a SKIP.
379 pin_file="$(target_home)/Code/alloy/rust-toolchain.toml"
380 if [ ! -r "$pin_file" ]; then
381 row SKIP toolchain "no ~/Code/alloy/rust-toolchain.toml; not a build host"
382 elif ! have rustc; then
383 row SKIP toolchain "no rustc for $(target_user); not a build host"
384 else
385 # The pin as written, and the active compiler's own version. Both are parsed
386 # narrowly on purpose: a shape this does not recognise degrades to SKIP, since
387 # a battery that invents a FAIL is one people learn to ignore.
388 pinned="$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$pin_file" | head -1)"
389 active="$(rustc --version 2>/dev/null | awk '{print $2}')"
390 if [ -z "$pinned" ] || [ -z "$active" ]; then
391 row SKIP toolchain "could not read the pin or the active version from this output shape"
392 elif [ "$pinned" = "$active" ]; then
393 # The channel is right. Now the two components the publish recipe runs
394 # before anything else, each asked for by the command that would fail.
395 missing=""
396 cargo fmt --version >/dev/null 2>&1 || missing="rustfmt"
397 cargo clippy --version >/dev/null 2>&1 || missing="${missing:+$missing }clippy"
398 if [ -n "$missing" ]; then
399 row FAIL toolchain "rustc $active matches the pin but $missing is absent -- run: rustup component add $missing"
400 else
401 row PASS toolchain "rustc $active matches the pinned channel, with rustfmt and clippy"
402 fi
403 elif have rustup; then
404 row FAIL toolchain "rustc $active, tree pins $pinned, and rustup is installed -- run: rustup toolchain install $pinned"
405 else
406 row FAIL toolchain "rustc $active, tree pins $pinned, and there is no rustup to honour it -- the pin is being ignored silently"
407 fi
408 fi
409
410 # ------------------------------------------------------------------ verdict ---
411 printf '\n%s passed, %s failed, %s skipped\n' "$PASSED" "$FAILED" "$SKIPPED"
412 [ "$SKIPPED" -gt 0 ] && printf 'skipped rows are unanswered questions, not passes\n'
413 [ "$FAILED" -eq 0 ] || exit 1
414 exit 0
415