Skip to main content

max / alloy

Skip the install questions the medium already answers
Author: Max Johnson <me@maxj.phd> · 2026-09-03 17:41 UTC
Signed with PGP, not checked
Commit: 8a1ff88ff45fb1d33d989a11d85c28cc3568497d
Parent: 44be4ec
3 files changed, +637 insertions, -24 deletions
@@ -74,6 +74,7 @@
74 74 use alloy_tui::{Cursor, FocusRing};
75 75
76 76 use crate::cli::{CommandLog, Invocation, Secret};
77 + use crate::preseed::{ANSWERS, Preseed};
77 78 use crate::profile::Profile;
78 79 use crate::recovery;
79 80 use crate::run::{Sequence, Stage};
@@ -530,6 +531,22 @@
530 531 pub encrypt: bool,
531 532 }
532 533
534 + /// Which of the answers came from the medium rather than from a person.
535 + ///
536 + /// Parallel to [`Answers`] and deliberately separate from it: `Answers` is what
537 + /// the install will do, and this is who decided. Nothing in the plan reads it.
538 + /// The review step does, because an answer nobody typed is the one most worth
539 + /// naming on a screen whose whole job is "confirm this before a disk is erased".
540 + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
541 + pub(crate) struct FromMedium {
542 + pub disk: bool,
543 + pub hostname: bool,
544 + pub username: bool,
545 + pub pubkey: bool,
546 + pub encrypt: bool,
547 + pub locate_timezone: bool,
548 + }
549 +
533 550 /// Where the installer mounts the target's root filesystem to configure it.
534 551 ///
535 552 /// A directory the installer creates, not one bootc provides. Upstream is
@@ -3226,12 +3243,35 @@
3226 3243 password: TextField,
3227 3244 confirm: TextField,
3228 3245 pubkey: TextField,
3229 - /// Whether the key field arrived seeded from the medium.
3246 + /// Which answers arrived from the medium rather than from a person.
3230 3247 ///
3231 - /// Only to say so on screen. The field is editable either way, and what
3248 + /// Only to say so on screen. Every seeded field stays editable, and what
3232 3249 /// gets planted is whatever it holds when the step is confirmed, so nothing
3233 - /// downstream reads this.
3234 - pubkey_from_medium: bool,
3250 + /// downstream reads this. The review step does: an answer nobody typed is
3251 + /// exactly the one worth naming, because the user is being asked to confirm
3252 + /// a decision that was made somewhere else.
3253 + from_medium: FromMedium,
3254 + /// The answer sheet the medium carries, if it carries one.
3255 + ///
3256 + /// Held rather than consumed at construction because the disk rule is
3257 + /// re-resolved against the disk list, which refreshes.
3258 + preseed: Preseed,
3259 + /// A sheet that is present and wrong, or a disk rule that named no single
3260 + /// disk.
3261 + ///
3262 + /// Shown on the step it failed to answer. Both are non-fatal by design: the
3263 + /// installer falls back to asking, and the one thing it must never do is
3264 + /// fall back silently, because a builder whose recipe did nothing would have
3265 + /// no way to find that out.
3266 + medium_note: Option<String>,
3267 + /// Which steps the user has stood on.
3268 + ///
3269 + /// A step answered by the medium is skipped only while nobody has visited
3270 + /// it. Stepping back into one hands it to the user, and it stops being
3271 + /// skipped, so a prefilled answer can always be corrected. Without this,
3272 + /// Esc from the account step would land on a step that immediately advanced
3273 + /// again and the user could never reach the thing they wanted to change.
3274 + visited: [bool; STEPS.len()],
3235 3275 /// Which of the two hostname-step slots has focus.
3236 3276 machine: FocusRing,
3237 3277 /// The checkbox, until the step is confirmed and it becomes an answer.
@@ -3331,10 +3371,36 @@
3331 3371
3332 3372 impl InstallView {
3333 3373 pub(crate) fn new(log: &mut CommandLog) -> Self {
3374 + // The medium's answer sheet, before anything is seeded from it. A sheet
3375 + // that is present and wrong becomes a note on screen rather than a
3376 + // refusal to install: see `Preseed::load`.
3377 + let (preseed, mut medium_note) = match Preseed::load(ANSWERS) {
3378 + Ok(sheet) => (sheet.unwrap_or_default(), None),
3379 + Err(message) => (Preseed::default(), Some(message)),
3380 + };
3381 + let mut from_medium = FromMedium::default();
3382 +
3334 3383 let mut hostname = TextField::new();
3335 3384 // Seeded rather than blank: the default is what most installs want, and
3336 3385 // a field arriving pre-filled says what shape of answer is expected.
3337 - hostname.set(DEFAULT_HOSTNAME);
3386 + //
3387 + // The sheet and `DEFAULT_HOSTNAME` are written by one Containerfile
3388 + // step from one build argument, so they agree by construction; the sheet
3389 + // is preferred anyway, because it is the file that says the question was
3390 + // answered rather than merely defaulted.
3391 + match &preseed.hostname {
3392 + Some(name) => {
3393 + hostname.set(name);
3394 + from_medium.hostname = true;
3395 + }
3396 + None => hostname.set(DEFAULT_HOSTNAME),
3397 + }
3398 +
3399 + let mut username = TextField::new();
3400 + if let Some(name) = &preseed.username {
3401 + username.set(name);
3402 + from_medium.username = true;
3403 + }
3338 3404
3339 3405 // Seeded from the medium for the same reason the hostname is seeded
3340 3406 // from its default, and for a sharper one besides. On the headless flow
@@ -3350,6 +3416,21 @@
3350 3416 let baked = baked_pubkey_at(BAKED_KEY);
3351 3417 if let Some(key) = &baked {
3352 3418 pubkey.set(key);
3419 + from_medium.pubkey = true;
3420 + }
3421 +
3422 + // The checkbox defaults, before the sheet has been near them. Both are
3423 + // the offered default rather than the derived one: Alloy encrypts unless
3424 + // told not to, and it looks nothing up over the network unless asked.
3425 + let mut encrypt = true;
3426 + if let Some(answer) = preseed.encrypt {
3427 + encrypt = answer;
3428 + from_medium.encrypt = true;
3429 + }
3430 + let mut locate_timezone = false;
3431 + if let Some(answer) = preseed.locate_timezone {
3432 + locate_timezone = answer;
3433 + from_medium.locate_timezone = true;
3353 3434 }
3354 3435
3355 3436 let mut view = Self {
@@ -3358,13 +3439,16 @@
3358 3439 disks: Vec::new(),
3359 3440 cursor: Cursor::new(),
3360 3441 hostname,
3361 - username: TextField::new(),
3442 + username,
3362 3443 password: TextField::new(),
3363 3444 confirm: TextField::new(),
3364 3445 pubkey,
3365 - pubkey_from_medium: baked.is_some(),
3446 + from_medium,
3447 + preseed,
3448 + medium_note: medium_note.take(),
3449 + visited: [false; STEPS.len()],
3366 3450 machine: FocusRing::new(HOSTNAME_SLOTS),
3367 - locate_timezone: false,
3451 + locate_timezone,
3368 3452 fields: FocusRing::new(ACCOUNT_FIELDS),
3369 3453 passphrase: TextField::new(),
3370 3454 passphrase_confirm: TextField::new(),
@@ -3374,7 +3458,7 @@
3374 3458 recovery_ack: false,
3375 3459 reboot_pending: false,
3376 3460 disk_intact: Arc::new(AtomicBool::new(false)),
3377 - encrypt: true,
3461 + encrypt,
3378 3462 secure_boot: secure_boot(),
3379 3463 profile: Profile::current(),
3380 3464 credits_scroll: 0,
@@ -3387,6 +3471,10 @@
3387 3471 blink: 0,
3388 3472 };
3389 3473 view.refresh(log);
3474 + // The disk list has to exist before a disk rule can be resolved against
3475 + // it, and the first step is the disk, so the skip runs here rather than
3476 + // in the initialiser above.
3477 + view.skip_answered();
3390 3478 view
3391 3479 }
3392 3480
@@ -3395,6 +3483,104 @@
3395 3483 STEPS[self.steps.current()]
3396 3484 }
3397 3485
3486 + /// Move to the next question, then past any the medium already answered.
3487 + ///
3488 + /// Every step's confirm goes through here rather than calling
3489 + /// [`Steps::advance`] directly, so the skip cannot be forgotten on a step
3490 + /// added later.
3491 + fn advance(&mut self) {
3492 + self.steps.advance();
3493 + self.skip_answered();
3494 + }
3495 +
3496 + /// Walk forward over steps the medium answers in full.
3497 + ///
3498 + /// Forward only, and never over a step the user has stood on. Those two
3499 + /// rules are what keep a prefilled answer correctable: Esc walks back into
3500 + /// a skipped step, which marks it visited, and from then on it is an
3501 + /// ordinary question.
3502 + ///
3503 + /// A step that is only partly answered is not skipped. The account step is
3504 + /// the permanent example: the medium may carry the username and the key,
3505 + /// and it never carries the password, so the step is shown with one field
3506 + /// left to fill.
3507 + fn skip_answered(&mut self) {
3508 + while !self.visited[self.steps.current()] && self.answer_from_medium(self.step()) {
3509 + if !self.steps.advance() {
3510 + break;
3511 + }
3512 + }
3513 + }
3514 +
3515 + /// Take the medium's answer for one step, and say whether it answered it
3516 + /// in full.
3517 + ///
3518 + /// Writing into [`Answers`] here rather than at the step's own confirm is
3519 + /// what makes a skipped step still produce an answer. The summary then reads
3520 + /// the same fields whichever way they were filled, and marks the ones nobody
3521 + /// typed.
3522 + fn answer_from_medium(&mut self, step: Step) -> bool {
3523 + match step {
3524 + Step::Disk => {
3525 + let Some(rule) = self.preseed.disk else {
3526 + return false;
3527 + };
3528 + match rule.resolve(&self.disks) {
3529 + Ok(path) => {
3530 + self.answers.disk = Some(path);
3531 + self.from_medium.disk = true;
3532 + true
3533 + }
3534 + Err(failure) => {
3535 + // Falling back to asking, and saying why. A recipe that
3536 + // describes a machine this is not must never resolve to
3537 + // "some disk"; it must resolve to a question.
3538 + self.medium_note = Some(failure.reason(rule));
3539 + false
3540 + }
3541 + }
3542 + }
3543 + Step::Hostname => {
3544 + // Both of the step's answers or neither. The timezone checkbox
3545 + // shares this screen, and skipping past an unanswered checkbox
3546 + // would decide it by default while looking like the recipe had
3547 + // decided it.
3548 + let (Some(name), Some(locate)) =
3549 + (self.preseed.hostname.clone(), self.preseed.locate_timezone)
3550 + else {
3551 + return false;
3552 + };
3553 + if let Err(message) = validate_hostname(&name) {
3554 + self.medium_note =
3555 + Some(format!("the medium's hostname is unusable: {message}"));
3556 + return false;
3557 + }
3558 + self.answers.hostname = Some(name);
3559 + self.answers.locate_timezone = locate;
3560 + true
3561 + }
3562 + // Never skipped: the password is a secret and a secret is never on
3563 + // the medium. The username and the key arrive seeded, so what is
3564 + // left is the one field only a person can supply.
3565 + Step::Account => false,
3566 + Step::Encryption => {
3567 + // `encrypt = false` answers the whole step, because an
3568 + // unencrypted install has no second question. `encrypt = true`
3569 + // answers only the checkbox: the passphrase is a secret, so the
3570 + // step is shown with the decision already made and the secret
3571 + // still to type.
3572 + matches!(self.preseed.encrypt, Some(false)) && {
3573 + self.answers.encrypt = false;
3574 + true
3575 + }
3576 + }
3577 + // Not questions. The review exists to be read before a disk is
3578 + // erased, and skipping it would be the one prefill that removes a
3579 + // safeguard rather than a keystroke.
3580 + Step::Summary | Step::Credits => false,
3581 + }
3582 + }
3583 +
3398 3584 fn refresh(&mut self, log: &mut CommandLog) {
3399 3585 match self.backend.list(log) {
3400 3586 Ok(disks) => {
@@ -3430,7 +3616,7 @@
3430 3616
3431 3617 self.answers.disk = Some(disk.path.clone());
3432 3618 self.error = None;
3433 - self.steps.advance();
3619 + self.advance();
3434 3620 Flow::Continue
3435 3621 }
3436 3622
@@ -3445,7 +3631,7 @@
3445 3631 self.answers.hostname = Some(self.hostname.value().to_string());
3446 3632 self.answers.locate_timezone = self.locate_timezone;
3447 3633 self.error = None;
3448 - self.steps.advance();
3634 + self.advance();
3449 3635 }
3450 3636 Err(message) => self.error = Some(message),
3451 3637 }
@@ -3538,7 +3724,7 @@
3538 3724 let key = self.pubkey.value().trim();
3539 3725 self.answers.pubkey = (!key.is_empty()).then(|| key.to_string());
3540 3726 self.error = None;
3541 - self.steps.advance();
3727 + self.advance();
3542 3728 Flow::Continue
3543 3729 }
3544 3730
@@ -3601,7 +3787,7 @@
3601 3787
3602 3788 self.answers.encrypt = self.encrypt;
3603 3789 self.error = None;
3604 - self.steps.advance();
3790 + self.advance();
3605 3791 Flow::Continue
3606 3792 }
3607 3793
@@ -3796,7 +3982,7 @@
3796 3982 "or later from the console. A machine with no console is a",
3797 3983 "machine nobody can reach.",
3798 3984 ]
3799 - } else if self.pubkey_from_medium {
3985 + } else if self.from_medium.pubkey {
3800 3986 // Said because a field that filled itself in is a field people
3801 3987 // scroll past. Where it came from is also the check worth making:
3802 3988 // if this is not the key you expect, the medium is not the one you
@@ -4014,6 +4200,20 @@
4014 4200 ])
4015 4201 }
4016 4202
4203 + /// Mark a row whose answer nobody typed.
4204 + ///
4205 + /// This screen exists to be checked, and a row that was answered
4206 + /// somewhere else is the one a person cannot check from memory. Said on
4207 + /// every such row rather than once at the top, because a summary with a
4208 + /// banner still leaves the reader working out which lines it applies to.
4209 + fn sourced(note: String, from_medium: bool) -> String {
4210 + if from_medium {
4211 + format!("{note} from the medium")
4212 + } else {
4213 + note
4214 + }
4215 + }
4216 +
4017 4217 let disk = self.answers.disk.as_deref().unwrap_or("-");
4018 4218 let detail = match self.disks.iter().find(|d| d.path == disk) {
4019 4219 Some(d) => format!(" {} {}", format_size(d.size), d.model_or_dash()),
@@ -4021,25 +4221,30 @@
4021 4221 };
4022 4222
4023 4223 let mut lines = vec![
4024 - row(theme, "disk", disk, detail),
4224 + row(theme, "disk", disk, sourced(detail, self.from_medium.disk)),
4025 4225 row(
4026 4226 theme,
4027 4227 "hostname",
4028 4228 self.answers.hostname.as_deref().unwrap_or("-"),
4029 - String::new(),
4229 + sourced(String::new(), self.from_medium.hostname),
4030 4230 ),
4031 4231 row(
4032 4232 theme,
4033 4233 "account",
4034 4234 self.answers.username.as_deref().unwrap_or("-"),
4035 - String::new(),
4235 + sourced(String::new(), self.from_medium.username),
4036 4236 ),
4037 4237 // Same rule as the timezone below: named whichever way it went. The
4038 4238 // no-key case is the one worth a review screen, since it decides
4039 4239 // whether this machine is reachable at all, so it says what it means
4040 4240 // rather than printing a dash.
4041 4241 match self.answers.pubkey.as_deref() {
4042 - Some(key) => row(theme, "ssh key", &pubkey_summary(key), String::new()),
4242 + Some(key) => row(
4243 + theme,
4244 + "ssh key",
4245 + &pubkey_summary(key),
4246 + sourced(String::new(), self.from_medium.pubkey),
4247 + ),
4043 4248 None => row(
4044 4249 theme,
4045 4250 "ssh key",
@@ -4056,14 +4261,20 @@
4056 4261 theme,
4057 4262 "encryption",
4058 4263 "on",
4059 - " TPM, passphrase, recovery phrase".into(),
4264 + sourced(
4265 + " TPM, passphrase, recovery phrase".into(),
4266 + self.from_medium.encrypt,
4267 + ),
4060 4268 )
4061 4269 } else {
4062 4270 row(
4063 4271 theme,
4064 4272 "encryption",
4065 4273 "off",
4066 - " cannot be added without reinstalling".into(),
4274 + sourced(
4275 + " cannot be added without reinstalling".into(),
4276 + self.from_medium.encrypt,
4277 + ),
4067 4278 )
4068 4279 },
4069 4280 // Named on the summary whichever way it went. "UTC" is a decision
@@ -4075,14 +4286,20 @@
4075 4286 theme,
4076 4287 "timezone",
4077 4288 "from location",
4078 - format!(" asks {GEO_HOST}"),
4289 + sourced(
4290 + format!(" asks {GEO_HOST}"),
4291 + self.from_medium.locate_timezone,
4292 + ),
4079 4293 )
4080 4294 } else {
4081 4295 row(
4082 4296 theme,
4083 4297 "timezone",
4084 4298 "UTC",
4085 - " change it with `alloy settings`".into(),
4299 + sourced(
4300 + " change it with `alloy settings`".into(),
4301 + self.from_medium.locate_timezone,
4302 + ),
4086 4303 )
4087 4304 },
4088 4305 ];
@@ -4793,6 +5010,15 @@
4793 5010 if let Some(message) = &self.error {
4794 5011 return Some((Severity::Error, message.clone()));
4795 5012 }
5013 + // Below an error and above the target, because it explains why a
5014 + // question is being asked rather than reporting something the user did.
5015 + // A medium whose answers did not apply has to say so: an installer that
5016 + // silently asked everything would look exactly like one that was never
5017 + // given a recipe, and the builder would have no way to tell the
5018 + // difference from here.
5019 + if let Some(note) = &self.medium_note {
5020 + return Some((Severity::Warn, note.clone()));
5021 + }
4796 5022 self.answers
4797 5023 .disk
4798 5024 .as_ref()
@@ -4879,7 +5105,7 @@
4879 5105 // screen apart is the whole of what moved here.
4880 5106 Step::Summary => {
4881 5107 if key.code == KeyCode::Enter {
4882 - self.steps.advance();
5108 + self.advance();
4883 5109 }
4884 5110 return Flow::Continue;
4885 5111 }
@@ -5031,6 +5257,10 @@
5031 5257 };
5032 5258 }
5033 5259 if self.steps.back() {
5260 + // Stepping into a skipped step hands it to the user. Without this
5261 + // the skip would fire again on the way forward and the answer could
5262 + // never be corrected.
5263 + self.visited[self.steps.current()] = true;
5034 5264 self.error = None;
5035 5265 Flow::Continue
5036 5266 } else {
@@ -5338,7 +5568,10 @@
5338 5568 password: TextField::new(),
5339 5569 confirm: TextField::new(),
5340 5570 pubkey: TextField::new(),
5341 - pubkey_from_medium: false,
5571 + from_medium: FromMedium::default(),
5572 + preseed: Preseed::default(),
5573 + medium_note: None,
5574 + visited: [false; STEPS.len()],
5342 5575 machine: FocusRing::new(HOSTNAME_SLOTS),
5343 5576 locate_timezone: false,
5344 5577 fields: FocusRing::new(ACCOUNT_FIELDS),
@@ -5367,6 +5600,210 @@
5367 5600 (view, CommandLog::new())
5368 5601 }
5369 5602
5603 + use crate::preseed::DiskRule;
5604 +
5605 + /// A view whose disks and answer sheet are both the test's own.
5606 + ///
5607 + /// The shared fixture's only internal disk is mounted, which is correct for
5608 + /// what that fixture is for and useless here: every rule below would resolve
5609 + /// to "no match" and the skips would never fire.
5610 + fn preseeded(sheet: Preseed, disks: Vec<Disk>) -> InstallView {
5611 + let (mut view, _log) = view();
5612 + view.disks = disks;
5613 + view.cursor.resize(view.disks.len());
5614 + if let Some(name) = &sheet.hostname {
5615 + view.hostname.set(name);
5616 + view.from_medium.hostname = true;
5617 + }
5618 + if let Some(name) = &sheet.username {
5619 + view.username.set(name);
5620 + view.from_medium.username = true;
5621 + }
5622 + if let Some(answer) = sheet.encrypt {
5623 + view.encrypt = answer;
5624 + view.from_medium.encrypt = true;
5625 + }
5626 + if let Some(answer) = sheet.locate_timezone {
5627 + view.locate_timezone = answer;
5628 + view.from_medium.locate_timezone = true;
5629 + }
5630 + view.preseed = sheet;
5631 + view.skip_answered();
5632 + view
5633 + }
5634 +
5635 + fn target(name: &str, transport: Option<&str>) -> Disk {
5636 + Disk {
5637 + path: format!("/dev/{name}"),
5638 + name: name.to_string(),
5639 + size: 2_048_408_248_320,
5640 + model: Some("WD_BLACK SN7100 2TB".into()),
5641 + removable: false,
5642 + transport: transport.map(str::to_string),
5643 + read_only: false,
5644 + mountpoints: Vec::new(),
5645 + }
5646 + }
5647 +
5648 + fn fw12_sheet() -> Preseed {
5649 + Preseed {
5650 + hostname: Some("fw12".into()),
Lines truncated
@@ -22,6 +22,7 @@
22 22 mod net;
23 23 mod pkg;
24 24 mod polkit;
25 + mod preseed;
25 26 mod profile;
26 27 mod recovery;
27 28 mod run;
@@ -1,0 +1,331 @@
1 + //! The answers a minted medium already carries.
2 + //!
3 + //! `alloy install` asks five questions, and on a machine the builder already
4 + //! knows about, most of them have one right answer that was decided when the
5 + //! image was minted. This is that answer sheet: a file the Containerfile writes
6 + //! from the per-host recipe (`build/hosts/<name>.env`), read once when the
7 + //! wizard opens, and used to skip the steps it can answer in full.
8 + //!
9 + //! Alloy is distributed as a builder rather than as an image (wiki
10 + //! `alloy-distribution`), so the person minting the medium is the person who
11 + //! will install from it. Their recipe is their answer sheet, which is why
12 + //! prefilling belongs here and would not belong in a distro that ships one ISO
13 + //! to strangers.
14 + //!
15 + //! # What may be in this file, and what may never be
16 + //!
17 + //! **No secrets.** Not the account password, not the LUKS passphrase. The
18 + //! Containerfile's identity step states the invariant this preserves: the image
19 + //! "can be kept, copied or rebuilt without care and a leak of it costs
20 + //! nothing". A passphrase written here would be in every layer cache, every
21 + //! `podman save`, and on every stick written from the medium. So the account
22 + //! and encryption steps are still asked, with everything except the secret
23 + //! already filled in.
24 + //!
25 + //! **The disk is a rule, not a path.** A medium that erases `/dev/nvme0n1`
26 + //! without asking is one plugged into the wrong laptop away from erasing the
27 + //! wrong machine. [`DiskRule`] describes the shape of the target instead, and
28 + //! resolving it is allowed to fail: no match and several matches both fall back
29 + //! to asking, which is the behaviour that has to survive every later change
30 + //! here.
31 + //!
32 + //! # Where the answers come from
33 + //!
34 + //! One file, `/usr/lib/alloy/answers.toml`, beside the baked
35 + //! `authorized_keys` that already works this way. A second source read from the
36 + //! medium at boot is the obvious way to supply the two secrets later without
37 + //! putting them in the image, and nothing here forecloses it: parsing is
38 + //! separate from where the bytes came from.
39 +
40 + use serde::Deserialize;
41 +
42 + use crate::install::Disk;
43 +
44 + /// Where a minted medium carries its answer sheet.
45 + ///
46 + /// Beside `/usr/lib/alloy/authorized_keys`, written by the same Containerfile
47 + /// step from the same recipe, because they are the same idea: what the builder
48 + /// knew, carried on the medium so the installer does not have to ask again.
49 + pub(crate) const ANSWERS: &str = "/usr/lib/alloy/answers.toml";
50 +
51 + /// The answers a medium was minted with.
52 + ///
53 + /// Every field is optional and an absent one means "ask". That is what makes a
54 + /// stock mint, which carries no answer sheet at all, indistinguishable from one
55 + /// whose sheet answers nothing: both ask all five questions.
56 + #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
57 + #[serde(deny_unknown_fields)]
58 + pub(crate) struct Preseed {
59 + /// The machine's name, which is also baked into `DEFAULT_HOSTNAME`.
60 + ///
61 + /// Written twice by one Containerfile step from one build argument, so the
62 + /// two cannot drift: os-release is the installer's default and the fallback
63 + /// for a machine with no static hostname, and this is the statement that
64 + /// the question is answered. The build asserts they agree.
65 + pub hostname: Option<String>,
66 + /// The account to create. The password is deliberately absent; see the
67 + /// module header.
68 + pub username: Option<String>,
69 + /// Which disk to install to, as a shape rather than a device path.
70 + pub disk: Option<DiskRule>,
71 + /// Whether to encrypt. The passphrase is deliberately absent, so `true`
72 + /// answers the checkbox and still asks for the secret, while `false`
73 + /// answers the whole step.
74 + pub encrypt: Option<bool>,
75 + /// Whether the install may ask the network where this machine is.
76 + ///
77 + /// Spelled out rather than defaulted, because leaving it unset is what
78 + /// keeps the hostname step on screen: this and the name are that step's two
79 + /// answers, and a step is skipped only when it is answered in full.
80 + pub locate_timezone: Option<bool>,
81 + }
82 +
83 + /// Which disk a recipe means, described by shape.
84 + ///
85 + /// Deliberately a tiny closed vocabulary rather than a pattern language. Every
86 + /// variant has to be a sentence a person can check against a machine they are
87 + /// holding, because the failure this exists to prevent is a medium that erases
88 + /// the wrong disk while looking like it worked.
89 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
90 + #[serde(rename_all = "kebab-case")]
91 + pub(crate) enum DiskRule {
92 + /// The one disk that is not removable and not the medium.
93 + ///
94 + /// For a machine with a single soldered or single-socket drive, which is
95 + /// every laptop in this tree.
96 + SingleInternal,
97 + /// The same, narrowed to NVMe.
98 + ///
99 + /// Worth having separately because it fails closed on a machine whose only
100 + /// internal disk is a SATA drive somebody swapped in: the rule stops
101 + /// matching and the installer asks, rather than quietly taking a disk the
102 + /// recipe was not written for.
103 + SingleInternalNvme,
104 + }
105 +
106 + /// Why a rule did not produce a target.
107 + ///
108 + /// Both variants fall back to asking. They are distinguished because the
109 + /// sentence shown to the user differs, and because "your recipe describes no
110 + /// disk in this machine" and "it describes two" are different mistakes.
111 + #[derive(Debug, Clone, PartialEq, Eq)]
112 + pub(crate) enum RuleFailure {
113 + /// Nothing in the machine matches.
114 + NoMatch,
115 + /// More than one does, so the rule does not name a single disk.
116 + Ambiguous(usize),
117 + }
118 +
119 + impl RuleFailure {
120 + /// The line shown on the disk step when the rule did not resolve.
121 + ///
122 + /// Phrased as a statement about the machine rather than about the file,
123 + /// because the person reading it is standing in front of the machine and
124 + /// the file was written somewhere else.
125 + pub(crate) fn reason(&self, rule: DiskRule) -> String {
126 + match self {
127 + Self::NoMatch => format!(
128 + "the medium expected {}, and this machine has none; choose the disk",
129 + rule.describe()
130 + ),
131 + Self::Ambiguous(n) => format!(
132 + "the medium expected {}, and this machine has {n}; choose the disk",
133 + rule.describe()
134 + ),
135 + }
136 + }
137 + }
138 +
139 + impl DiskRule {
140 + /// The rule as a sentence, for the summary and for a refusal.
141 + pub(crate) const fn describe(self) -> &'static str {
142 + match self {
143 + Self::SingleInternal => "one internal disk",
144 + Self::SingleInternalNvme => "one internal NVMe disk",
145 + }
146 + }
147 +
148 + /// Whether one disk is the kind this rule is about.
149 + ///
150 + /// Removable is excluded before anything else: on a live install the stick
151 + /// being booted from is attached, listed, and exactly the disk that must
152 + /// not be chosen. A blocked disk is excluded too, which covers the medium
153 + /// again by a second route (it is mounted) and covers a read-only device.
154 + fn admits(self, disk: &Disk) -> bool {
155 + if disk.removable || disk.blocker().is_some() {
156 + return false;
157 + }
158 + match self {
159 + Self::SingleInternal => true,
160 + Self::SingleInternalNvme => disk.transport.as_deref() == Some("nvme"),
161 + }
162 + }
163 +
164 + /// The one disk this rule names, or why it named none.
165 + ///
166 + /// Returns a device path rather than a reference so the caller can hold it
167 + /// without borrowing the disk list, which the view refreshes.
168 + pub(crate) fn resolve(self, disks: &[Disk]) -> Result<String, RuleFailure> {
169 + let mut matched = disks.iter().filter(|d| self.admits(d));
170 + let Some(first) = matched.next() else {
171 + return Err(RuleFailure::NoMatch);
172 + };
173 + let extra = matched.count();
174 + if extra > 0 {
175 + return Err(RuleFailure::Ambiguous(extra + 1));
176 + }
177 + Ok(first.path.clone())
178 + }
179 + }
180 +
181 + impl Preseed {
182 + /// Read the sheet a medium carries.
183 + ///
184 + /// Three outcomes, and the third is the reason this is not a plain
185 + /// `Option`. `Ok(None)` is a medium with no sheet, which is the ordinary
186 + /// desktop mint. `Ok(Some)` is a sheet that parsed. `Err` is a sheet that
187 + /// is there and wrong, which must be said out loud: silently asking all
188 + /// five questions would look identical to a medium that was never given
189 + /// answers, and the builder would have no way to tell that their recipe did
190 + /// nothing.
191 + ///
192 + /// A parse failure is not fatal. The installer still runs and still asks;
193 + /// what it must not do is pretend the file was not there.
194 + pub(crate) fn load(path: &str) -> Result<Option<Self>, String> {
195 + let contents = match std::fs::read_to_string(path) {
196 + Ok(contents) => contents,
197 + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
198 + Err(err) => return Err(format!("{path} could not be read: {err}")),
199 + };
200 + toml::from_str(&contents)
201 + .map(Some)
202 + .map_err(|err| format!("{path} is not a valid answer sheet: {err}"))
203 + }
204 + }
205 +
206 + #[cfg(test)]
207 + mod tests {
208 + use super::*;
209 +
210 + fn disk(name: &str, transport: Option<&str>, removable: bool) -> Disk {
211 + Disk {
212 + path: format!("/dev/{name}"),
213 + name: name.to_string(),
214 + size: 512_000_000_000,
215 + model: None,
216 + removable,
217 + transport: transport.map(str::to_string),
218 + read_only: false,
219 + mountpoints: Vec::new(),
220 + }
221 + }
222 +
223 + #[test]
224 + fn an_absent_sheet_is_not_an_error() {
225 + assert_eq!(Preseed::load("/nonexistent/answers.toml"), Ok(None));
226 + }
227 +
228 + #[test]
229 + fn a_sheet_that_is_there_and_wrong_is_reported() {
230 + let dir = std::env::temp_dir().join(format!("alloy-preseed-{}", std::process::id()));
231 + std::fs::create_dir_all(&dir).unwrap();
232 + let path = dir.join("bad.toml");
233 + std::fs::write(&path, "username = [1, 2]\n").unwrap();
234 + let err = Preseed::load(path.to_str().unwrap()).unwrap_err();
235 + assert!(err.contains("not a valid answer sheet"), "{err}");
236 + std::fs::remove_dir_all(&dir).ok();
237 + }
238 +
239 + #[test]
240 + fn a_misspelled_key_is_refused_rather_than_ignored() {
241 + // The whole point of the sheet is that the builder's intent reaches the
242 + // machine. A key that is silently dropped is the failure mode this
243 + // feature exists to remove, so `deny_unknown_fields` earns its keep.
244 + let err = toml::from_str::<Preseed>("user_name = \"max\"\n").unwrap_err();
245 + assert!(err.to_string().contains("user_name"), "{err}");
246 + }
247 +
248 + #[test]
249 + fn the_nvme_rule_takes_the_only_internal_nvme() {
250 + let disks = vec![
251 + disk("nvme0n1", Some("nvme"), false),
252 + disk("sdb", Some("usb"), true),
253 + ];
254 + assert_eq!(
255 + DiskRule::SingleInternalNvme.resolve(&disks),
256 + Ok("/dev/nvme0n1".to_string())
257 + );
258 + }
259 +
260 + #[test]
261 + fn two_internal_disks_are_ambiguous_rather_than_a_guess() {
262 + let disks = vec![
263 + disk("nvme0n1", Some("nvme"), false),
264 + disk("nvme1n1", Some("nvme"), false),
265 + ];
266 + assert_eq!(
267 + DiskRule::SingleInternalNvme.resolve(&disks),
268 + Err(RuleFailure::Ambiguous(2))
269 + );
270 + }
271 +
272 + #[test]
273 + fn a_sata_only_machine_does_not_match_the_nvme_rule() {
274 + let disks = vec![disk("sda", Some("sata"), false)];
275 + assert_eq!(
276 + DiskRule::SingleInternalNvme.resolve(&disks),
277 + Err(RuleFailure::NoMatch)
278 + );
279 + assert_eq!(
280 + DiskRule::SingleInternal.resolve(&disks),
281 + Ok("/dev/sda".to_string())
282 + );
283 + }
284 +
285 + #[test]
286 + fn the_medium_itself_is_never_a_match() {
287 + // Removable, and mounted, which is the live stick. Both exclusions are
288 + // exercised here because either one alone would let it through on a
289 + // machine where the other did not apply.
290 + let mut stick = disk("sdb", Some("usb"), true);
291 + stick.mountpoints = vec!["/run/media/live".into()];
292 + let disks = vec![stick];
293 + assert_eq!(
294 + DiskRule::SingleInternal.resolve(&disks),
295 + Err(RuleFailure::NoMatch)
296 + );
297 + }
298 +
299 + #[test]
300 + fn a_mounted_internal_disk_is_not_a_target() {
301 + // The running system on an install-from-installed-machine attempt.
302 + let mut running = disk("nvme0n1", Some("nvme"), false);
303 + running.mountpoints = vec!["/".into()];
304 + assert_eq!(
305 + DiskRule::SingleInternal.resolve(&[running]),
306 + Err(RuleFailure::NoMatch)
307 + );
308 + }
309 +
310 + #[test]
311 + fn a_full_sheet_parses() {
312 + let sheet: Preseed = toml::from_str(
313 + "hostname = \"fw12\"\n\
314 + username = \"max\"\n\
315 + disk = \"single-internal-nvme\"\n\
316 + encrypt = true\n\
317 + locate_timezone = false\n",
318 + )
319 + .unwrap();
320 + assert_eq!(
321 + sheet,
322 + Preseed {
323 + hostname: Some("fw12".into()),
324 + username: Some("max".into()),
325 + disk: Some(DiskRule::SingleInternalNvme),
326 + encrypt: Some(true),
327 + locate_timezone: Some(false),
328 + }
329 + );
330 + }
331 + }