Skip to main content

max / alloy

Give image a module layer, and drop its second hostname rule image.rs held five subjects, one of which answered a different question than the file's title: version() reads the machine already booted, everything else builds an image. The layers are files now: version, choices, record, discover, view. valid_hostname is deleted rather than moved. install::validate_hostname enforces the same RFC 1123 label rules and one more, counts chars rather than bytes, and returns the message to show. The delta is that a dotted hostname is now a blocker here too, which is what the installer already refuses and what keeps `hostname -s` and `hostname -f` agreeing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 17:13 UTC
Signed with PGP, not checked
Commit: d1fa5654f74b0e2d6c2568b6deeac936695cbd2a
Parent: 17bb281
10 files changed, +1753 insertions, -970 deletions
@@ -72,1386 +72,25 @@
72 72 //!
73 73 //! <!-- wiki: alloy-distribution -->
74 74
75 - use std::collections::BTreeSet;
76 - use std::fmt::Write as _;
77 - use std::path::{Path, PathBuf};
78 -
79 - use alloy_tui::keys::{Action, classify};
80 - use alloy_tui::{
81 - AlloyBlock, AlloyForm, AlloyList, Cursor, FieldKind, FormRow, Hint, Severity, TextField, Theme,
82 - hint, text,
83 - };
84 - use anyhow::{Context, Result};
85 - use ratatui::Frame;
86 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
87 - use ratatui::layout::{Constraint, Layout, Rect};
88 - use ratatui::text::Line;
89 -
90 - use crate::cli::{CommandLog, Invocation};
91 - use crate::run::{Sequence, Stage};
92 - use crate::shell::{Confirm, Flow, View, block_title};
93 -
94 75 /// Where a built image records the choices that made it.
95 76 ///
96 77 /// Read back at startup so a rebuild starts from what the machine already is
97 78 /// rather than from defaults. The design note accepts that a user with neither
98 79 /// a previous image nor an ISO to hand starts from defaults, which is what an
99 80 /// absent file means here.
100 - const RECORD: &str = "/usr/lib/alloy/build.toml";
81 + pub(super) const RECORD: &str = "/usr/lib/alloy/build.toml";
101 82
102 83 /// The same record, in a checkout, where the builder writes it.
103 - const RECORD_LOCAL: &str = "build/alloy-build.toml";
104 -
105 - /// Where the running system states which Alloy it is.
106 - ///
107 - /// `/usr/lib` and not `/etc`: the Containerfile ships it there and symlinks
108 - /// `/etc/os-release` at it, because bootc reads the deployment's copy when it
109 - /// writes a boot menu entry. Reading the source rather than the link.
110 - const OS_RELEASE: &str = "/usr/lib/os-release";
111 -
112 - /// The image's version: the product, the build it came from, and the Fedora
113 - /// base it was built on, composed into one line.
114 - ///
115 - /// Three fields because they move on three clocks. `VERSION_ID` is the product
116 - /// and moves on a release; `IMAGE_VERSION` is stamped by every build;
117 - /// `ALLOY_BASE` follows the Containerfile's `FROM`. Two machines on the same
118 - /// product version can be different images, and that is exactly the pair a
119 - /// support conversation has to tell apart.
120 - ///
121 - /// Separate from the console's `CARGO_PKG_VERSION`, and the two diverge on
122 - /// purpose: the hotfix channel exists to put a newer console on an older
123 - /// image, so a support conversation that cannot see both cannot tell a layered
124 - /// machine from a rebuilt one, so `alloy --version` reports both.
125 - ///
126 - /// `None` off an Alloy machine, which is why `ID` is checked rather than
127 - /// assumed: every Linux host has an os-release, and reading a dev box's Fedora
128 - /// or Pop!_OS `VERSION_ID` would report a confident wrong answer. There is no
129 - /// image there to have a version, so the line is omitted rather than filled in
130 - /// with "unknown".
131 - pub(crate) fn version() -> Option<String> {
132 - version_from(&std::fs::read_to_string(OS_RELEASE).ok()?)
133 - }
134 -
135 - /// The parse, split from the read so it can be tested against the os-release
136 - /// this repo actually ships rather than against the host's.
137 - ///
138 - /// Composed here and not in `notice_text`, which stays a formatter with no
139 - /// os-release knowledge.
140 - ///
141 - /// Each of the two trailing fields is dropped rather than filled in when it is
142 - /// missing. An unstamped image is a real state — a bare `podman build` past the
143 - /// wrapper scripts — and `0.1 (Fedora 43)` says less than the full line while
144 - /// saying nothing false, which is what `unknown` would do.
145 - fn version_from(text: &str) -> Option<String> {
146 - // `strip_prefix` on the key and then on `=`, in that order, so a key that
147 - // is a prefix of another does not match it: `ID` against `ID_LIKE=fedora`
148 - // leaves `_LIKE=fedora`, which has no leading `=` and is skipped.
149 - let field = |key: &str| {
150 - text.lines()
151 - .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
152 - .map(|value| value.trim_matches('"').to_string())
153 - };
154 - if field("ID").as_deref() != Some("alloy") {
155 - return None;
156 - }
157 - let product = field("VERSION_ID")?;
158 - let detail: Vec<String> = [
159 - field("IMAGE_VERSION").map(|build| format!("build {build}")),
160 - field("ALLOY_BASE").map(|base| format!("Fedora {base}")),
161 - ]
162 - .into_iter()
163 - .flatten()
164 - .collect();
165 - if detail.is_empty() {
166 - return Some(product);
167 - }
168 - Some(format!("{product} ({})", detail.join(", ")))
169 - }
170 -
171 - /// Which machine is being built.
172 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
173 - pub(crate) enum Profile {
174 - /// The desktop: compositor, greeter, session, browser.
175 - #[default]
176 - Client,
177 - /// Headless. The console, the shell stack, the hardware-health group and
178 - /// the themed bare console, and nothing that needs a screen.
179 - Server,
180 - }
181 -
182 - impl Profile {
183 - const ALL: [Profile; 2] = [Profile::Client, Profile::Server];
184 -
185 - const fn value(self) -> &'static str {
186 - match self {
187 - Profile::Client => "client",
188 - Profile::Server => "server",
189 - }
190 - }
191 -
192 - const fn label(self) -> &'static str {
193 - match self {
194 - Profile::Client => "client (desktop)",
195 - Profile::Server => "server (headless)",
196 - }
197 - }
198 -
199 - fn parse(value: &str) -> Option<Self> {
200 - Self::ALL.into_iter().find(|p| p.value() == value)
201 - }
202 - }
203 -
204 - /// The browser: a pick, and whether to carry it.
205 - ///
206 - /// Alloy picks Firefox and defends it (wiki `alloy-byo-principle`). It makes no
207 - /// claim that Gecko beats Blink; what it defends is the candidate. Firefox is the only browser reachable on an image-based
208 - /// system whose defaults Alloy can stand behind, and it stands behind them in
209 - /// one file rather than by adopting another project's patchset.
210 - ///
211 - /// So the screen still asks, but it is asking whether to carry Alloy's browser
212 - /// rather than which browser Alloy should have.
213 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
214 - pub(crate) enum Browser {
215 - /// Gecko, from Fedora's own repos, configured by
216 - /// `/etc/firefox/pref/alloy.js` to remove anti-features, restore one
217 - /// control Mozilla hides, and take one gate off a reversible act.
218 - #[default]
219 - Firefox,
220 - /// No browser in the image. Still a real answer: someone who wants to
221 - /// install their own from a flatpak remote should not pay for one they
222 - /// will remove.
223 - None,
224 - }
225 -
226 - impl Browser {
227 - const ALL: [Browser; 2] = [Browser::Firefox, Browser::None];
228 -
229 - const fn value(self) -> &'static str {
230 - match self {
231 - Browser::Firefox => "firefox",
232 - Browser::None => "none",
233 - }
234 - }
235 -
236 - const fn label(self) -> &'static str {
237 - match self {
238 - Browser::Firefox => "firefox (Gecko, from Fedora)",
239 - Browser::None => "none",
240 - }
241 - }
242 -
243 - fn parse(value: &str) -> Option<Self> {
244 - Self::ALL.into_iter().find(|b| b.value() == value)
245 - }
246 - }
247 -
248 - /// A language toolchain the image can carry.
249 - ///
250 - /// The curated set, not an arbitrary package list. Each is a toolchain someone
251 - /// would reasonably want to build with on the machine itself rather than in a
252 - /// box, which is the line between this and `alloy pkg box`.
253 - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
254 - pub(crate) enum Lang {
255 - Rust,
256 - C,
257 - Go,
258 - Python,
259 - Zig,
260 - Js,
261 - }
262 -
263 - impl Lang {
264 - const ALL: [Lang; 6] = [
265 - Lang::Rust,
266 - Lang::C,
267 - Lang::Go,
268 - Lang::Python,
269 - Lang::Zig,
270 - Lang::Js,
271 - ];
272 -
273 - const fn value(self) -> &'static str {
274 - match self {
275 - Lang::Rust => "rust",
276 - Lang::C => "c",
277 - Lang::Go => "go",
278 - Lang::Python => "python",
279 - Lang::Zig => "zig",
280 - Lang::Js => "js",
281 - }
282 - }
283 -
284 - /// What the label says about cost, because the cost is the point.
285 - ///
286 - /// Rust's figure is the Containerfile's own measurement, and it is the
287 - /// reason this row exists: 610 MiB is an order of magnitude above the
288 - /// hardware-health group and by a distance the largest thing in the image.
289 - ///
290 - /// Go's is its marginal cost by exclusive closure, what turning this row on
291 - /// adds rather than what `golang` declares. Every toolchain is off by
292 - /// default, so each number is one somebody is deciding to spend.
293 - const fn label(self) -> &'static str {
294 - match self {
295 - Lang::Rust => "rust (610 MiB; the build-host role asks for this)",
296 - Lang::C => "c (193 MiB; g++, make, cmake, meson, ninja, autotools)",
297 - Lang::Go => "go (356 MB; rebuilds syncthing, restic, tailscale)",
298 - Lang::Python => "python",
299 - Lang::Zig => "zig",
300 - Lang::Js => "js (93 MiB; node and npm, for building MNW's frontends)",
301 - }
302 - }
303 -
304 - fn parse(value: &str) -> Option<Self> {
305 - Self::ALL.into_iter().find(|l| l.value() == value)
306 - }
307 - }
308 -
309 - /// What to do about the base image's own dead weight.
310 - ///
311 - /// The only choice on this screen that is about `fedora-bootc` rather than
312 - /// about Alloy. The base is a general-purpose server image and carries an AWS
313 - /// SDK, eighteen architectures of `qemu-user-static`, and toolbox, none of
314 - /// which anything in Alloy reaches; the Containerfile's `ARG TRIM` argues the
315 - /// list and holds the measurement.
316 - ///
317 - /// Read the measurement before quoting a number at anyone. It takes 304 MiB
318 - /// out of `/usr` and 3.8 MiB off the image, because the base hardlinks its
319 - /// `/usr` into an ostree repo the removal cannot prune. The installed system
320 - /// is where it should pay, and that has not been weighed yet.
321 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
322 - pub(crate) enum Trim {
323 - /// Drop them. The default, because the capability lost with them is
324 - /// foreign-architecture emulation, which the house rules forbid using.
325 - #[default]
326 - Unused,
327 - /// Ship the base as it comes, for whoever disagrees on their own machine.
328 - Keep,
329 - }
330 -
331 - impl Trim {
332 - const ALL: [Trim; 2] = [Trim::Unused, Trim::Keep];
333 -
334 - const fn value(self) -> &'static str {
335 - match self {
336 - Trim::Unused => "unused",
337 - Trim::Keep => "keep",
338 - }
339 - }
340 -
341 - const fn label(self) -> &'static str {
342 - match self {
343 - Trim::Unused => "drop what nothing here reaches",
344 - Trim::Keep => "keep the base as it ships",
345 - }
346 - }
347 -
348 - fn parse(value: &str) -> Option<Self> {
349 - Self::ALL.into_iter().find(|t| t.value() == value)
350 - }
351 - }
352 -
353 - /// Whether the image carries a database, and which major.
354 - ///
355 - /// The major is the whole point of the dial. Fedora's default
356 - /// `postgresql-server` is 18, and Sando's scratch cluster exists to test MNW
357 - /// against what production runs, which is PostgreSQL 16.14 on `alpha-west-1`.
358 - /// So `postgres16` names `postgresql16` and `postgresql16-server` rather than
359 - /// the unversioned packages: three packages, 38 MiB installed.
360 - ///
361 - /// Binaries only. Nothing in the image enables a unit or runs initdb; a
362 - /// cluster is machine state and belongs to whoever runs the machine.
363 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
364 - pub(crate) enum Db {
365 - /// No database. The default, and what every image before this dial was.
366 - #[default]
367 - None,
368 - /// PostgreSQL 16, the major production runs.
369 - Postgres16,
370 - }
371 -
372 - impl Db {
373 - const ALL: [Db; 2] = [Db::None, Db::Postgres16];
374 -
375 - const fn value(self) -> &'static str {
376 - match self {
377 - Db::None => "none",
378 - Db::Postgres16 => "postgres16",
379 - }
380 - }
381 -
382 - const fn label(self) -> &'static str {
383 - match self {
384 - Db::None => "none",
385 - Db::Postgres16 => "postgresql 16 (38 MiB; the major prod runs)",
386 - }
387 - }
388 -
389 - fn parse(value: &str) -> Option<Self> {
390 - Self::ALL.into_iter().find(|d| d.value() == value)
391 - }
392 - }
393 -
394 - /// What the build produces.
395 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
396 - pub(crate) enum Artifact {
397 - /// The installer ISO that boots into `alloy install`. The decided path
398 - /// (wiki `alloy-distribution`): ISO plus dd.
399 - #[default]
400 - Iso,
401 - /// A raw disk image, for dd'ing a whole installed system.
402 - Raw,
403 - /// qcow2, for QEMU.
404 - Qcow2,
405 - }
406 -
407 - impl Artifact {
408 - const ALL: [Artifact; 3] = [Artifact::Iso, Artifact::Raw, Artifact::Qcow2];
409 -
410 - const fn value(self) -> &'static str {
411 - match self {
412 - Artifact::Iso => "iso",
413 - Artifact::Raw => "raw",
414 - Artifact::Qcow2 => "qcow2",
415 - }
416 - }
417 -
418 - const fn label(self) -> &'static str {
419 - match self {
420 - Artifact::Iso => "installer ISO (boots into alloy install)",
421 - Artifact::Raw => "raw disk image",
422 - Artifact::Qcow2 => "qcow2 (QEMU)",
423 - }
424 - }
425 -
426 - /// Which script owns it. The ISO is not bib's: every ISO type
427 - /// bootc-image-builder offers ends in Anaconda, and an install from one
428 - /// leaves root locked with no account, so `build-iso.sh` builds it outside
429 - /// bib. `build-image.sh` refuses the ISO types at the argument for the
430 - /// same reason.
431 - const fn script(self) -> &'static str {
432 - match self {
433 - Artifact::Iso => "build/build-iso.sh",
434 - Artifact::Raw | Artifact::Qcow2 => "build/build-image.sh",
435 - }
436 - }
437 -
438 - fn parse(value: &str) -> Option<Self> {
439 - Self::ALL.into_iter().find(|a| a.value() == value)
440 - }
441 - }
442 -
443 - /// Everything the builder decides.
444 - ///
445 - /// Derived `Default`, so every row's default is declared on its own enum and
446 - /// there is one place per choice rather than two that can disagree. The two
447 - /// that are not enums default to nothing, which is what "not set" means for
448 - /// both.
449 - #[derive(Debug, Clone, Default)]
450 - pub(crate) struct Choices {
451 - pub(crate) profile: Profile,
452 - pub(crate) browser: Browser,
453 - /// Toolchains, empty by default.
454 - ///
455 - /// The default is what the image itself requires, which is nothing:
456 - /// measured, the shipped image compiles nothing at runtime, since the
457 - /// `cargo install` that builds shop runs in a builder stage the final image
458 - /// is not built from. A toolchain that is merely useful is picked here, at
459 - /// mint time, and a build host is a thing a mint asks for: fw13 and astra
460 - /// both take `LANGS=rust`. `ARG LANGS` in the Containerfile carries the
461 - /// reasoning.
462 - pub(crate) langs: BTreeSet<Lang>,
463 - pub(crate) hostname: String,
464 - /// Path to a public key on the building machine, not the key itself.
465 - ///
466 - /// The path is what the user picks and the bytes are read at build time,
467 - /// which keeps the record portable: a saved `build.toml` that embedded a
468 - /// key would be a different file on every machine for no reason. It is a
469 - /// *public* key, so nothing here is a secret either way — see the module
470 - /// docs on why that property is load-bearing.
471 - pub(crate) pubkey: String,
472 - pub(crate) artifact: Artifact,
473 - pub(crate) trim: Trim,
474 - pub(crate) db: Db,
475 - }
476 -
477 - impl Choices {
478 - /// The `--build-arg` pairs, in a fixed order so the displayed command is
479 - /// stable between frames and between runs.
480 - ///
481 - /// `LANGS` is a comma-joined list rather than one ARG per language: the
482 - /// Containerfile validates the whole list against its own curated set, so
483 - /// the gate lives there rather than in the number of arguments. That is
484 - /// the same layering `PROFILE` uses, and it means the build refuses an
485 - /// unknown language even if this screen is bypassed entirely.
486 - pub(crate) fn build_args(&self) -> Vec<(String, String)> {
487 - let mut args = vec![
488 - ("PROFILE".to_string(), self.profile.value().to_string()),
489 - ("BROWSER".to_string(), self.browser.value().to_string()),
490 - (
491 - "LANGS".to_string(),
492 - self.langs
493 - .iter()
494 - .map(|lang| lang.value())
495 - .collect::<Vec<_>>()
496 - .join(","),
497 - ),
498 - ("TRIM".to_string(), self.trim.value().to_string()),
499 - ("DB".to_string(), self.db.value().to_string()),
500 - ];
501 - if !self.hostname.is_empty() {
502 - args.push(("ALLOY_HOSTNAME".to_string(), self.hostname.clone()));
503 - }
504 - // The KEY, not the path to it. `pubkey` is a path on the building
505 - // machine and the build container cannot see it: the file is not in
506 - // the build context, and putting it there would mean writing a key
507 - // into the repo. So the bytes are read here and travel as the value,
508 - // which is also why the record on disk keeps the path instead — a
509 - // saved `build.toml` holding an embedded key would be a different
510 - // file on every machine for no reason.
511 - //
512 - // Unreadable is silently omitted rather than guessed at, because
513 - // `blockers` is the gate and has already refused to start a build
514 - // whose key cannot be read. Passing the path through as if it were a
515 - // key would make the Containerfile's own validation reject it, which
516 - // is a confusing way to learn the file is missing.
517 - if !self.pubkey.is_empty()
518 - && let Ok(contents) = std::fs::read_to_string(&self.pubkey)
519 - {
520 - let key = contents.trim();
521 - if !key.is_empty() {
522 - args.push(("ALLOY_SSH_KEY".to_string(), key.to_string()));
523 - }
524 - }
525 - args
526 - }
527 -
528 - /// The command that builds this.
529 - ///
530 - /// One `--build-arg` per pair rather than a packed string, so the log pane
531 - /// shows exactly what podman will receive and the line is copy-pasteable.
532 - pub(crate) fn invocation(&self) -> Invocation {
533 - let mut invocation = Invocation::new(self.artifact.script());
534 -
535 - // The disk-image script needs a type; the ISO script builds one thing
536 - // and takes no type at all, and passing one would be an error rather
537 - // than a no-op.
538 - if self.artifact != Artifact::Iso {
539 - invocation = invocation.args(["--type", self.artifact.value()]);
540 - }
541 -
542 - for (key, value) in self.build_args() {
543 - invocation = invocation.arg("--build-arg").arg(format!("{key}={value}"));
544 - }
545 - invocation
546 - }
547 -
548 - /// Reasons this cannot be built, in the order a user would hit them.
549 - ///
550 - /// Returned rather than rendered so the summary and the build gate read
551 - /// the same list. An empty vector is the only thing that starts a build.
552 - pub(crate) fn blockers(&self, repo: Option<&Path>) -> Vec<String> {
553 - let mut blockers = Vec::new();
554 -
555 - match repo {
556 - None => blockers.push(
557 - "no Alloy checkout here: run this from a clone, or pass one with --repo. \
558 - The builder drives build/build-iso.sh from the source tree."
559 - .to_string(),
560 - ),
561 - Some(root) => {
562 - let script = root.join(self.artifact.script());
563 - if !script.is_file() {
564 - blockers.push(format!("{} is not in this checkout", script.display()));
565 - }
566 - }
567 - }
568 -
569 - if !self.hostname.is_empty() && !valid_hostname(&self.hostname) {
570 - blockers.push(format!(
Lines truncated
@@ -1,0 +1,441 @@
1 + //! The builder's choices: one enum per row, and what they become as argv.
2 +
3 + use std::collections::BTreeSet;
4 + use std::path::{Path, PathBuf};
5 +
6 + use crate::cli::Invocation;
7 +
8 + use super::discover::looks_like_pubkey;
9 +
10 + /// Which machine is being built.
11 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12 + pub(crate) enum Profile {
13 + /// The desktop: compositor, greeter, session, browser.
14 + #[default]
15 + Client,
16 + /// Headless. The console, the shell stack, the hardware-health group and
17 + /// the themed bare console, and nothing that needs a screen.
18 + Server,
19 + }
20 +
21 + impl Profile {
22 + pub(super) const ALL: [Profile; 2] = [Profile::Client, Profile::Server];
23 +
24 + pub(super) const fn value(self) -> &'static str {
25 + match self {
26 + Profile::Client => "client",
27 + Profile::Server => "server",
28 + }
29 + }
30 +
31 + pub(super) const fn label(self) -> &'static str {
32 + match self {
33 + Profile::Client => "client (desktop)",
34 + Profile::Server => "server (headless)",
35 + }
36 + }
37 +
38 + pub(super) fn parse(value: &str) -> Option<Self> {
39 + Self::ALL.into_iter().find(|p| p.value() == value)
40 + }
41 + }
42 +
43 + /// The browser: a pick, and whether to carry it.
44 + ///
45 + /// Alloy picks Firefox and defends it (wiki `alloy-byo-principle`). It makes no
46 + /// claim that Gecko beats Blink; what it defends is the candidate. Firefox is the only browser reachable on an image-based
47 + /// system whose defaults Alloy can stand behind, and it stands behind them in
48 + /// one file rather than by adopting another project's patchset.
49 + ///
50 + /// So the screen still asks, but it is asking whether to carry Alloy's browser
51 + /// rather than which browser Alloy should have.
52 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53 + pub(crate) enum Browser {
54 + /// Gecko, from Fedora's own repos, configured by
55 + /// `/etc/firefox/pref/alloy.js` to remove anti-features, restore one
56 + /// control Mozilla hides, and take one gate off a reversible act.
57 + #[default]
58 + Firefox,
59 + /// No browser in the image. Still a real answer: someone who wants to
60 + /// install their own from a flatpak remote should not pay for one they
61 + /// will remove.
62 + None,
63 + }
64 +
65 + impl Browser {
66 + pub(super) const ALL: [Browser; 2] = [Browser::Firefox, Browser::None];
67 +
68 + pub(super) const fn value(self) -> &'static str {
69 + match self {
70 + Browser::Firefox => "firefox",
71 + Browser::None => "none",
72 + }
73 + }
74 +
75 + pub(super) const fn label(self) -> &'static str {
76 + match self {
77 + Browser::Firefox => "firefox (Gecko, from Fedora)",
78 + Browser::None => "none",
79 + }
80 + }
81 +
82 + pub(super) fn parse(value: &str) -> Option<Self> {
83 + Self::ALL.into_iter().find(|b| b.value() == value)
84 + }
85 + }
86 +
87 + /// A language toolchain the image can carry.
88 + ///
89 + /// The curated set, not an arbitrary package list. Each is a toolchain someone
90 + /// would reasonably want to build with on the machine itself rather than in a
91 + /// box, which is the line between this and `alloy pkg box`.
92 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
93 + pub(crate) enum Lang {
94 + Rust,
95 + C,
96 + Go,
97 + Python,
98 + Zig,
99 + Js,
100 + }
101 +
102 + impl Lang {
103 + pub(super) const ALL: [Lang; 6] = [
104 + Lang::Rust,
105 + Lang::C,
106 + Lang::Go,
107 + Lang::Python,
108 + Lang::Zig,
109 + Lang::Js,
110 + ];
111 +
112 + pub(super) const fn value(self) -> &'static str {
113 + match self {
114 + Lang::Rust => "rust",
115 + Lang::C => "c",
116 + Lang::Go => "go",
117 + Lang::Python => "python",
118 + Lang::Zig => "zig",
119 + Lang::Js => "js",
120 + }
121 + }
122 +
123 + /// What the label says about cost, because the cost is the point.
124 + ///
125 + /// Rust's figure is the Containerfile's own measurement, and it is the
126 + /// reason this row exists: 610 MiB is an order of magnitude above the
127 + /// hardware-health group and by a distance the largest thing in the image.
128 + ///
129 + /// Go's is its marginal cost by exclusive closure, what turning this row on
130 + /// adds rather than what `golang` declares. Every toolchain is off by
131 + /// default, so each number is one somebody is deciding to spend.
132 + pub(super) const fn label(self) -> &'static str {
133 + match self {
134 + Lang::Rust => "rust (610 MiB; the build-host role asks for this)",
135 + Lang::C => "c (193 MiB; g++, make, cmake, meson, ninja, autotools)",
136 + Lang::Go => "go (356 MB; rebuilds syncthing, restic, tailscale)",
137 + Lang::Python => "python",
138 + Lang::Zig => "zig",
139 + Lang::Js => "js (93 MiB; node and npm, for building MNW's frontends)",
140 + }
141 + }
142 +
143 + pub(super) fn parse(value: &str) -> Option<Self> {
144 + Self::ALL.into_iter().find(|l| l.value() == value)
145 + }
146 + }
147 +
148 + /// What to do about the base image's own dead weight.
149 + ///
150 + /// The only choice on this screen that is about `fedora-bootc` rather than
151 + /// about Alloy. The base is a general-purpose server image and carries an AWS
152 + /// SDK, eighteen architectures of `qemu-user-static`, and toolbox, none of
153 + /// which anything in Alloy reaches; the Containerfile's `ARG TRIM` argues the
154 + /// list and holds the measurement.
155 + ///
156 + /// Read the measurement before quoting a number at anyone. It takes 304 MiB
157 + /// out of `/usr` and 3.8 MiB off the image, because the base hardlinks its
158 + /// `/usr` into an ostree repo the removal cannot prune. The installed system
159 + /// is where it should pay, and that has not been weighed yet.
160 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
161 + pub(crate) enum Trim {
162 + /// Drop them. The default, because the capability lost with them is
163 + /// foreign-architecture emulation, which the house rules forbid using.
164 + #[default]
165 + Unused,
166 + /// Ship the base as it comes, for whoever disagrees on their own machine.
167 + Keep,
168 + }
169 +
170 + impl Trim {
171 + pub(super) const ALL: [Trim; 2] = [Trim::Unused, Trim::Keep];
172 +
173 + pub(super) const fn value(self) -> &'static str {
174 + match self {
175 + Trim::Unused => "unused",
176 + Trim::Keep => "keep",
177 + }
178 + }
179 +
180 + pub(super) const fn label(self) -> &'static str {
181 + match self {
182 + Trim::Unused => "drop what nothing here reaches",
183 + Trim::Keep => "keep the base as it ships",
184 + }
185 + }
186 +
187 + pub(super) fn parse(value: &str) -> Option<Self> {
188 + Self::ALL.into_iter().find(|t| t.value() == value)
189 + }
190 + }
191 +
192 + /// Whether the image carries a database, and which major.
193 + ///
194 + /// The major is the whole point of the dial. Fedora's default
195 + /// `postgresql-server` is 18, and Sando's scratch cluster exists to test MNW
196 + /// against what production runs, which is PostgreSQL 16.14 on `alpha-west-1`.
197 + /// So `postgres16` names `postgresql16` and `postgresql16-server` rather than
198 + /// the unversioned packages: three packages, 38 MiB installed.
199 + ///
200 + /// Binaries only. Nothing in the image enables a unit or runs initdb; a
201 + /// cluster is machine state and belongs to whoever runs the machine.
202 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
203 + pub(crate) enum Db {
204 + /// No database. The default, and what every image before this dial was.
205 + #[default]
206 + None,
207 + /// PostgreSQL 16, the major production runs.
208 + Postgres16,
209 + }
210 +
211 + impl Db {
212 + pub(super) const ALL: [Db; 2] = [Db::None, Db::Postgres16];
213 +
214 + pub(super) const fn value(self) -> &'static str {
215 + match self {
216 + Db::None => "none",
217 + Db::Postgres16 => "postgres16",
218 + }
219 + }
220 +
221 + pub(super) const fn label(self) -> &'static str {
222 + match self {
223 + Db::None => "none",
224 + Db::Postgres16 => "postgresql 16 (38 MiB; the major prod runs)",
225 + }
226 + }
227 +
228 + pub(super) fn parse(value: &str) -> Option<Self> {
229 + Self::ALL.into_iter().find(|d| d.value() == value)
230 + }
231 + }
232 +
233 + /// What the build produces.
234 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235 + pub(crate) enum Artifact {
236 + /// The installer ISO that boots into `alloy install`. The decided path
237 + /// (wiki `alloy-distribution`): ISO plus dd.
238 + #[default]
239 + Iso,
240 + /// A raw disk image, for dd'ing a whole installed system.
241 + Raw,
242 + /// qcow2, for QEMU.
243 + Qcow2,
244 + }
245 +
246 + impl Artifact {
247 + pub(super) const ALL: [Artifact; 3] = [Artifact::Iso, Artifact::Raw, Artifact::Qcow2];
248 +
249 + pub(super) const fn value(self) -> &'static str {
250 + match self {
251 + Artifact::Iso => "iso",
252 + Artifact::Raw => "raw",
253 + Artifact::Qcow2 => "qcow2",
254 + }
255 + }
256 +
257 + pub(super) const fn label(self) -> &'static str {
258 + match self {
259 + Artifact::Iso => "installer ISO (boots into alloy install)",
260 + Artifact::Raw => "raw disk image",
261 + Artifact::Qcow2 => "qcow2 (QEMU)",
262 + }
263 + }
264 +
265 + /// Which script owns it. The ISO is not bib's: every ISO type
266 + /// bootc-image-builder offers ends in Anaconda, and an install from one
267 + /// leaves root locked with no account, so `build-iso.sh` builds it outside
268 + /// bib. `build-image.sh` refuses the ISO types at the argument for the
269 + /// same reason.
270 + pub(super) const fn script(self) -> &'static str {
271 + match self {
272 + Artifact::Iso => "build/build-iso.sh",
273 + Artifact::Raw | Artifact::Qcow2 => "build/build-image.sh",
274 + }
275 + }
276 +
277 + pub(super) fn parse(value: &str) -> Option<Self> {
278 + Self::ALL.into_iter().find(|a| a.value() == value)
279 + }
280 + }
281 +
282 + /// Everything the builder decides.
283 + ///
284 + /// Derived `Default`, so every row's default is declared on its own enum and
285 + /// there is one place per choice rather than two that can disagree. The two
286 + /// that are not enums default to nothing, which is what "not set" means for
287 + /// both.
288 + #[derive(Debug, Clone, Default)]
289 + pub(crate) struct Choices {
290 + pub(crate) profile: Profile,
291 + pub(crate) browser: Browser,
292 + /// Toolchains, empty by default.
293 + ///
294 + /// The default is what the image itself requires, which is nothing:
295 + /// measured, the shipped image compiles nothing at runtime, since the
296 + /// `cargo install` that builds shop runs in a builder stage the final image
297 + /// is not built from. A toolchain that is merely useful is picked here, at
298 + /// mint time, and a build host is a thing a mint asks for: fw13 and astra
299 + /// both take `LANGS=rust`. `ARG LANGS` in the Containerfile carries the
300 + /// reasoning.
301 + pub(crate) langs: BTreeSet<Lang>,
302 + pub(crate) hostname: String,
303 + /// Path to a public key on the building machine, not the key itself.
304 + ///
305 + /// The path is what the user picks and the bytes are read at build time,
306 + /// which keeps the record portable: a saved `build.toml` that embedded a
307 + /// key would be a different file on every machine for no reason. It is a
308 + /// *public* key, so nothing here is a secret either way — see the module
309 + /// docs on why that property is load-bearing.
310 + pub(crate) pubkey: String,
311 + pub(crate) artifact: Artifact,
312 + pub(crate) trim: Trim,
313 + pub(crate) db: Db,
314 + }
315 +
316 + impl Choices {
317 + /// The `--build-arg` pairs, in a fixed order so the displayed command is
318 + /// stable between frames and between runs.
319 + ///
320 + /// `LANGS` is a comma-joined list rather than one ARG per language: the
321 + /// Containerfile validates the whole list against its own curated set, so
322 + /// the gate lives there rather than in the number of arguments. That is
323 + /// the same layering `PROFILE` uses, and it means the build refuses an
324 + /// unknown language even if this screen is bypassed entirely.
325 + pub(crate) fn build_args(&self) -> Vec<(String, String)> {
326 + let mut args = vec![
327 + ("PROFILE".to_string(), self.profile.value().to_string()),
328 + ("BROWSER".to_string(), self.browser.value().to_string()),
329 + (
330 + "LANGS".to_string(),
331 + self.langs
332 + .iter()
333 + .map(|lang| lang.value())
334 + .collect::<Vec<_>>()
335 + .join(","),
336 + ),
337 + ("TRIM".to_string(), self.trim.value().to_string()),
338 + ("DB".to_string(), self.db.value().to_string()),
339 + ];
340 + if !self.hostname.is_empty() {
341 + args.push(("ALLOY_HOSTNAME".to_string(), self.hostname.clone()));
342 + }
343 + // The KEY, not the path to it. `pubkey` is a path on the building
344 + // machine and the build container cannot see it: the file is not in
345 + // the build context, and putting it there would mean writing a key
346 + // into the repo. So the bytes are read here and travel as the value,
347 + // which is also why the record on disk keeps the path instead — a
348 + // saved `build.toml` holding an embedded key would be a different
349 + // file on every machine for no reason.
350 + //
351 + // Unreadable is silently omitted rather than guessed at, because
352 + // `blockers` is the gate and has already refused to start a build
353 + // whose key cannot be read. Passing the path through as if it were a
354 + // key would make the Containerfile's own validation reject it, which
355 + // is a confusing way to learn the file is missing.
356 + if !self.pubkey.is_empty()
357 + && let Ok(contents) = std::fs::read_to_string(&self.pubkey)
358 + {
359 + let key = contents.trim();
360 + if !key.is_empty() {
361 + args.push(("ALLOY_SSH_KEY".to_string(), key.to_string()));
362 + }
363 + }
364 + args
365 + }
366 +
367 + /// The command that builds this.
368 + ///
369 + /// One `--build-arg` per pair rather than a packed string, so the log pane
370 + /// shows exactly what podman will receive and the line is copy-pasteable.
371 + pub(crate) fn invocation(&self) -> Invocation {
372 + let mut invocation = Invocation::new(self.artifact.script());
373 +
374 + // The disk-image script needs a type; the ISO script builds one thing
375 + // and takes no type at all, and passing one would be an error rather
376 + // than a no-op.
377 + if self.artifact != Artifact::Iso {
378 + invocation = invocation.args(["--type", self.artifact.value()]);
379 + }
380 +
381 + for (key, value) in self.build_args() {
382 + invocation = invocation.arg("--build-arg").arg(format!("{key}={value}"));
383 + }
384 + invocation
385 + }
386 +
387 + /// Reasons this cannot be built, in the order a user would hit them.
388 + ///
389 + /// Returned rather than rendered so the summary and the build gate read
390 + /// the same list. An empty vector is the only thing that starts a build.
391 + pub(crate) fn blockers(&self, repo: Option<&Path>) -> Vec<String> {
392 + let mut blockers = Vec::new();
393 +
394 + match repo {
395 + None => blockers.push(
396 + "no Alloy checkout here: run this from a clone, or pass one with --repo. \
397 + The builder drives build/build-iso.sh from the source tree."
398 + .to_string(),
399 + ),
400 + Some(root) => {
401 + let script = root.join(self.artifact.script());
402 + if !script.is_file() {
403 + blockers.push(format!("{} is not in this checkout", script.display()));
404 + }
405 + }
406 + }
407 +
408 + if !self.hostname.is_empty()
409 + && let Err(why) = crate::install::validate_hostname(&self.hostname)
410 + {
411 + blockers.push(format!("`{}` is not a hostname: {why}", self.hostname));
412 + }
413 +
414 + if !self.pubkey.is_empty() {
415 + let path = PathBuf::from(&self.pubkey);
416 + if !path.is_file() {
417 + blockers.push(format!("no public key at {}", path.display()));
418 + } else if std::fs::read_to_string(&path)
419 + .is_ok_and(|contents| !looks_like_pubkey(&contents))
420 + {
421 + blockers.push(format!(
422 + "{} does not look like an SSH public key. If this is a PRIVATE key, do not \
423 + bake it in: the artifact is meant to be copyable without care.",
424 + path.display()
425 + ));
426 + }
427 + }
428 +
429 + if self.profile == Profile::Server && self.browser != Browser::None {
430 + blockers.push(
431 + "the server profile ships no graphical session, so it cannot carry a browser"
432 + .to_string(),
433 + );
434 + }
435 +
436 + blockers
437 + }
438 + }
439 +
440 + #[cfg(test)]
441 + mod tests;
@@ -1,0 +1,203 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + #[test]
6 + fn the_default_is_the_desktop_with_what_the_image_requires() {
7 + let choices = Choices::default();
8 + assert_eq!(choices.profile, Profile::Client);
9 + assert_eq!(choices.browser, Browser::Firefox);
10 + // No toolchain. The image requires none to function, and a build
11 + // host asks for one at mint. See the comment on `Choices::default`.
12 + assert!(choices.langs.is_empty());
13 + assert_eq!(choices.artifact, Artifact::Iso);
14 + // Trimmed by default. What it costs is foreign-architecture emulation,
15 + // which the house rules forbid using in the first place.
16 + assert_eq!(choices.trim, Trim::Unused);
17 + // No database. It is opt-in for the build-host role and every image
18 + // before the dial existed carried none.
19 + assert_eq!(choices.db, Db::None);
20 + }
21 +
22 + /// The build args are the whole contract with the Containerfile, so their
23 + /// names and their order are asserted rather than left to whatever the
24 + /// struct happens to iterate.
25 + #[test]
26 + fn the_build_args_name_what_the_containerfile_reads() {
27 + let args = Choices::default().build_args();
28 + let names: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).collect();
29 + assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM", "DB"]);
30 + assert_eq!(args[0].1, "client");
31 + // Comma-joined in the enum's declared order, and this is also the
32 + // literal the Containerfile's own `ARG LANGS` default has to match:
33 + // the two defaults are one decision written in two files, and a build
34 + // that bypasses the TUI must get the same stack the TUI would have
35 + // asked for.
36 + assert_eq!(args[2].1, "");
37 + assert_eq!(args[3].1, "unused");
38 + assert_eq!(args[4].1, "none");
39 + }
40 +
41 + #[test]
42 + fn identity_args_appear_only_once_they_are_set() {
43 + let mut choices = Choices::default();
44 + assert!(
45 + !choices
46 + .build_args()
47 + .iter()
48 + .any(|(k, _)| k == "ALLOY_HOSTNAME")
49 + );
50 + choices.hostname = "bench".to_string();
51 + assert!(
52 + choices
53 + .build_args()
54 + .iter()
55 + .any(|(k, _)| k == "ALLOY_HOSTNAME")
56 + );
57 + }
58 +
59 + #[test]
60 + fn several_languages_join_into_one_arg() {
61 + let choices = Choices {
62 + langs: BTreeSet::from([Lang::Rust, Lang::Go, Lang::Zig]),
63 + ..Choices::default()
64 + };
65 + let args = choices.build_args();
66 + let langs = &args.iter().find(|(k, _)| k == "LANGS").unwrap().1;
67 + // BTreeSet order, which is the enum's declared order, so the arg is
68 + // stable between runs rather than however a HashSet felt that day.
69 + assert_eq!(langs, "rust,go,zig");
70 + }
71 +
72 + /// The ISO does not come from bootc-image-builder, and passing it a type
73 + /// would be an error rather than a no-op.
74 + #[test]
75 + fn the_iso_and_the_disk_images_use_different_scripts() {
76 + let mut choices = Choices::default();
77 + assert_eq!(choices.artifact.script(), "build/build-iso.sh");
78 + assert!(!choices.invocation().display().contains("--type"));
79 +
80 + choices.artifact = Artifact::Raw;
81 + assert_eq!(choices.artifact.script(), "build/build-image.sh");
82 + assert!(choices.invocation().display().contains("--type raw"));
83 + }
84 +
85 + #[test]
86 + fn the_command_is_copy_pasteable() {
87 + let choices = Choices {
88 + hostname: "bench".to_string(),
89 + ..Choices::default()
90 + };
91 + let shown = choices.invocation().display();
92 + assert!(shown.starts_with("build/build-iso.sh"));
93 + assert!(shown.contains("--build-arg PROFILE=client"));
94 + assert!(shown.contains("--build-arg ALLOY_HOSTNAME=bench"));
95 + }
96 +
97 + /// The hostname rule is the installer's, so a name refused on one screen is
98 + /// refused on the other. Dots included: `/etc/hostname` holding a dotted name
99 + /// makes `hostname -s` and `hostname -f` disagree.
100 + #[test]
101 + fn hostnames_are_checked_the_way_the_installer_checks_them() {
102 + let blocked = |name: &str| {
103 + Choices {
104 + hostname: name.to_string(),
105 + ..Choices::default()
106 + }
107 + .blockers(Some(Path::new("/tmp")))
108 + .iter()
109 + .any(|blocker| blocker.contains("is not a hostname"))
110 + };
111 + assert!(!blocked("bench"));
112 + assert!(!blocked("build-host-2"));
113 + assert!(!blocked(&"a".repeat(63)));
114 + assert!(blocked("-leading"));
115 + assert!(blocked("trailing-"));
116 + assert!(blocked("under_score"));
117 + assert!(blocked("has space"));
118 + assert!(blocked(&"a".repeat(64)));
119 + assert!(blocked("bench.local"), "a dotted name is not a short name");
120 + }
121 +
122 + /// A server has no session, so it cannot carry a browser. The blocker
123 + /// exists for a record loaded off disk; the key handler prevents reaching
124 + /// the state interactively.
125 + #[test]
126 + fn a_server_carrying_a_browser_is_blocked() {
127 + let choices = Choices {
128 + profile: Profile::Server,
129 + browser: Browser::Firefox,
130 + ..Choices::default()
131 + };
132 + let blockers = choices.blockers(Some(Path::new("/nonexistent")));
133 + assert!(
134 + blockers.iter().any(|b| b.contains("no graphical session")),
135 + "{blockers:?}"
136 + );
137 + }
138 +
139 + #[test]
140 + fn no_checkout_is_the_first_thing_reported() {
141 + let blockers = Choices::default().blockers(None);
142 + assert!(blockers[0].contains("no Alloy checkout"), "{blockers:?}");
143 + }
144 +
145 + /// The build container cannot see a path on the building machine — the
146 + /// file is not in the build context — so the key travels as its bytes.
147 + /// Passing the path would make the Containerfile's own validation reject
148 + /// it, which is a confusing way to learn a file is missing.
149 + #[test]
150 + fn the_key_travels_as_bytes_and_the_record_keeps_the_path() {
151 + let dir = std::env::temp_dir().join(format!("alloy-image-key-{}", std::process::id()));
152 + std::fs::create_dir_all(&dir).expect("scratch dir");
153 + let path = dir.join("id_ed25519.pub");
154 + std::fs::write(
155 + &path,
156 + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n",
157 + )
158 + .expect("write key");
159 +
160 + let choices = Choices {
161 + pubkey: path.display().to_string(),
162 + ..Choices::default()
163 + };
164 +
165 + let key = choices
166 + .build_args()
167 + .into_iter()
168 + .find(|(name, _)| name == "ALLOY_SSH_KEY")
169 + .expect("the key is passed")
170 + .1;
171 + assert!(key.starts_with("ssh-ed25519 AAAA"), "{key}");
172 + // Trimmed: a trailing newline inside a --build-arg value would land in
173 + // the authorized_keys file and in the validation `case`.
174 + assert!(!key.ends_with('\n'));
175 +
176 + // The record keeps the path, so a saved build.toml is not a different
177 + // file on every machine.
178 + assert!(choices.to_toml().contains("id_ed25519.pub"));
179 +
180 + let _ = std::fs::remove_dir_all(&dir);
181 + }
182 +
183 + /// An unreadable key is omitted rather than guessed at, because `blockers`
184 + /// is the gate and has already refused to start the build.
185 + #[test]
186 + fn an_unreadable_key_is_omitted_and_blocked() {
187 + let choices = Choices {
188 + pubkey: "/nonexistent/id_ed25519.pub".to_string(),
189 + ..Choices::default()
190 + };
191 + assert!(
192 + !choices
193 + .build_args()
194 + .iter()
195 + .any(|(name, _)| name == "ALLOY_SSH_KEY")
196 + );
197 + assert!(
198 + choices
199 + .blockers(None)
200 + .iter()
201 + .any(|blocker| blocker.contains("no public key at")),
202 + );
203 + }
@@ -1,0 +1,147 @@
1 + //! What the building machine can be asked about itself: the public keys it
2 + //! carries, and whether it is standing in an Alloy checkout.
3 +
4 + use std::path::PathBuf;
5 +
6 + /// Whether a file looks like an SSH *public* key.
7 + ///
8 + /// Loose on purpose about which algorithm, strict about the shape: two or
9 + /// three whitespace-separated fields whose first names a key type. The
10 + /// question worth answering is not "is this ed25519" but "is this the private
11 + /// half by mistake", because that is the error that turns a shareable artifact
12 + /// into a leaked credential.
13 + pub(super) fn looks_like_pubkey(contents: &str) -> bool {
14 + let Some(line) = contents.lines().find(|line| !line.trim().is_empty()) else {
15 + return false;
16 + };
17 + if line.contains("PRIVATE KEY") {
18 + return false;
19 + }
20 + let mut fields = line.split_whitespace();
21 + let Some(kind) = fields.next() else {
22 + return false;
23 + };
24 + let known = kind.starts_with("ssh-") || kind.starts_with("ecdsa-") || kind.starts_with("sk-");
25 + known && fields.next().is_some_and(|body| body.len() > 16)
26 + }
27 +
28 + /// The public keys sitting in `~/.ssh` on the minting host.
29 + ///
30 + /// Sorted, and filtered by [`looks_like_pubkey`] rather than by the `.pub`
31 + /// suffix alone: `~/.ssh` collects other people's keys, `known_hosts` fragments
32 + /// and the occasional misnamed private half, and offering one of those as a
33 + /// candidate is how a private key gets baked into an artifact whose whole
34 + /// premise is that it holds no secrets.
35 + ///
36 + /// **This never reaches the network.** `gh:username` was considered and takes
37 + /// the explicit opt-in the installer's timezone geolocation already has; until
38 + /// someone builds that, the only keys offered are ones already on this disk.
39 + pub(super) fn discover_pubkeys() -> Vec<String> {
40 + let Some(home) = std::env::var_os("HOME") else {
41 + return Vec::new();
42 + };
43 + let Ok(entries) = std::fs::read_dir(PathBuf::from(home).join(".ssh")) else {
44 + return Vec::new();
45 + };
46 +
47 + let mut found: Vec<String> = entries
48 + .flatten()
49 + .map(|entry| entry.path())
50 + .filter(|path| path.extension().is_some_and(|ext| ext == "pub"))
51 + .filter(|path| {
52 + std::fs::read_to_string(path).is_ok_and(|contents| looks_like_pubkey(&contents))
53 + })
54 + .map(|path| path.display().to_string())
55 + .collect();
56 + found.sort();
57 + found
58 + }
59 +
60 + /// Find the Alloy checkout to drive.
61 + ///
62 + /// Walks up from the working directory looking for the two things that make a
63 + /// directory this repo rather than any repo. Both are named: a Containerfile
64 + /// alone is most container projects, and `build/build-iso.sh` alone would
65 + /// match a fork that moved it.
66 + pub(super) fn find_repo() -> Option<PathBuf> {
67 + let mut dir = std::env::current_dir().ok()?;
68 + loop {
69 + if dir.join("Containerfile").is_file() && dir.join("build/build-iso.sh").is_file() {
70 + return Some(dir);
71 + }
72 + if !dir.pop() {
73 + return None;
74 + }
75 + }
76 + }
77 +
78 + #[cfg(test)]
79 + mod tests {
80 + use super::*;
81 +
82 + /// The one check that matters here: a private key must never be mistaken
83 + /// for a public one, because the artifact is meant to be copyable without
84 + /// care and baking a private key in would silently make that false.
85 + #[test]
86 + fn a_private_key_is_not_mistaken_for_a_public_one() {
87 + let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n"; // gitleaks:allow
88 + assert!(!looks_like_pubkey(private));
89 + }
90 +
91 + #[test]
92 + fn a_public_key_is_recognized() {
93 + assert!(looks_like_pubkey(
94 + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n"
95 + ));
96 + assert!(looks_like_pubkey(
97 + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY= max@fw13"
98 + ));
99 + assert!(!looks_like_pubkey(""));
100 + assert!(!looks_like_pubkey("hello world"));
101 + // A key type with no body is not a key.
102 + assert!(!looks_like_pubkey("ssh-ed25519"));
103 + assert!(!looks_like_pubkey("ssh-ed25519 short"));
104 + }
105 +
106 + /// Discovery is filtered by shape, not by suffix. `~/.ssh` collects other
107 + /// files, and a misnamed private half offered as a candidate is how a
108 + /// secret reaches an artifact that promises to hold none.
109 + #[test]
110 + fn discovery_refuses_anything_that_is_not_a_public_key() {
111 + let dir = std::env::temp_dir().join(format!("alloy-image-scan-{}", std::process::id()));
112 + let ssh = dir.join(".ssh");
113 + std::fs::create_dir_all(&ssh).expect("scratch dir");
114 +
115 + std::fs::write(
116 + ssh.join("id_ed25519.pub"),
117 + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n",
118 + )
119 + .expect("write key");
120 + // A private key that someone named `.pub`. The suffix is not the check.
121 + std::fs::write(
122 + ssh.join("oops.pub"),
123 + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n",
124 + )
125 + .expect("write private");
126 + // And the ordinary neighbours, which have no `.pub` at all.
127 + std::fs::write(ssh.join("known_hosts"), "github.com ssh-ed25519 AAAA\n")
128 + .expect("write known_hosts");
129 +
130 + // SAFETY: single-threaded within this test's own scratch HOME. The
131 + // discovery reads HOME rather than taking a directory because that is
132 + // what it does in the program, and testing a different function would
133 + // test nothing.
134 + let restore = std::env::var_os("HOME");
135 + unsafe { std::env::set_var("HOME", &dir) };
136 + let found = discover_pubkeys();
137 + match restore {
138 + Some(home) => unsafe { std::env::set_var("HOME", home) },
139 + None => unsafe { std::env::remove_var("HOME") },
140 + }
141 +
142 + assert_eq!(found.len(), 1, "{found:?}");
143 + assert!(found[0].ends_with("id_ed25519.pub"), "{found:?}");
144 +
145 + let _ = std::fs::remove_dir_all(&dir);
146 + }
147 + }
@@ -1,0 +1,154 @@
1 + //! The build.toml round trip: choices to a file a person can read, and back.
2 +
3 + use std::collections::BTreeSet;
4 + use std::fmt::Write as _;
5 +
6 + use anyhow::{Context, Result};
7 +
8 + use super::choices::{Artifact, Browser, Choices, Db, Lang, Profile, Trim};
9 + use super::{RECORD, RECORD_LOCAL};
10 +
11 + impl Choices {
12 + /// The record, as TOML.
13 + ///
14 + /// Hand-written rather than serialized, for one reason: the file is read
15 + /// by a person deciding whether to rebuild, so it carries the commentary
16 + /// that makes it answerable. A derived `Serialize` would emit the same
17 + /// keys and none of the why.
18 + pub(crate) fn to_toml(&self) -> String {
19 + let mut out = String::new();
20 + out.push_str("# Alloy build record — the choices that made this image.\n");
21 + out.push_str("# Written by `alloy image`, and read back by it so a rebuild starts\n");
22 + out.push_str("# from what this machine already is rather than from defaults.\n");
23 + out.push_str("#\n");
24 + out.push_str("# This is the CHOICES half. It is not a lockfile: it does not pin the\n");
25 + out.push_str("# resolved RPM set, so rebuilding from it gives you the same decisions\n");
26 + out.push_str("# against today's packages, not the same image. See wiki\n");
27 + out.push_str("# `alloy-distribution` for why the resolutions half is still open.\n\n");
28 +
29 + let _ = writeln!(out, "profile = {:?}", self.profile.value());
30 + let _ = writeln!(out, "browser = {:?}", self.browser.value());
31 + let langs: Vec<String> = self
32 + .langs
33 + .iter()
34 + .map(|lang| format!("{:?}", lang.value()))
35 + .collect();
36 + let _ = writeln!(out, "langs = [{}]", langs.join(", "));
37 + let _ = writeln!(out, "trim = {:?}", self.trim.value());
38 + let _ = writeln!(out, "db = {:?}", self.db.value());
39 + let _ = writeln!(out, "artifact = {:?}", self.artifact.value());
40 + let _ = writeln!(out, "hostname = {:?}", self.hostname);
41 + let _ = writeln!(out, "pubkey = {:?}", self.pubkey);
42 + out
43 + }
44 +
45 + /// Parse a record back.
46 + ///
47 + /// An unknown value for an enum is an error rather than a silent fall back
48 + /// to the default. A record naming a browser this build does not offer is
49 + /// a record from a different version of Alloy, and quietly building
50 + /// something else is how a user ends up with an image they did not ask
51 + /// for and cannot explain.
52 + pub(crate) fn from_toml(raw: &str) -> Result<Self> {
53 + let doc: toml::Value = toml::from_str(raw).context("the build record is not valid TOML")?;
54 +
55 + let word = |key: &str| -> Option<&str> { doc.get(key).and_then(toml::Value::as_str) };
56 +
57 + let mut choices = Self::default();
58 +
59 + if let Some(value) = word("profile") {
60 + choices.profile =
61 + Profile::parse(value).with_context(|| format!("unknown profile `{value}`"))?;
62 + }
63 + if let Some(value) = word("browser") {
64 + choices.browser =
65 + Browser::parse(value).with_context(|| format!("unknown browser `{value}`"))?;
66 + }
67 + if let Some(value) = word("trim") {
68 + choices.trim = Trim::parse(value).with_context(|| format!("unknown trim `{value}`"))?;
69 + }
70 + if let Some(value) = word("db") {
71 + choices.db = Db::parse(value).with_context(|| format!("unknown db `{value}`"))?;
72 + }
73 + if let Some(value) = word("artifact") {
74 + choices.artifact =
75 + Artifact::parse(value).with_context(|| format!("unknown artifact `{value}`"))?;
76 + }
77 + if let Some(value) = word("hostname") {
78 + choices.hostname = value.to_string();
79 + }
80 + if let Some(value) = word("pubkey") {
81 + choices.pubkey = value.to_string();
82 + }
83 + if let Some(values) = doc.get("langs").and_then(toml::Value::as_array) {
84 + let mut langs = BTreeSet::new();
85 + for value in values {
86 + let name = value
87 + .as_str()
88 + .context("the langs list holds something that is not a string")?;
89 + langs.insert(
90 + Lang::parse(name).with_context(|| format!("unknown language `{name}`"))?,
91 + );
92 + }
93 + choices.langs = langs;
94 + }
95 +
96 + Ok(choices)
97 + }
98 +
99 + /// The record a checkout or a built image carries, and where it came from.
100 + ///
101 + /// Checkout first, image second. Both can be present at once — a clone on
102 + /// an Alloy machine — and the checkout is the more recent statement of
103 + /// intent: it is what the builder last saved, where the image record is
104 + /// what was last built.
105 + ///
106 + /// **A record that exists and does not parse is reported, not skipped.**
107 + /// Falling through to the next path and then to defaults is the
108 + /// silent-forgetting failure this whole feature exists to avoid: a builder
109 + /// that quietly loses the machine's configuration and says nothing. A
110 + /// record from a newer Alloy naming a browser this build does not offer is
111 + /// exactly how that happens.
112 + pub(super) fn load() -> Loaded {
113 + for path in [RECORD_LOCAL, RECORD] {
114 + let Ok(raw) = std::fs::read_to_string(path) else {
115 + continue;
116 + };
117 + return match Self::from_toml(&raw) {
118 + Ok(choices) => Loaded {
119 + choices,
120 + from: Some(path.to_string()),
121 + problem: None,
122 + },
123 + Err(err) => Loaded {
124 + choices: Self::default(),
125 + from: None,
126 + problem: Some(format!(
127 + "{path} could not be read, so these are defaults rather than \
128 + this machine's choices: {err}"
129 + )),
130 + },
131 + };
132 + }
133 + Loaded {
134 + choices: Self::default(),
135 + from: None,
136 + problem: None,
137 + }
138 + }
139 + }
140 +
141 + /// What [`Choices::load`] found, including the case where it found something
142 + /// broken. Separate from `Option<Choices>` so a bad record cannot be confused
143 + /// with no record: one is worth saying out loud and the other is ordinary.
144 + pub(super) struct Loaded {
145 + pub(super) choices: Choices,
146 + /// Which file the choices came from, for the screen to name. `None` means
147 + /// they are defaults.
148 + pub(super) from: Option<String>,
149 + /// A record that exists and did not parse.
150 + pub(super) problem: Option<String>,
151 + }
152 +
153 + #[cfg(test)]
154 + mod tests;
@@ -1,0 +1,77 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + #[test]
6 + fn a_record_round_trips() {
7 + let choices = Choices {
8 + profile: Profile::Server,
9 + browser: Browser::None,
10 + langs: BTreeSet::from([Lang::Go]),
11 + artifact: Artifact::Qcow2,
12 + trim: Trim::Keep,
13 + db: Db::Postgres16,
14 + hostname: "bench".to_string(),
15 + pubkey: "/home/max/.ssh/id_ed25519.pub".to_string(),
16 + };
17 +
18 + let parsed = Choices::from_toml(&choices.to_toml()).expect("round trip");
19 + assert_eq!(parsed.profile, Profile::Server);
20 + assert_eq!(parsed.browser, Browser::None);
21 + assert_eq!(parsed.langs, BTreeSet::from([Lang::Go]));
22 + assert_eq!(parsed.artifact, Artifact::Qcow2);
23 + assert_eq!(parsed.trim, Trim::Keep);
24 + assert_eq!(parsed.db, Db::Postgres16);
25 + assert_eq!(parsed.hostname, "bench");
26 + assert_eq!(parsed.pubkey, "/home/max/.ssh/id_ed25519.pub");
27 + }
28 +
29 + /// A record from a different version of Alloy fails loudly. Falling back
30 + /// to the default would build something the user did not ask for and
31 + /// could not explain.
32 + #[test]
33 + fn an_unknown_value_is_an_error_rather_than_a_default() {
34 + let err = Choices::from_toml("browser = \"netscape\"").unwrap_err();
35 + assert!(format!("{err}").contains("netscape"), "{err}");
36 +
37 + let err = Choices::from_toml("langs = [\"cobol\"]").unwrap_err();
38 + assert!(format!("{err}").contains("cobol"), "{err}");
39 + }
40 +
41 + #[test]
42 + fn an_empty_record_is_the_default() {
43 + let parsed = Choices::from_toml("").expect("empty is valid");
44 + assert_eq!(parsed.profile, Profile::Client);
45 + }
46 +
47 + /// What a built image records, parsed by the same code that reads it back.
48 + /// The image writes `artifact` nowhere (it does not know which one it was
49 + /// packed into) and `pubkey` empty (the path meant something on another
50 + /// machine), so both have to land on their defaults rather than erroring.
51 + #[test]
52 + fn the_record_an_image_carries_reads_back() {
53 + let from_image = "\
54 + profile = \"server\"
55 + browser = \"none\"
56 + langs = [\"rust\", \"go\"]
57 + hostname = \"bench\"
58 + pubkey = \"\"
59 + ";
60 + let parsed = Choices::from_toml(from_image).expect("an image record parses");
61 + assert_eq!(parsed.profile, Profile::Server);
62 + assert_eq!(parsed.browser, Browser::None);
63 + assert_eq!(parsed.langs, BTreeSet::from([Lang::Rust, Lang::Go]));
64 + assert_eq!(parsed.hostname, "bench");
65 + assert!(parsed.pubkey.is_empty());
66 + // Absent, so the default. Not an error, and not a guess.
67 + assert_eq!(parsed.artifact, Artifact::Iso);
68 + }
69 +
70 + #[test]
71 + fn the_record_says_it_is_not_a_lockfile() {
72 + let toml = Choices::default().to_toml();
73 + assert!(
74 + toml.contains("not a lockfile"),
75 + "the record must not read as one: it pins choices, not resolutions",
76 + );
77 + }
@@ -1,482 +1,0 @@
1 - //! Tests for [`super`].
2 -
3 - use super::*;
4 -
5 - #[test]
6 - fn the_default_is_the_desktop_with_what_the_image_requires() {
7 - let choices = Choices::default();
8 - assert_eq!(choices.profile, Profile::Client);
9 - assert_eq!(choices.browser, Browser::Firefox);
10 - // No toolchain. The image requires none to function, and a build
11 - // host asks for one at mint. See the comment on `Choices::default`.
12 - assert!(choices.langs.is_empty());
13 - assert_eq!(choices.artifact, Artifact::Iso);
14 - // Trimmed by default. What it costs is foreign-architecture emulation,
15 - // which the house rules forbid using in the first place.
16 - assert_eq!(choices.trim, Trim::Unused);
17 - // No database. It is opt-in for the build-host role and every image
18 - // before the dial existed carried none.
19 - assert_eq!(choices.db, Db::None);
20 - }
21 -
22 - /// The build args are the whole contract with the Containerfile, so their
23 - /// names and their order are asserted rather than left to whatever the
24 - /// struct happens to iterate.
25 - #[test]
26 - fn the_build_args_name_what_the_containerfile_reads() {
27 - let args = Choices::default().build_args();
28 - let names: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).collect();
29 - assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM", "DB"]);
30 - assert_eq!(args[0].1, "client");
31 - // Comma-joined in the enum's declared order, and this is also the
32 - // literal the Containerfile's own `ARG LANGS` default has to match:
33 - // the two defaults are one decision written in two files, and a build
34 - // that bypasses the TUI must get the same stack the TUI would have
35 - // asked for.
36 - assert_eq!(args[2].1, "");
37 - assert_eq!(args[3].1, "unused");
38 - assert_eq!(args[4].1, "none");
39 - }
40 -
41 - #[test]
42 - fn identity_args_appear_only_once_they_are_set() {
43 - let mut choices = Choices::default();
44 - assert!(
45 - !choices
46 - .build_args()
47 - .iter()
48 - .any(|(k, _)| k == "ALLOY_HOSTNAME")
49 - );
50 - choices.hostname = "bench".to_string();
51 - assert!(
52 - choices
53 - .build_args()
54 - .iter()
55 - .any(|(k, _)| k == "ALLOY_HOSTNAME")
56 - );
57 - }
58 -
59 - #[test]
60 - fn several_languages_join_into_one_arg() {
61 - let choices = Choices {
62 - langs: BTreeSet::from([Lang::Rust, Lang::Go, Lang::Zig]),
63 - ..Choices::default()
64 - };
65 - let args = choices.build_args();
66 - let langs = &args.iter().find(|(k, _)| k == "LANGS").unwrap().1;
67 - // BTreeSet order, which is the enum's declared order, so the arg is
68 - // stable between runs rather than however a HashSet felt that day.
69 - assert_eq!(langs, "rust,go,zig");
70 - }
71 -
72 - /// The ISO does not come from bootc-image-builder, and passing it a type
73 - /// would be an error rather than a no-op.
74 - #[test]
75 - fn the_iso_and_the_disk_images_use_different_scripts() {
76 - let mut choices = Choices::default();
77 - assert_eq!(choices.artifact.script(), "build/build-iso.sh");
78 - assert!(!choices.invocation().display().contains("--type"));
79 -
80 - choices.artifact = Artifact::Raw;
81 - assert_eq!(choices.artifact.script(), "build/build-image.sh");
82 - assert!(choices.invocation().display().contains("--type raw"));
83 - }
84 -
85 - #[test]
86 - fn the_command_is_copy_pasteable() {
87 - let choices = Choices {
88 - hostname: "bench".to_string(),
89 - ..Choices::default()
90 - };
91 - let shown = choices.invocation().display();
92 - assert!(shown.starts_with("build/build-iso.sh"));
93 - assert!(shown.contains("--build-arg PROFILE=client"));
94 - assert!(shown.contains("--build-arg ALLOY_HOSTNAME=bench"));
95 - }
96 -
97 - #[test]
98 - fn a_record_round_trips() {
99 - let choices = Choices {
100 - profile: Profile::Server,
101 - browser: Browser::None,
102 - langs: BTreeSet::from([Lang::Go]),
103 - artifact: Artifact::Qcow2,
104 - trim: Trim::Keep,
105 - db: Db::Postgres16,
106 - hostname: "bench".to_string(),
107 - pubkey: "/home/max/.ssh/id_ed25519.pub".to_string(),
108 - };
109 -
110 - let parsed = Choices::from_toml(&choices.to_toml()).expect("round trip");
111 - assert_eq!(parsed.profile, Profile::Server);
112 - assert_eq!(parsed.browser, Browser::None);
113 - assert_eq!(parsed.langs, BTreeSet::from([Lang::Go]));
114 - assert_eq!(parsed.artifact, Artifact::Qcow2);
115 - assert_eq!(parsed.trim, Trim::Keep);
116 - assert_eq!(parsed.db, Db::Postgres16);
117 - assert_eq!(parsed.hostname, "bench");
118 - assert_eq!(parsed.pubkey, "/home/max/.ssh/id_ed25519.pub");
119 - }
120 -
121 - /// A record from a different version of Alloy fails loudly. Falling back
122 - /// to the default would build something the user did not ask for and
123 - /// could not explain.
124 - #[test]
125 - fn an_unknown_value_is_an_error_rather_than_a_default() {
126 - let err = Choices::from_toml("browser = \"netscape\"").unwrap_err();
127 - assert!(format!("{err}").contains("netscape"), "{err}");
128 -
129 - let err = Choices::from_toml("langs = [\"cobol\"]").unwrap_err();
130 - assert!(format!("{err}").contains("cobol"), "{err}");
131 - }
132 -
133 - #[test]
134 - fn an_empty_record_is_the_default() {
135 - let parsed = Choices::from_toml("").expect("empty is valid");
136 - assert_eq!(parsed.profile, Profile::Client);
137 - }
138 -
139 - #[test]
140 - fn hostnames_are_checked_the_way_dns_checks_them() {
141 - assert!(valid_hostname("bench"));
142 - assert!(valid_hostname("build-host-2"));
143 - assert!(!valid_hostname(""));
144 - assert!(!valid_hostname("-leading"));
145 - assert!(!valid_hostname("trailing-"));
146 - assert!(!valid_hostname("under_score"));
147 - assert!(!valid_hostname("has space"));
148 - assert!(!valid_hostname(&"a".repeat(64)));
149 - assert!(valid_hostname(&"a".repeat(63)));
150 - }
151 -
152 - /// The one check that matters here: a private key must never be mistaken
153 - /// for a public one, because the artifact is meant to be copyable without
154 - /// care and baking a private key in would silently make that false.
155 - #[test]
156 - fn a_private_key_is_not_mistaken_for_a_public_one() {
157 - let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n";
158 - assert!(!looks_like_pubkey(private));
159 - }
160 -
161 - #[test]
162 - fn a_public_key_is_recognized() {
163 - assert!(looks_like_pubkey(
164 - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n"
165 - ));
166 - assert!(looks_like_pubkey(
167 - "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY= max@fw13"
168 - ));
169 - assert!(!looks_like_pubkey(""));
170 - assert!(!looks_like_pubkey("hello world"));
171 - // A key type with no body is not a key.
172 - assert!(!looks_like_pubkey("ssh-ed25519"));
173 - assert!(!looks_like_pubkey("ssh-ed25519 short"));
174 - }
175 -
176 - /// A server has no session, so it cannot carry a browser. The blocker
177 - /// exists for a record loaded off disk; the key handler prevents reaching
178 - /// the state interactively.
179 - #[test]
180 - fn a_server_carrying_a_browser_is_blocked() {
181 - let choices = Choices {
182 - profile: Profile::Server,
183 - browser: Browser::Firefox,
184 - ..Choices::default()
185 - };
186 - let blockers = choices.blockers(Some(Path::new("/nonexistent")));
187 - assert!(
188 - blockers.iter().any(|b| b.contains("no graphical session")),
189 - "{blockers:?}"
190 - );
191 - }
192 -
193 - #[test]
194 - fn no_checkout_is_the_first_thing_reported() {
195 - let blockers = Choices::default().blockers(None);
196 - assert!(blockers[0].contains("no Alloy checkout"), "{blockers:?}");
197 - }
198 -
199 - /// The write goes through the script's own path, with `--write-only` so it
200 - /// writes the artifact that already exists rather than rebuilding it.
201 - /// Re-deriving this is the disk-eating bug the design note warns about.
202 - #[test]
203 - fn the_write_delegates_to_the_script_that_owns_the_guards() {
204 - let shown = ImageView {
205 - choices: Choices::default(),
206 - cursor: Cursor::new(),
207 - repo: None,
208 - candidates: Vec::new(),
209 - editing: None,
210 - sequence: None,
211 - pending_write: None,
212 - device: None,
213 - error: None,
214 - saved: false,
215 - }
216 - .write_command("/dev/sdX")
217 - .display();
218 -
219 - assert!(shown.starts_with("build/build-iso.sh"));
220 - assert!(shown.contains("--write-only"));
221 - assert!(shown.contains("--write /dev/sdX"));
222 - // Nothing resembling a dd, anywhere.
223 - assert!(!shown.contains("dd "));
224 - assert!(!shown.contains("of="));
225 - }
226 -
227 - /// The build container cannot see a path on the building machine — the
228 - /// file is not in the build context — so the key travels as its bytes.
229 - /// Passing the path would make the Containerfile's own validation reject
230 - /// it, which is a confusing way to learn a file is missing.
231 - #[test]
232 - fn the_key_travels_as_bytes_and_the_record_keeps_the_path() {
233 - let dir = std::env::temp_dir().join(format!("alloy-image-key-{}", std::process::id()));
234 - std::fs::create_dir_all(&dir).expect("scratch dir");
235 - let path = dir.join("id_ed25519.pub");
236 - std::fs::write(
237 - &path,
238 - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n",
239 - )
240 - .expect("write key");
241 -
242 - let choices = Choices {
243 - pubkey: path.display().to_string(),
244 - ..Choices::default()
245 - };
246 -
247 - let key = choices
248 - .build_args()
249 - .into_iter()
250 - .find(|(name, _)| name == "ALLOY_SSH_KEY")
251 - .expect("the key is passed")
252 - .1;
253 - assert!(key.starts_with("ssh-ed25519 AAAA"), "{key}");
254 - // Trimmed: a trailing newline inside a --build-arg value would land in
255 - // the authorized_keys file and in the validation `case`.
256 - assert!(!key.ends_with('\n'));
257 -
258 - // The record keeps the path, so a saved build.toml is not a different
259 - // file on every machine.
260 - assert!(choices.to_toml().contains("id_ed25519.pub"));
261 -
262 - let _ = std::fs::remove_dir_all(&dir);
263 - }
264 -
265 - /// An unreadable key is omitted rather than guessed at, because `blockers`
266 - /// is the gate and has already refused to start the build.
267 - #[test]
268 - fn an_unreadable_key_is_omitted_and_blocked() {
269 - let choices = Choices {
270 - pubkey: "/nonexistent/id_ed25519.pub".to_string(),
271 - ..Choices::default()
272 - };
273 - assert!(
274 - !choices
275 - .build_args()
276 - .iter()
277 - .any(|(name, _)| name == "ALLOY_SSH_KEY")
278 - );
279 - assert!(
280 - choices
281 - .blockers(None)
282 - .iter()
283 - .any(|blocker| blocker.contains("no public key at")),
284 - );
285 - }
286 -
287 - /// What a built image records, parsed by the same code that reads it back.
288 - /// The image writes `artifact` nowhere (it does not know which one it was
289 - /// packed into) and `pubkey` empty (the path meant something on another
290 - /// machine), so both have to land on their defaults rather than erroring.
291 - #[test]
292 - fn the_record_an_image_carries_reads_back() {
293 - let from_image = "\
294 - profile = \"server\"
295 - browser = \"none\"
296 - langs = [\"rust\", \"go\"]
297 - hostname = \"bench\"
298 - pubkey = \"\"
299 - ";
300 - let parsed = Choices::from_toml(from_image).expect("an image record parses");
301 - assert_eq!(parsed.profile, Profile::Server);
302 - assert_eq!(parsed.browser, Browser::None);
303 - assert_eq!(parsed.langs, BTreeSet::from([Lang::Rust, Lang::Go]));
304 - assert_eq!(parsed.hostname, "bench");
305 - assert!(parsed.pubkey.is_empty());
306 - // Absent, so the default. Not an error, and not a guess.
307 - assert_eq!(parsed.artifact, Artifact::Iso);
308 - }
309 -
310 - /// A view with a known candidate list, so the cycling can be exercised
311 - /// without a `~/.ssh` to stand in front of it.
312 - fn view_with(candidates: &[&str]) -> ImageView {
313 - ImageView {
314 - choices: Choices::default(),
315 - cursor: Cursor::new(),
316 - repo: None,
317 - candidates: candidates.iter().map(|key| (*key).to_string()).collect(),
318 - editing: None,
319 - sequence: None,
320 - pending_write: None,
321 - device: None,
322 - error: None,
323 - saved: false,
324 - }
325 - }
326 -
327 - /// Empty is a position on the ring, not a state to escape. An image with no
328 - /// baked key is the ordinary desktop install, so it has to stay reachable
329 - /// once a key has been cycled onto the row.
330 - #[test]
331 - fn cycling_the_pubkey_row_passes_back_through_none() {
332 - let mut view = view_with(&["/home/max/.ssh/a.pub", "/home/max/.ssh/b.pub"]);
333 - assert_eq!(view.choices.pubkey, "");
334 -
335 - view.cycle_pubkey(true);
336 - assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
337 - view.cycle_pubkey(true);
338 - assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
339 - view.cycle_pubkey(true);
340 - assert_eq!(view.choices.pubkey, "", "the ring returns to no key");
341 -
342 - // And backwards, off none onto the last one.
343 - view.cycle_pubkey(false);
344 - assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
345 - }
346 -
347 - /// A path typed by hand is not one of the candidates, so it reads as the
348 - /// empty position rather than panicking on a lookup that finds nothing.
349 - #[test]
350 - fn a_typed_path_is_not_lost_to_an_index_it_never_had() {
351 - let mut view = view_with(&["/home/max/.ssh/a.pub"]);
352 - view.choices.pubkey = "/elsewhere/key.pub".to_string();
353 - view.cycle_pubkey(true);
354 - assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
355 - }
356 -
357 - /// Nothing to cycle says so, rather than silently doing nothing. An inert
358 - /// key reads as the form being broken.
359 - #[test]
360 - fn no_candidates_explains_itself() {
361 - let mut view = view_with(&[]);
362 - view.cycle_pubkey(true);
363 - assert_eq!(view.choices.pubkey, "");
364 - assert!(
365 - view.error.as_deref().is_some_and(|e| e.contains("~/.ssh")),
366 - "{:?}",
367 - view.error
368 - );
369 - }
370 -
371 - /// Discovery is filtered by shape, not by suffix. `~/.ssh` collects other
372 - /// files, and a misnamed private half offered as a candidate is how a
373 - /// secret reaches an artifact that promises to hold none.
374 - #[test]
375 - fn discovery_refuses_anything_that_is_not_a_public_key() {
376 - let dir = std::env::temp_dir().join(format!("alloy-image-scan-{}", std::process::id()));
377 - let ssh = dir.join(".ssh");
378 - std::fs::create_dir_all(&ssh).expect("scratch dir");
379 -
380 - std::fs::write(
381 - ssh.join("id_ed25519.pub"),
382 - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n",
383 - )
384 - .expect("write key");
385 - // A private key that someone named `.pub`. The suffix is not the check.
386 - std::fs::write(
387 - ssh.join("oops.pub"),
388 - "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n",
389 - )
390 - .expect("write private");
391 - // And the ordinary neighbours, which have no `.pub` at all.
392 - std::fs::write(ssh.join("known_hosts"), "github.com ssh-ed25519 AAAA\n")
393 - .expect("write known_hosts");
394 -
395 - // SAFETY: single-threaded within this test's own scratch HOME. The
396 - // discovery reads HOME rather than taking a directory because that is
397 - // what it does in the program, and testing a different function would
398 - // test nothing.
399 - let restore = std::env::var_os("HOME");
400 - unsafe { std::env::set_var("HOME", &dir) };
401 - let found = discover_pubkeys();
402 - match restore {
403 - Some(home) => unsafe { std::env::set_var("HOME", home) },
404 - None => unsafe { std::env::remove_var("HOME") },
405 - }
406 -
407 - assert_eq!(found.len(), 1, "{found:?}");
408 - assert!(found[0].ends_with("id_ed25519.pub"), "{found:?}");
409 -
410 - let _ = std::fs::remove_dir_all(&dir);
411 - }
412 -
413 - #[test]
414 - fn the_record_says_it_is_not_a_lockfile() {
415 - let toml = Choices::default().to_toml();
416 - assert!(
417 - toml.contains("not a lockfile"),
418 - "the record must not read as one: it pins choices, not resolutions",
419 - );
420 - }
421 -
422 - /// Against the file the image actually ships, not a fixture, so an
423 - /// os-release edit that drops or renames `VERSION_ID` fails here rather
424 - /// than by silently removing the image line from `alloy --version`.
425 - ///
426 - /// The committed file carries no `IMAGE_VERSION`: the build stamps it, and
427 - /// a placeholder here would be a lie on any machine where the stamping
428 - /// step stopped working. So this is the unstamped shape on purpose, and
429 - /// the product version is asserted rather than the whole line.
430 - #[test]
431 - fn the_shipped_os_release_states_an_image_version() {
432 - let shipped = concat!(env!("CARGO_MANIFEST_DIR"), "/../../usr/lib/os-release");
433 - let text = std::fs::read_to_string(shipped).expect("the repo ships usr/lib/os-release");
434 - let version = version_from(&text).expect("the shipped os-release names an image version");
435 - assert!(version.starts_with("0."), "{version}");
436 - assert!(
437 - !version.contains("build"),
438 - "the stamp is not committed: {version}"
439 - );
440 - assert!(version.contains("Fedora"), "{version}");
441 - }
442 -
443 - /// The line a support conversation reads back: product, build and base,
444 - /// which move on three different clocks and are three fields for that
445 - /// reason.
446 - #[test]
447 - fn a_stamped_image_composes_the_whole_triple() {
448 - let stamped = "NAME=\"Alloy\"\nVERSION_ID=\"0.1\"\nIMAGE_VERSION=\"20260816.143012\"\n\
449 - ALLOY_BASE=\"43\"\nID=alloy\nID_LIKE=fedora\n";
450 - assert_eq!(
451 - version_from(stamped).as_deref(),
452 - Some("0.1 (build 20260816.143012, Fedora 43)")
453 - );
454 - }
455 -
456 - /// An unstamped build is a real state rather than a broken one: a bare
457 - /// `podman build` past the wrapper scripts produces one. It reports less
458 - /// and nothing false, which is what a filled-in "unknown" would not do.
459 - #[test]
460 - fn an_unstamped_image_says_less_rather_than_something_false() {
461 - let unstamped = "VERSION_ID=\"0.1\"\nALLOY_BASE=\"43\"\nID=alloy\n";
462 - assert_eq!(version_from(unstamped).as_deref(), Some("0.1 (Fedora 43)"));
463 -
464 - let bare = "VERSION_ID=\"0.1\"\nID=alloy\n";
465 - assert_eq!(version_from(bare).as_deref(), Some("0.1"));
466 - }
467 -
468 - /// The reason `ID` is checked. Every Linux host has an os-release, so a
469 - /// dev box would otherwise report its own distro's version as the image's.
470 - #[test]
471 - fn a_foreign_os_release_has_no_image_version() {
472 - let fedora = "NAME=\"Fedora Linux\"\nID=fedora\nVERSION_ID=43\n";
473 - assert_eq!(version_from(fedora), None);
474 - }
475 -
476 - /// `ID` is a prefix of `ID_LIKE`, and matching the wrong one would read
477 - /// every Fedora derivative as Alloy.
478 - #[test]
479 - fn id_like_is_not_mistaken_for_id() {
480 - let derivative = "ID=notalloy\nID_LIKE=alloy\nVERSION_ID=9\n";
481 - assert_eq!(version_from(derivative), None);
482 - }
@@ -1,0 +1,138 @@
1 + //! The running machine's Alloy version.
2 + //!
3 + //! Answers a different question from the rest of this verb: everything else
4 + //! here builds an image, and this reads the one already booted. It lives under
5 + //! `image` because `image::version()` is the name a reader guesses.
6 +
7 + /// Where the running system states which Alloy it is.
8 + ///
9 + /// `/usr/lib` and not `/etc`: the Containerfile ships it there and symlinks
10 + /// `/etc/os-release` at it, because bootc reads the deployment's copy when it
11 + /// writes a boot menu entry. Reading the source rather than the link.
12 + const OS_RELEASE: &str = "/usr/lib/os-release";
13 +
14 + /// The image's version: the product, the build it came from, and the Fedora
15 + /// base it was built on, composed into one line.
16 + ///
17 + /// Three fields because they move on three clocks. `VERSION_ID` is the product
18 + /// and moves on a release; `IMAGE_VERSION` is stamped by every build;
19 + /// `ALLOY_BASE` follows the Containerfile's `FROM`. Two machines on the same
20 + /// product version can be different images, and that is exactly the pair a
21 + /// support conversation has to tell apart.
22 + ///
23 + /// Separate from the console's `CARGO_PKG_VERSION`, and the two diverge on
24 + /// purpose: the hotfix channel exists to put a newer console on an older
25 + /// image, so a support conversation that cannot see both cannot tell a layered
26 + /// machine from a rebuilt one, so `alloy --version` reports both.
27 + ///
28 + /// `None` off an Alloy machine, which is why `ID` is checked rather than
29 + /// assumed: every Linux host has an os-release, and reading a dev box's Fedora
30 + /// or Pop!_OS `VERSION_ID` would report a confident wrong answer. There is no
31 + /// image there to have a version, so the line is omitted rather than filled in
32 + /// with "unknown".
33 + pub(crate) fn version() -> Option<String> {
34 + version_from(&std::fs::read_to_string(OS_RELEASE).ok()?)
35 + }
36 +
37 + /// The parse, split from the read so it can be tested against the os-release
38 + /// this repo actually ships rather than against the host's.
39 + ///
40 + /// Composed here and not in `notice_text`, which stays a formatter with no
41 + /// os-release knowledge.
42 + ///
43 + /// Each of the two trailing fields is dropped rather than filled in when it is
44 + /// missing. An unstamped image is a real state — a bare `podman build` past the
45 + /// wrapper scripts — and `0.1 (Fedora 43)` says less than the full line while
46 + /// saying nothing false, which is what `unknown` would do.
47 + fn version_from(text: &str) -> Option<String> {
48 + // `strip_prefix` on the key and then on `=`, in that order, so a key that
49 + // is a prefix of another does not match it: `ID` against `ID_LIKE=fedora`
50 + // leaves `_LIKE=fedora`, which has no leading `=` and is skipped.
51 + let field = |key: &str| {
52 + text.lines()
53 + .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
54 + .map(|value| value.trim_matches('"').to_string())
55 + };
56 + if field("ID").as_deref() != Some("alloy") {
57 + return None;
58 + }
59 + let product = field("VERSION_ID")?;
60 + let detail: Vec<String> = [
61 + field("IMAGE_VERSION").map(|build| format!("build {build}")),
62 + field("ALLOY_BASE").map(|base| format!("Fedora {base}")),
63 + ]
64 + .into_iter()
65 + .flatten()
66 + .collect();
67 + if detail.is_empty() {
68 + return Some(product);
69 + }
70 + Some(format!("{product} ({})", detail.join(", ")))
71 + }
72 +
73 + #[cfg(test)]
74 + mod tests {
75 + use super::*;
76 +
77 + /// Against the file the image actually ships, not a fixture, so an
78 + /// os-release edit that drops or renames `VERSION_ID` fails here rather
79 + /// than by silently removing the image line from `alloy --version`.
80 + ///
81 + /// The committed file carries no `IMAGE_VERSION`: the build stamps it, and
82 + /// a placeholder here would be a lie on any machine where the stamping
83 + /// step stopped working. So this is the unstamped shape on purpose, and
84 + /// the product version is asserted rather than the whole line.
85 + #[test]
86 + fn the_shipped_os_release_states_an_image_version() {
87 + let shipped = concat!(env!("CARGO_MANIFEST_DIR"), "/../../usr/lib/os-release");
88 + let text = std::fs::read_to_string(shipped).expect("the repo ships usr/lib/os-release");
89 + let version = version_from(&text).expect("the shipped os-release names an image version");
90 + assert!(version.starts_with("0."), "{version}");
91 + assert!(
92 + !version.contains("build"),
93 + "the stamp is not committed: {version}"
94 + );
95 + assert!(version.contains("Fedora"), "{version}");
96 + }
97 +
98 + /// The line a support conversation reads back: product, build and base,
99 + /// which move on three different clocks and are three fields for that
100 + /// reason.
101 + #[test]
102 + fn a_stamped_image_composes_the_whole_triple() {
103 + let stamped = "NAME=\"Alloy\"\nVERSION_ID=\"0.1\"\nIMAGE_VERSION=\"20260816.143012\"\n\
104 + ALLOY_BASE=\"43\"\nID=alloy\nID_LIKE=fedora\n";
105 + assert_eq!(
106 + version_from(stamped).as_deref(),
107 + Some("0.1 (build 20260816.143012, Fedora 43)")
108 + );
109 + }
110 +
111 + /// An unstamped build is a real state rather than a broken one: a bare
112 + /// `podman build` past the wrapper scripts produces one. It reports less
113 + /// and nothing false, which is what a filled-in "unknown" would not do.
114 + #[test]
115 + fn an_unstamped_image_says_less_rather_than_something_false() {
116 + let unstamped = "VERSION_ID=\"0.1\"\nALLOY_BASE=\"43\"\nID=alloy\n";
117 + assert_eq!(version_from(unstamped).as_deref(), Some("0.1 (Fedora 43)"));
118 +
119 + let bare = "VERSION_ID=\"0.1\"\nID=alloy\n";
120 + assert_eq!(version_from(bare).as_deref(), Some("0.1"));
121 + }
122 +
123 + /// The reason `ID` is checked. Every Linux host has an os-release, so a
124 + /// dev box would otherwise report its own distro's version as the image's.
125 + #[test]
126 + fn a_foreign_os_release_has_no_image_version() {
127 + let fedora = "NAME=\"Fedora Linux\"\nID=fedora\nVERSION_ID=43\n";
128 + assert_eq!(version_from(fedora), None);
129 + }
130 +
131 + /// `ID` is a prefix of `ID_LIKE`, and matching the wrong one would read
132 + /// every Fedora derivative as Alloy.
133 + #[test]
134 + fn id_like_is_not_mistaken_for_id() {
135 + let derivative = "ID=notalloy\nID_LIKE=alloy\nVERSION_ID=9\n";
136 + assert_eq!(version_from(derivative), None);
137 + }
138 + }
@@ -1,0 +1,651 @@
1 + //! The `alloy image` screen: the form, its blockers, and the build it drives.
2 +
3 + use std::path::PathBuf;
4 +
5 + use alloy_tui::keys::{Action, classify};
6 + use alloy_tui::{
7 + AlloyBlock, AlloyForm, AlloyList, Cursor, FieldKind, FormRow, Hint, Severity, TextField, Theme,
8 + hint, text,
9 + };
10 + use ratatui::Frame;
11 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
12 + use ratatui::layout::{Constraint, Layout, Rect};
13 + use ratatui::text::Line;
14 +
15 + use super::RECORD_LOCAL;
16 + use super::choices::{Artifact, Browser, Choices, Db, Lang, Profile, Trim};
17 + use super::discover::{discover_pubkeys, find_repo};
18 + use crate::cli::{CommandLog, Invocation};
19 + use crate::run::{Sequence, Stage};
20 + use crate::shell::{Confirm, Flow, View, block_title};
21 +
22 + /// Which row the form is on.
23 + ///
24 + /// An enum rather than an index into a `Vec` so that adding a row cannot
25 + /// silently renumber what a keypress edits.
26 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 + enum Row {
28 + Profile,
29 + Browser,
30 + /// One row per language rather than one row holding a set. A single
31 + /// "languages" row would need a key meaning "toggle the next one", which
32 + /// is a model nobody can predict from looking at it; four toggles are four
33 + /// things you can see the state of.
34 + Lang(Lang),
35 + Trim,
36 + Db,
37 + Artifact,
38 + Hostname,
39 + Pubkey,
40 + }
41 +
42 + impl Row {
43 + /// The rows, in screen order. A function rather than a constant because
44 + /// the language rows are derived from [`Lang::ALL`], so adding a language
45 + /// cannot leave the form and the model disagreeing.
46 + fn all() -> Vec<Row> {
47 + let mut rows = vec![Row::Profile, Row::Browser];
48 + rows.extend(Lang::ALL.map(Row::Lang));
49 + rows.extend([
50 + Row::Trim,
51 + Row::Db,
52 + Row::Artifact,
53 + Row::Hostname,
54 + Row::Pubkey,
55 + ]);
56 + rows
57 + }
58 +
59 + fn label(self) -> String {
60 + match self {
61 + Row::Profile => "profile".to_string(),
62 + Row::Browser => "browser".to_string(),
63 + Row::Lang(lang) => lang.value().to_string(),
64 + Row::Trim => "base trim".to_string(),
65 + Row::Db => "database".to_string(),
66 + Row::Artifact => "artifact".to_string(),
67 + Row::Hostname => "hostname".to_string(),
68 + Row::Pubkey => "ssh pubkey".to_string(),
69 + }
70 + }
71 +
72 + fn help(self) -> &'static str {
73 + match self {
74 + Row::Profile => {
75 + "client is the desktop; server drops the compositor, greeter and session"
76 + }
77 + Row::Browser => "the one stack pick Alloy declines to make for you",
78 + Row::Lang(lang) => lang.label(),
79 + Row::Trim => "base packages nothing in Alloy reaches. Never firmware",
80 + Row::Db => "the binaries, not a cluster. 16 is what production runs",
81 + Row::Artifact => "ISO boots into the installer; raw and qcow2 are installed systems",
82 + Row::Hostname => "baked in, so a headless box is found at <name>.local",
83 + Row::Pubkey => "a PUBLIC key from ~/.ssh, or a path. The installer's only credential",
84 + }
85 + }
86 + }
87 +
88 + /// The `alloy image` screen.
89 + pub(crate) struct ImageView {
90 + choices: Choices,
91 + cursor: Cursor,
92 + /// The checkout to drive, found once at construction. `None` is a state
93 + /// the screen renders rather than an error it fails with: someone may open
94 + /// the builder to read what it would do before cloning anything.
95 + repo: Option<PathBuf>,
96 + /// The public keys found in `~/.ssh` at startup, for the pubkey row to
97 + /// cycle. Kept even when one of them was adopted as the default, so a host
98 + /// with several keys stays choosable without typing a path.
99 + candidates: Vec<String>,
100 + /// The open text editor, if the focused row takes one.
101 + editing: Option<TextField>,
102 + /// The running build. `None` before the first one and after a finished one
103 + /// is dismissed.
104 + sequence: Option<Sequence>,
105 + /// The device a write has been aimed at but not yet confirmed.
106 + pending_write: Option<String>,
107 + /// A device path being typed.
108 + device: Option<TextField>,
109 + error: Option<String>,
110 + saved: bool,
111 + }
112 +
113 + impl ImageView {
114 + pub(crate) fn new(log: &mut CommandLog) -> Self {
115 + let loaded = Choices::load();
116 + if let Some(from) = &loaded.from {
117 + log.record(format!("# choices from {from}"), Severity::Healthy);
118 + }
119 +
120 + let mut choices = loaded.choices;
121 + let candidates = discover_pubkeys();
122 +
123 + // Only when the record did not already answer. A saved `pubkey` is a
124 + // statement about which key this machine is minted with, and quietly
125 + // replacing it with whatever sorts first in `~/.ssh` would be the
126 + // silent-forgetting failure `load` exists to avoid.
127 + //
128 + // ONE key is adopted; SEVERAL are offered and none is chosen. There is
129 + // no rule that picks correctly between a personal key and a key for the
130 + // machine being built, and guessing wrong bakes the wrong credential
131 + // into an artifact nobody re-reads before writing it to a stick.
132 + if choices.pubkey.is_empty() {
133 + match candidates.as_slice() {
134 + [only] => {
135 + choices.pubkey.clone_from(only);
136 + log.record(
137 + format!("# one public key in ~/.ssh: {only}"),
138 + Severity::Info,
139 + );
140 + }
141 + [_, _, ..] => log.record(
142 + format!(
143 + "# {} public keys in ~/.ssh, so none was chosen: h/l on the ssh pubkey row",
144 + candidates.len()
145 + ),
146 + Severity::Info,
147 + ),
148 + [] => {}
149 + }
150 + }
151 +
152 + Self {
153 + choices,
154 + cursor: Cursor::new(),
155 + repo: find_repo(),
156 + candidates,
157 + editing: None,
158 + sequence: None,
159 + pending_write: None,
160 + device: None,
161 + // A broken record is the first thing the screen says, ahead of any
162 + // blocker: everything below it is defaults masquerading as this
163 + // machine's configuration, and that is worth knowing before
164 + // reading a single row.
165 + error: loaded.problem,
166 + saved: false,
167 + }
168 + }
169 +
170 + fn row(&self) -> Row {
171 + let rows = Row::all();
172 + rows[self.cursor.selected().unwrap_or(0).min(rows.len() - 1)]
173 + }
174 +
175 + /// Cycle the focused enum row forward.
176 + ///
177 + /// One key rather than a picker modal: four of the six rows are two- or
178 + /// three-valued, and a modal to choose between two things costs more
179 + /// keystrokes than it saves.
180 + fn cycle(&mut self, forward: bool) {
181 + fn step<T: Copy + PartialEq>(all: &[T], current: T, forward: bool) -> T {
182 + let index = all.iter().position(|c| *c == current).unwrap_or(0);
183 + let len = all.len();
184 + let next = if forward {
185 + (index + 1) % len
186 + } else {
187 + (index + len - 1) % len
188 + };
189 + all[next]
190 + }
191 +
192 + match self.row() {
193 + Row::Profile => {
194 + self.choices.profile = step(&Profile::ALL, self.choices.profile, forward);
195 + // A server has no session to run a browser in. Following the
196 + // profile rather than blocking on it: the blocker exists for a
197 + // record loaded from disk, but a user who just pressed a key
198 + // should see the consequence, not a complaint.
199 + if self.choices.profile == Profile::Server {
200 + self.choices.browser = Browser::None;
201 + }
202 + }
203 + Row::Browser => {
204 + if self.choices.profile == Profile::Server {
205 + self.error = Some(
206 + "the server profile ships no graphical session, so it carries no browser"
207 + .to_string(),
208 + );
209 + } else {
210 + self.choices.browser = step(&Browser::ALL, self.choices.browser, forward);
211 + }
212 + }
213 + Row::Trim => {
214 + self.choices.trim = step(&Trim::ALL, self.choices.trim, forward);
215 + }
216 + Row::Db => {
217 + self.choices.db = step(&Db::ALL, self.choices.db, forward);
218 + }
219 + Row::Artifact => {
220 + self.choices.artifact = step(&Artifact::ALL, self.choices.artifact, forward);
221 + }
222 + // A toggle has no direction, so h/l does what space does on these
223 + // rows rather than nothing. A key that is inert on one row of a
224 + // form reads as the form being broken.
225 + Row::Lang(_) => self.toggle(),
226 + // Cycles the keys found in `~/.ssh`, which is the whole of the
227 + // choice on a host that has more than one. Typing a path is still
228 + // there on enter, for a key that lives somewhere else.
229 + Row::Pubkey => self.cycle_pubkey(forward),
230 + Row::Hostname => {}
231 + }
232 + self.saved = false;
233 + }
234 +
235 + /// Step through the discovered keys.
236 + ///
237 + /// Empty is one of the positions rather than something to escape from: an
238 + /// image without a baked key is the ordinary desktop install, so a user who
239 + /// cycles past the last candidate should land back on "none" instead of
240 + /// wrapping straight onto a key they were trying to get away from.
241 + ///
242 + /// A path typed by hand is not in the list, so it reads as position zero
243 + /// and the first press moves to the first candidate. That loses the typed
244 + /// value, which is why this is `h/l` and the typed path is committed with
245 + /// enter: the two are different gestures.
246 + fn cycle_pubkey(&mut self, forward: bool) {
247 + if self.candidates.is_empty() {
248 + self.error = Some("no public keys in ~/.ssh: press enter to type a path".to_string());
249 + return;
250 + }
251 +
252 + // Position 0 is "none", so the ring is one longer than the candidates.
253 + let len = self.candidates.len() + 1;
254 + let current = self
255 + .candidates
256 + .iter()
257 + .position(|key| *key == self.choices.pubkey)
258 + .map_or(0, |index| index + 1);
259 + let next = if forward {
260 + (current + 1) % len
261 + } else {
262 + (current + len - 1) % len
263 + };
264 +
265 + self.choices.pubkey = match next {
266 + 0 => String::new(),
267 + index => self.candidates[index - 1].clone(),
268 + };
269 + }
270 +
271 + /// Toggle the language on the focused row.
272 + fn toggle(&mut self) {
273 + let Row::Lang(lang) = self.row() else {
274 + return;
275 + };
276 + if !self.choices.langs.remove(&lang) {
277 + self.choices.langs.insert(lang);
278 + }
279 + self.saved = false;
280 + }
281 +
282 + fn begin_edit(&mut self) {
283 + let current = match self.row() {
284 + Row::Hostname => self.choices.hostname.clone(),
285 + Row::Pubkey => self.choices.pubkey.clone(),
286 + _ => return,
287 + };
288 + let mut field = TextField::new();
289 + field.set(current);
290 + field.end();
291 + self.editing = Some(field);
292 + }
293 +
294 + fn commit_edit(&mut self) {
295 + let Some(field) = self.editing.take() else {
296 + return;
297 + };
298 + let value = field.value().trim().to_string();
299 + match self.row() {
300 + Row::Hostname => self.choices.hostname = value,
301 + Row::Pubkey => self.choices.pubkey = value,
302 + _ => {}
303 + }
304 + self.saved = false;
305 + }
306 +
307 + fn save(&mut self, log: &mut CommandLog) {
308 + let Some(repo) = &self.repo else {
309 + self.error = Some("no checkout to save into".to_string());
310 + return;
311 + };
312 + let path = repo.join(RECORD_LOCAL);
313 + match std::fs::write(&path, self.choices.to_toml()) {
314 + Ok(()) => {
315 + log.record(format!("# wrote {}", path.display()), Severity::Healthy);
316 + self.saved = true;
317 + }
318 + Err(err) => self.error = Some(format!("cannot write {}: {err}", path.display())),
319 + }
320 + }
321 +
322 + fn start_build(&mut self, log: &mut CommandLog) {
323 + let blockers = self.choices.blockers(self.repo.as_deref());
324 + if let Some(first) = blockers.first() {
325 + self.error = Some(first.clone());
326 + return;
327 + }
328 +
329 + // Saving before building rather than after, so a build that fails
330 + // half way still leaves the choices that produced it on disk. The
331 + // alternative loses the configuration at exactly the moment someone
332 + // wants to retry it.
333 + self.save(log);
334 +
335 + self.sequence = Some(Sequence::new(vec![Stage::Run(self.choices.invocation())]));
336 + }
337 +
338 + /// The write, handed to the script rather than performed here.
339 + ///
340 + /// See the module docs. This returns the command for the shell to run with
341 + /// the terminal attached; the script asks its own confirmation there.
342 + fn write_command(&self, device: &str) -> Invocation {
343 + Invocation::new(self.choices.artifact.script()).args(["--write-only", "--write", device])
344 + }
345 + }
346 +
347 + impl View for ImageView {
348 + fn title(&self) -> String {
349 + match &self.repo {
350 + Some(repo) => format!(
351 + "image builder ({})",
352 + repo.file_name().map_or_else(
353 + || repo.display().to_string(),
354 + |n| n.to_string_lossy().into()
355 + )
356 + ),
357 + None => "image builder (no checkout)".to_string(),
358 + }
359 + }
360 +
361 + fn hints(&self) -> Vec<Hint> {
362 + if self.editing.is_some() {
363 + return vec![hint("enter", "commit"), hint("esc", "cancel")];
364 + }
365 + if self.device.is_some() {
366 + return vec![hint("enter", "confirm device"), hint("esc", "cancel")];
367 + }
368 + if self.sequence.as_ref().is_some_and(|s| !s.is_done()) {
369 + return vec![hint("esc", "leave it running")];
370 + }
371 +
372 + let mut hints = vec![hint("j/k", "row")];
373 + match self.row() {
374 + Row::Lang(_) => hints.push(hint("space", "toggle")),
375 + Row::Hostname => hints.push(hint("enter", "edit")),
376 + // Both, on this row, and they do different things: one walks the
377 + // keys already on this disk, the other takes a path to one that is
378 + // not. Naming only the editor would hide the common case.
379 + Row::Pubkey => {
380 + if !self.candidates.is_empty() {
381 + hints.push(hint("h/l", "found keys"));
382 + }
383 + hints.push(hint("enter", "path"));
384 + }
385 + _ => hints.push(hint("h/l", "change")),
386 + }
387 + hints.push(hint("b", "build"));
388 + hints.push(hint("w", "write"));
389 + hints.push(hint("s", "save"));
390 + hints
391 + }
392 +
393 + fn unanswered(&self) -> &'static [Action] {
394 + &[Action::NextTab, Action::PrevTab]
395 + }
396 +
397 + fn text_entry(&self) -> bool {
398 + self.editing.is_some() || self.device.is_some()
399 + }
400 +
401 + fn status(&self) -> Option<(Severity, String)> {
402 + if let Some(error) = &self.error {
403 + return Some((Severity::Error, error.clone()));
404 + }
405 + if let Some(sequence) = &self.sequence {
406 + return match sequence.outcome() {
407 + None => Some((Severity::Warn, "building".to_string())),
408 + Some(Ok(())) => Some((Severity::Healthy, "built".to_string())),
409 + Some(Err(message)) => Some((Severity::Error, message.clone())),
410 + };
411 + }
412 + let blockers = self.choices.blockers(self.repo.as_deref());
413 + if let Some(blocker) = blockers.first() {
414 + return Some((Severity::Warn, blocker.clone()));
415 + }
416 + // Nothing wrong, so the footer answers the remaining question: are
417 + // these choices on disk. `b` saves before it builds, so an unsaved
418 + // form is only ever a form nobody has acted on yet — but "did that
419 + // take" is worth being able to see rather than infer.
420 + Some(if self.saved {
421 + (Severity::Healthy, format!("saved to {RECORD_LOCAL}"))
422 + } else {
423 + (Severity::Info, format!("s saves to {RECORD_LOCAL}"))
424 + })
425 + }
426 +
427 + fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
428 + let block = AlloyBlock::new(theme)
429 + .focused(true)
430 + .build()
431 + .title(block_title(&self.title()));
432 + let inner = block.inner(area);
433 + frame.render_widget(block, area);
434 +
435 + // A running or finished build takes the lower half. Before the first
436 + // build there is nothing to show there, so the form gets the screen.
437 + let [form_area, output_area] = if self.sequence.is_some() {
438 + Layout::vertical([Constraint::Length(10), Constraint::Min(0)]).areas(inner)
439 + } else {
440 + Layout::vertical([Constraint::Min(0), Constraint::Length(0)]).areas(inner)
441 + };
442 +
443 + // Labels are owned so the borrow does not outlive the loop; the widget
444 + // takes `&'a str`, and building them inline would drop each one at the
445 + // end of its own iteration.
446 + let labels: Vec<String> = Row::all().iter().map(|row| row.label()).collect();
447 +
448 + let rows: Vec<FormRow> = Row::all()
449 + .into_iter()
450 + .zip(&labels)
451 + .map(|(row, label)| {
452 + let field = match row {
453 + Row::Profile => alloy_tui::AlloyField::new(
454 + theme,
455 + label,
456 + FieldKind::Enum {
457 + label: self.choices.profile.label(),
458 + },
459 + ),
460 + Row::Browser => alloy_tui::AlloyField::new(
461 + theme,
462 + label,
463 + FieldKind::Enum {
464 + label: self.choices.browser.label(),
465 + },
466 + )
467 + // Dimmed rather than hidden on a server: a row that
468 + // vanishes reads as a bug, and the point is that the
469 + // choice exists and this profile has no use for it.
470 + .unset(self.choices.profile == Profile::Server),
471 + Row::Lang(lang) => alloy_tui::AlloyField::new(
472 + theme,
473 + label,
474 + FieldKind::Toggle(self.choices.langs.contains(&lang)),
475 + )
476 + .indent(true),
477 + Row::Trim => alloy_tui::AlloyField::new(
478 + theme,
479 + label,
480 + FieldKind::Enum {
481 + label: self.choices.trim.label(),
482 + },
483 + ),
484 + Row::Db => alloy_tui::AlloyField::new(
485 + theme,
486 + label,
487 + FieldKind::Enum {
488 + label: self.choices.db.label(),
489 + },
490 + ),
491 + Row::Artifact => alloy_tui::AlloyField::new(
492 + theme,
493 + label,
494 + FieldKind::Enum {
495 + label: self.choices.artifact.label(),
496 + },
497 + ),
498 + Row::Hostname => alloy_tui::AlloyField::new(
499 + theme,
500 + label,
Lines truncated
@@ -1,0 +1,92 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + /// The write goes through the script's own path, with `--write-only` so it
6 + /// writes the artifact that already exists rather than rebuilding it.
7 + /// Re-deriving this is the disk-eating bug the design note warns about.
8 + #[test]
9 + fn the_write_delegates_to_the_script_that_owns_the_guards() {
10 + let shown = ImageView {
11 + choices: Choices::default(),
12 + cursor: Cursor::new(),
13 + repo: None,
14 + candidates: Vec::new(),
15 + editing: None,
16 + sequence: None,
17 + pending_write: None,
18 + device: None,
19 + error: None,
20 + saved: false,
21 + }
22 + .write_command("/dev/sdX")
23 + .display();
24 +
25 + assert!(shown.starts_with("build/build-iso.sh"));
26 + assert!(shown.contains("--write-only"));
27 + assert!(shown.contains("--write /dev/sdX"));
28 + // Nothing resembling a dd, anywhere.
29 + assert!(!shown.contains("dd "));
30 + assert!(!shown.contains("of="));
31 + }
32 +
33 + /// A view with a known candidate list, so the cycling can be exercised
34 + /// without a `~/.ssh` to stand in front of it.
35 + fn view_with(candidates: &[&str]) -> ImageView {
36 + ImageView {
37 + choices: Choices::default(),
38 + cursor: Cursor::new(),
39 + repo: None,
40 + candidates: candidates.iter().map(|key| (*key).to_string()).collect(),
41 + editing: None,
42 + sequence: None,
43 + pending_write: None,
44 + device: None,
45 + error: None,
46 + saved: false,
47 + }
48 + }
49 +
50 + /// Empty is a position on the ring, not a state to escape. An image with no
51 + /// baked key is the ordinary desktop install, so it has to stay reachable
52 + /// once a key has been cycled onto the row.
53 + #[test]
54 + fn cycling_the_pubkey_row_passes_back_through_none() {
55 + let mut view = view_with(&["/home/max/.ssh/a.pub", "/home/max/.ssh/b.pub"]);
56 + assert_eq!(view.choices.pubkey, "");
57 +
58 + view.cycle_pubkey(true);
59 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
60 + view.cycle_pubkey(true);
61 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
62 + view.cycle_pubkey(true);
63 + assert_eq!(view.choices.pubkey, "", "the ring returns to no key");
64 +
65 + // And backwards, off none onto the last one.
66 + view.cycle_pubkey(false);
67 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
68 + }
69 +
70 + /// A path typed by hand is not one of the candidates, so it reads as the
71 + /// empty position rather than panicking on a lookup that finds nothing.
72 + #[test]
73 + fn a_typed_path_is_not_lost_to_an_index_it_never_had() {
74 + let mut view = view_with(&["/home/max/.ssh/a.pub"]);
75 + view.choices.pubkey = "/elsewhere/key.pub".to_string();
76 + view.cycle_pubkey(true);
77 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
78 + }
79 +
80 + /// Nothing to cycle says so, rather than silently doing nothing. An inert
81 + /// key reads as the form being broken.
82 + #[test]
83 + fn no_candidates_explains_itself() {
84 + let mut view = view_with(&[]);
85 + view.cycle_pubkey(true);
86 + assert_eq!(view.choices.pubkey, "");
87 + assert!(
88 + view.error.as_deref().is_some_and(|e| e.contains("~/.ssh")),
89 + "{:?}",
90 + view.error
91 + );
92 + }