Skip to main content

max / alloy

Give the install a commit point and a duty past it The installer assumed that a failure means no machine. That stops being true the moment `bootc install to-disk` returns, and four shipped bugs share the shape: each left a machine that existed and was wrong, and each was found by a person booting the result. The one that prompted this: bootc finishes a good install, then fails to close the LUKS device it opened, because udev on the live medium re-probes the filesystem it just wrote and holds the mapper. bootc reports that teardown failure as the exit status of the install. Alloy took it as one and abandoned the run, leaving a disk with no account and a volume only its TPM could open, while the recovery phrase on screen enrolled nothing. cryptsetup exit 5 on a close means the mapper existed and was held: a close against an absent mapper is 4, an open but unheld one is 0. So the device was still open, which is what makes repairing forward possible. - Stage::Commit marks the boundary. Failure before it owes nothing; failure after it owes the disk an answer. - The deploy's exit status becomes one input to a verdict the stages behind it reach, rather than the verdict. A deploy is successful when the disk carries a root partition, a mountable writable filesystem and an ostree deployment. A run that carries on past a failing deploy with nothing to check it still fails. - recover_plan repairs forward by enrolling the slots bootc wipes, and wipes the LUKS signature if the header still has no slot but the TPM's. Which branch runs is decided by reading the header. - acceptance_plan asserts the four things the four bugs each violated: an account exists, the origin names a fetchable image, a greeter is enabled, and a non-TPM slot opens the volume. - recovery_pending follows the disk rather than the run, so a failure that left an openable volume still asks for the phrase.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 16:58 UTC
Signed with PGP, not checked
Commit: 13111674f55a0a600e390ca1e6dfc28f278871af
Parent: a9b496e
2 files changed, +831 insertions, -24 deletions
@@ -57,6 +57,8 @@
57 57 //!
58 58 //! <!-- wiki: alloy-console -->
59 59
60 + use std::sync::Arc;
61 + use std::sync::atomic::{AtomicBool, Ordering};
60 62 use std::time::Duration;
61 63
62 64 use alloy_tui::{AlloyBlock, AlloyList, Hint, Severity, Theme, hint, text};
@@ -1160,6 +1162,309 @@
1160 1162 vec![enroll(passphrase), enroll(recovery)]
1161 1163 }
1162 1164
1165 + /// Ask a LUKS header what is in it.
1166 + fn luks_dump(partition: &str) -> Invocation {
1167 + Invocation::new("cryptsetup").arg("luksDump").arg(partition)
1168 + }
1169 +
1170 + /// Remove the LUKS signature from `partition`.
1171 + ///
1172 + /// The honest-wreck branch of [`recover_plan`]. What it leaves is a partition
1173 + /// nothing recognizes as anything, which is the point: an install that failed
1174 + /// should leave a disk that is obviously unusable rather than one that is
1175 + /// subtly unusable. A volume whose only key is a TPM slot, on a machine with no
1176 + /// account, looks like a working install right up until the day the TPM stops
1177 + /// answering, and by then the eight words that would have opened it are long
1178 + /// thrown away.
1179 + ///
1180 + /// `wipefs` rather than `cryptsetup luksErase`: erasing the keyslots leaves a
1181 + /// header that still says LUKS, so the disk still presents as an encrypted
1182 + /// volume, and "encrypted volume that refuses every key" is exactly the subtle
1183 + /// wreck this is here to avoid. Removing the signature makes the partition read
1184 + /// as empty to everything that looks at it.
1185 + ///
1186 + /// The data under it is not recoverable either way. It never was: the key was
1187 + /// in the keyslots this destroys, and the user consented to erasing this disk
1188 + /// before the install began.
1189 + fn wipe_luks_header(partition: &str) -> Invocation {
1190 + Invocation::new("wipefs").arg("--all").arg(partition)
1191 + }
1192 +
1193 + /// Whether the header in `dump` has a keyslot that is not the TPM's.
1194 + ///
1195 + /// The invariant this reads: **no LUKS volume is left reachable only by a TPM
1196 + /// slot.** A TPM answers only on the machine it is bound to, only while that
1197 + /// machine's firmware measurements are unchanged, and never after it is
1198 + /// cleared. A volume with one TPM slot and nothing else is one board failure
1199 + /// away from being unreadable by anyone, including its owner.
1200 + ///
1201 + /// Read from the header rather than counted, because the number of slots is a
1202 + /// fact about today's bootc. `--wipe-slot=all --tpm2-device=auto` happens to
1203 + /// leave exactly one, so "more than one slot" is true whenever this is true,
1204 + /// and it stops being a way to ask the question the day bootc enrolls a second
1205 + /// one for its own reasons.
1206 + ///
1207 + /// Anything unparseable answers false. This decides whether to destroy a
1208 + /// header, so the failure it must not have is a confident yes.
1209 + fn non_tpm_slot_exists(dump: &str) -> bool {
1210 + let mut section = "";
1211 + let mut entry = "";
1212 + let mut slots: Vec<&str> = Vec::new();
1213 + let mut tpm_slots: Vec<&str> = Vec::new();
1214 +
1215 + for line in dump.lines() {
1216 + // Sections sit hard against the left margin: "Keyslots:", "Tokens:".
1217 + if !line.starts_with([' ', '\t']) {
1218 + section = line.trim_end().strip_suffix(':').unwrap_or("");
1219 + entry = "";
1220 + continue;
1221 + }
1222 +
1223 + // Entries are indented by spaces and numbered: " 0: luks2",
1224 + // " 0: systemd-tpm2". Their fields are indented by a tab.
1225 + let trimmed = line.trim();
1226 + if !line.starts_with('\t')
1227 + && let Some((id, kind)) = trimmed.split_once(": ")
1228 + && id.chars().all(|c| c.is_ascii_digit())
1229 + {
1230 + entry = kind.trim();
1231 + if section == "Keyslots" {
1232 + slots.push(id);
1233 + }
1234 + continue;
1235 + }
1236 +
1237 + // A token names the keyslot it unlocks, which is how a slot is known to
1238 + // be the TPM's rather than a person's.
1239 + if section == "Tokens"
1240 + && entry == "systemd-tpm2"
1241 + && let Some(slot) = trimmed.strip_prefix("Keyslot:")
1242 + {
1243 + tpm_slots.push(slot.trim());
1244 + }
1245 + }
1246 +
1247 + slots.iter().any(|slot| !tpm_slots.contains(slot))
1248 + }
1249 +
1250 + /// The unit that presents a login on a client-profile machine.
1251 + const GREETER_UNIT: &str = "greetd.service";
1252 +
1253 + /// The checks that decide whether what was built is a machine.
1254 + ///
1255 + /// Every stage before these does a piece of work and reports whether the
1256 + /// command worked. That is a different question from whether the result is
1257 + /// usable, and the gap between the two is where this installer's shipped bugs
1258 + /// have lived. Four of them, and each is one assertion here:
1259 + ///
1260 + /// - `fb967fd2`, an install with no usable account,
1261 + /// - `a2dfb319`, a volume openable only by its TPM,
1262 + /// - `59e768fa`, an origin naming an image that cannot be fetched,
1263 + /// - and a greeter that was present but never enabled.
1264 + ///
1265 + /// All four were found by a person booting the result, which is the slowest
1266 + /// possible way to learn them and the only one available while the install
1267 + /// finished by saying the last command exited 0.
1268 + ///
1269 + /// Run before finalize and umount, because every one of them reads the target
1270 + /// through the mount. They are the last thing the install asserts and the first
1271 + /// thing it would have wanted to know.
1272 + fn acceptance_plan(username: &str, encryption: Option<&Encryption<'_>>, root: &str) -> Vec<Stage> {
1273 + let username = username.to_string();
1274 + let mut stages = vec![
1275 + // An account that exists, with the ids to prove it. The install's own
1276 + // useradd exited 0 in the case this catches.
1277 + Stage::Resolve {
1278 + invocation: Invocation::new("cat").arg(format!("{root}/etc/passwd")),
1279 + then: Box::new(move |listing| {
1280 + passwd_ids(listing, &username)?;
1281 + Ok(Vec::new())
1282 + }),
1283 + },
1284 + // An update address the updater could act on. Writing the file worked
1285 + // in the case this catches; what was written was unusable.
1286 + Stage::Resolve {
1287 + invocation: Invocation::new("cat").arg(format!("{root}.origin")),
1288 + then: Box::new(move |origin| {
1289 + update_reference_parses(origin)?;
1290 + Ok(Vec::new())
1291 + }),
1292 + },
1293 + // A way in on first boot.
1294 + Stage::Resolve {
1295 + invocation: Invocation::new("systemctl")
1296 + .arg(format!("--root={root}"))
1297 + .arg("list-unit-files")
1298 + .arg(GREETER_UNIT),
1299 + then: Box::new(move |listing| {
1300 + greeter_ready(listing, GREETER_UNIT)?;
1301 + Ok(Vec::new())
1302 + }),
1303 + },
1304 + ];
1305 +
1306 + // The invariant, asserted rather than assumed: the enrollment above ran
1307 + // two commands and both exited 0, which is not the same as the header
1308 + // having a slot a person can use.
1309 + if let Some(encryption) = encryption {
1310 + let partition = encryption.partition.to_string();
1311 + stages.push(Stage::Resolve {
1312 + invocation: luks_dump(&partition),
1313 + then: Box::new(move |dump| {
1314 + if non_tpm_slot_exists(dump) {
1315 + return Ok(Vec::new());
1316 + }
1317 + Err(format!(
1318 + "{partition} has no keyslot but the TPM's; \
1319 + the passphrase and recovery phrase would open nothing"
1320 + ))
1321 + }),
1322 + });
1323 + }
1324 +
1325 + stages
1326 + }
1327 +
1328 + /// Whether the deployment origin names an image that can be fetched.
1329 + ///
1330 + /// The bug this catches shipped: omitting `--target-imgref` paired the source
1331 + /// path off the live medium with the registry transport and wrote
1332 + /// `ostree-unverified-registry:/run/initramfs/live/source/alloy:local` into the
1333 + /// origin. Every machine installed that way fails its first update at reference
1334 + /// parsing. Nothing on the install noticed, because writing the file worked.
1335 + /// See [`UPDATE_IMAGE`].
1336 + ///
1337 + /// A path rather than a registry name is the specific shape to reject, and a
1338 + /// missing tag is the other: [`split_tag`] is what the update path itself uses,
1339 + /// so this asks the same question the updater will ask.
1340 + fn update_reference_parses(origin: &str) -> Result<(), String> {
1341 + let reference = origin
1342 + .lines()
1343 + .find_map(|line| line.trim().strip_prefix("container-image-reference="))
1344 + .ok_or("the deployment origin names no container image")?
1345 + .trim();
1346 +
1347 + // ostree writes `<transport>:<image>`, and the half a registry has to
1348 + // understand is the second. A reference with no transport at all is left
1349 + // whole rather than guessed at.
1350 + let image = reference
1351 + .split_once(':')
1352 + .map_or(reference, |(_transport, image)| image);
1353 +
1354 + if image.starts_with('/') {
1355 + return Err(format!(
1356 + "the deployment origin points at a path rather than a registry ({reference}); \
1357 + this machine could never take an update"
1358 + ));
1359 + }
1360 + if split_tag(image).is_none() {
1361 + return Err(format!(
1362 + "the deployment origin names no tag ({reference}); \
1363 + this machine could never take an update"
1364 + ));
1365 + }
1366 + Ok(())
1367 + }
1368 +
1369 + /// Whether the target will present a way to log in.
1370 + ///
1371 + /// Reads a `systemctl list-unit-files` listing, which exits 0 whether the unit
1372 + /// exists or not, so absence and disabled-ness are told apart here rather than
1373 + /// by an exit status.
1374 + ///
1375 + /// Absent is fine and not an oversight: the `server` profile prunes greetd
1376 + /// deliberately and logs in on a getty. Present but disabled is the failure,
1377 + /// because that is a machine that boots to nothing on a profile built to have a
1378 + /// greeter.
1379 + fn greeter_ready(listing: &str, unit: &str) -> Result<(), String> {
1380 + let Some(state) = listing.lines().find_map(|line| {
1381 + let mut columns = line.split_whitespace();
1382 + (columns.next() == Some(unit)).then(|| columns.next().unwrap_or_default())
1383 + }) else {
1384 + return Ok(());
1385 + };
1386 +
1387 + match state {
1388 + "enabled" | "static" | "indirect" | "generated" => Ok(()),
1389 + other => Err(format!(
1390 + "{unit} is {other} on the installed system; it would boot to no way of logging in"
1391 + )),
1392 + }
1393 + }
1394 +
1395 + /// What is owed to the disk when the install fails past its commit point.
1396 + ///
1397 + /// Before the commit point a failure owes nothing: the machine is as it was
1398 + /// found. After it there is a half-built system on a disk the user asked to
1399 + /// have Alloy on, and walking away from that is how three of this installer's
1400 + /// four shipped bugs reached a person. Each was found by booting the result.
1401 + ///
1402 + /// Two branches, in order, and the second only because the first did not take.
1403 + ///
1404 + /// **Repair forward.** The enrollment is the one piece of remaining work that
1405 + /// is still possible after an arbitrary failure: it needs the LUKS header and a
1406 + /// TPM that will authorize, and neither depends on the mount, the deployment or
1407 + /// the account. Running it turns the worst outcome (a volume only a TPM can
1408 + /// open) into a recoverable one, and it is what makes the recovery phrase on
1409 + /// screen mean something. It runs as [`Stage::Attempt`]s because a repair that
1410 + /// fails must not stop the check that follows it.
1411 + ///
1412 + /// **Leave an honest wreck.** If the header still has no slot but the TPM's,
1413 + /// the volume is destroyed rather than left looking installable. See
1414 + /// [`wipe_luks_header`].
1415 + ///
1416 + /// The choice between them is made by reading the header, not by whether the
1417 + /// repair reported success. That is the same rule the deploy is held to, and it
1418 + /// matters more here: the branch it guards destroys data.
1419 + ///
1420 + /// Empty for an unencrypted install. The trap this exists to prevent is a
1421 + /// volume with one TPM slot, and there is no such volume to answer for. A
1422 + /// failed unencrypted install leaves a machine with no account, which is
1423 + /// visibly broken at the greeter rather than silently broken years later.
1424 + fn recover_plan(
1425 + disk: &str,
1426 + encryption: Option<&EncryptionChoice>,
1427 + openable: &Arc<AtomicBool>,
1428 + ) -> Vec<Stage> {
1429 + let Some(choice) = encryption else {
1430 + return Vec::new();
1431 + };
1432 + let passphrase = choice.passphrase.clone();
1433 + let recovery = choice.recovery.clone();
1434 + let openable = Arc::clone(openable);
1435 +
1436 + vec![Stage::Resolve {
1437 + invocation: partition_types(disk),
1438 + then: Box::new(move |listing| {
1439 + let partition = root_partition(listing)?;
1440 + let mut stages: Vec<Stage> = enroll_plan(&partition, &passphrase, &recovery)
1441 + .into_iter()
1442 + .map(|stage| match stage {
1443 + Stage::Run(invocation) => Stage::Attempt(invocation),
1444 + other => other,
1445 + })
1446 + .collect();
1447 +
1448 + stages.push(Stage::Resolve {
1449 + invocation: luks_dump(&partition),
1450 + then: Box::new(move |dump| {
1451 + // Recorded either way, because the run screen has a
1452 + // different thing to say in each case: a phrase worth
1453 + // copying down, or a disk that no longer exists.
1454 + let repaired = non_tpm_slot_exists(dump);
1455 + openable.store(repaired, Ordering::Relaxed);
1456 + if repaired {
1457 + return Ok(Vec::new());
1458 + }
1459 + Ok(vec![Stage::Run(wipe_luks_header(&partition))])
1460 + }),
1461 + });
1462 +
1463 + Ok(stages)
1464 + }),
1465 + }]
1466 + }
1467 +
1163 1468 /// What lands in the new home besides the skeleton.
1164 1469 ///
1165 1470 /// Grouped because they are the same kind of thing and go to the same place:
@@ -1417,10 +1722,15 @@
1417 1722
1418 1723 // Before finalize and umount, because the TPM slot that authorizes these is
1419 1724 // only guaranteed to answer while the volume this deployment sits in is
1420 - // still open. After the account, because an install that gets this far and
1421 - // then fails to enroll has still produced a machine the user can log into
1422 - // with the TPM alone, where the reverse order would leave slots on a disk
1423 - // with no account on it.
1725 + // still open.
1726 + //
1727 + // The position relative to the account is not a judgment about which wreck
1728 + // reads better. It follows from the invariant in [`non_tpm_slot_exists`]:
1729 + // no LUKS volume is left reachable only by a TPM slot. An account can be
1730 + // added later from any live medium; a keyslot cannot be added to a volume
1731 + // nobody can open. So the irrecoverable thing is not what gets left to
1732 + // last, and if this does fail, [`recover_plan`] runs it again rather than
1733 + // accepting the order's consolation prize.
1424 1734 if let Some(encryption) = encryption {
1425 1735 stages.extend(enroll_plan(
1426 1736 encryption.partition,
@@ -1429,6 +1739,8 @@
1429 1739 ));
1430 1740 }
1431 1741
1742 + stages.extend(acceptance_plan(username, encryption, root));
1743 +
1432 1744 stages.extend([
1433 1745 // Upstream: "optional, but recommended to run as the penultimate step
1434 1746 // before unmounting the target filesystem. This command will perform
@@ -1653,6 +1965,36 @@
1653 1965 /// while it partitions, and names the ostree deployment after a checksum that
1654 1966 /// does not exist until the deploy finishes. Neither can be an argument written
1655 1967 /// in advance, which is why this returns [`Stage`]s rather than a flat list.
1968 + ///
1969 + /// # The commit point
1970 + ///
1971 + /// One stage in this list is a [`Stage::Commit`], and it divides the plan in
1972 + /// two. Everything above it is free to fail: the disk has not been touched, so
1973 + /// the machine is as it was found and there is nothing owed. Everything from it
1974 + /// onward fails into a machine that exists. What is owed then is
1975 + /// [`recover_plan`]'s subject.
1976 + ///
1977 + /// # What counts as a deployed disk
1978 + ///
1979 + /// A deploy has succeeded when the disk says so, not when bootc says so. Four
1980 + /// facts, each established by a stage below, and together the definition:
1981 + ///
1982 + /// 1. a partition of type [`ROOT_PARTITION_TYPE`] exists on the target disk
1983 + /// ([`root_partition`]),
1984 + /// 2. it holds either that filesystem or a LUKS container with the filesystem
1985 + /// inside it ([`filesystem_device`]),
1986 + /// 3. that filesystem mounts, and mounts writable ([`writable_mount`]),
1987 + /// 4. ostree reports a current deployment inside it ([`deployment_dir`]).
1988 + ///
1989 + /// bootc's own exit status is evidence toward this and not a substitute for it,
1990 + /// which is why the deploy is a `Commit` rather than a `Run`. It earns that
1991 + /// treatment: bootc reports the failure of its own teardown as the failure of
1992 + /// the install, so a complete disk and an incomplete one can arrive with the
1993 + /// same non-zero status. The four checks tell them apart; the status cannot.
1994 + ///
1995 + /// This is the discipline the mount check already stated for itself, applied to
1996 + /// the stage where being wrong is most expensive. `mount` warns and exits 0 when
1997 + /// it falls back to read-only, so its success was never evidence either.
1656 1998 fn install_plan(
1657 1999 disk: &str,
1658 2000 hostname: &str,
@@ -1677,7 +2019,13 @@
1677 2019 // --wipe is explicit rather than implied by the confirm the user just
1678 2020 // answered: the flag that destroys the disk should be visible on the
1679 2021 // line the summary displays, not hidden in a default.
1680 - Stage::Run({
2022 + //
2023 + // [`Stage::Commit`] and not `Run`: this is the install's commit point.
2024 + // Every stage above it can fail freely, because failing there leaves
2025 + // the machine as it was found. From the moment this one starts there
2026 + // is a disk being rewritten, and every stage below it fails into a
2027 + // machine that exists. See [`recover_plan`] for what is owed then.
2028 + Stage::Commit({
1681 2029 let mut install = Invocation::new("bootc").args(["install", "to-disk", "--wipe"]);
1682 2030 // bootc installs a container image, and by default it expects to
1683 2031 // be running inside the one it is installing. On the ISO it is
@@ -2183,6 +2531,13 @@
2183 2531 /// reboots without these words written down has a machine whose disk dies
2184 2532 /// with its TPM, and nothing will ever show them again.
2185 2533 recovery_ack: bool,
2534 + /// Whether the disk ended the run with a keyslot a person can use.
2535 + ///
2536 + /// Written by [`recover_plan`]'s check, which runs on a thread the view
2537 + /// does not own, and read by [`recovery_pending`](Self::recovery_pending).
2538 + /// Only the recovery sets it: a run that succeeds has already asserted the
2539 + /// same fact in [`acceptance_plan`].
2540 + disk_openable: Arc<AtomicBool>,
2186 2541 /// The encryption checkbox, until the step is confirmed and it becomes an
2187 2542 /// answer. Starts ticked: Alloy encrypts unless told not to.
2188 2543 encrypt: bool,
@@ -2241,6 +2596,7 @@
2241 2596 recovery: None,
2242 2597 recovery_typed: TextField::new(),
2243 2598 recovery_ack: false,
2599 + disk_openable: Arc::new(AtomicBool::new(false)),
2244 2600 encrypt: true,
2245 2601 credits_scroll: 0,
2246 2602 credits_viewport: std::cell::Cell::new(0),
@@ -2968,17 +3324,33 @@
2968 3324
2969 3325 /// Whether the recovery phrase is waiting to be written down.
2970 3326 ///
2971 - /// True only between a successful install and the user typing the phrase
2972 - /// back. A failed install does not ask: there is no disk to recover, and
2973 - /// demanding a transcription from someone whose install just broke would be
2974 - /// asking them to copy down a secret for a machine that does not exist.
3327 + /// True while a phrase that opens a real disk has not been written down.
3328 + ///
3329 + /// This used to be "the install succeeded", on the reasoning that a failed
3330 + /// install leaves no disk to recover. That reasoning is the false invariant
3331 + /// the commit point exists to replace. A failure past the commit point can
3332 + /// leave a perfectly good encrypted volume with this phrase in a keyslot,
3333 + /// and not asking for it there is how the phrase gets thrown away by
3334 + /// someone who was told their install broke.
3335 + ///
3336 + /// So the question is about the disk rather than about the run: is there a
3337 + /// volume, and does this phrase open it. A success answers yes because
3338 + /// [`acceptance_plan`] asserted it. A failure answers yes only if the
3339 + /// recovery repaired the header, and no if it wiped it, because a phrase
3340 + /// for a volume that no longer exists is worth nothing and asking someone
3341 + /// to transcribe it would be a lie about what they hold.
2975 3342 fn recovery_pending(&self) -> bool {
2976 - !self.recovery_ack
2977 - && self.recovery.is_some()
2978 - && self
2979 - .running
2980 - .as_ref()
2981 - .is_some_and(|sequence| matches!(sequence.outcome(), Some(Ok(()))))
3343 + if self.recovery_ack || self.recovery.is_none() {
3344 + return false;
3345 + }
3346 + let Some(sequence) = self.running.as_ref() else {
3347 + return false;
3348 + };
3349 + match sequence.outcome() {
3350 + Some(Ok(())) => true,
3351 + Some(Err(_)) => sequence.committed() && self.disk_openable.load(Ordering::Relaxed),
3352 + None => false,
3353 + }
2982 3354 }
2983 3355
2984 3356 /// Keys for the recovery pane: a field, and Enter to check it.
@@ -3024,11 +3396,29 @@
3024 3396 return;
3025 3397 };
3026 3398
3399 + // A failed install that still has to ask for the phrase must not open
3400 + // with "finished". The disk under it is real and encrypted either way,
3401 + // which is why the phrase is still worth the same; what differs is
3402 + // whether the machine on it is finished.
3403 + let failed = self
3404 + .running
3405 + .as_ref()
3406 + .is_some_and(|sequence| matches!(sequence.outcome(), Some(Err(_))));
3407 + let (heading, severity) = if failed {
3408 + (
3409 + "The install did not finish, but the disk is encrypted. \
3410 + Write this down before you reboot.",
3411 + Severity::Warn,
3412 + )
3413 + } else {
3414 + (
3415 + "Installation finished. Write this down before you reboot.",
3416 + Severity::Healthy,
3417 + )
3418 + };
3419 +
3027 3420 let mut lines = vec![
3028 - Line::from(Span::styled(
3029 - "Installation finished. Write this down before you reboot.".to_string(),
3030 - Severity::Healthy.style(theme),
3031 - )),
3421 + Line::from(Span::styled(heading.to_string(), severity.style(theme))),
3032 3422 Line::default(),
3033 3423 Line::from(text::muted(
3034 3424 theme,
@@ -3080,6 +3470,16 @@
3080 3470 /// is still happening, which is what the count and the activity light say.
3081 3471 fn render_run(&self, frame: &mut Frame, area: Rect, theme: &Theme, sequence: &Sequence) {
3082 3472 let status = match sequence.outcome() {
3473 + // The install has already failed and the run is discharging what it
3474 + // owes the disk. Saying "installing" here would be the screen
3475 + // hiding the failure until the cleanup finished.
3476 + None if sequence.recovering() => Line::from(vec![
3477 + Span::styled(
3478 + "The install failed. Leaving the disk in a state you can see. ".to_string(),
3479 + Severity::Warn.style(theme),
Lines truncated
@@ -74,6 +74,40 @@
74 74 pub(crate) enum Stage {
75 75 /// Run it. Nothing downstream depends on what it prints.
76 76 Run(Invocation),
77 + /// Run it, and treat everything from the moment it starts as past the
78 + /// commit point.
79 + ///
80 + /// The installer's stages divide at exactly one command. Before it, a
81 + /// failure leaves the machine as it was found and the honest thing to
82 + /// report is that nothing happened. From the instant it starts, the disk
83 + /// is being rewritten, and a failure leaves a machine that exists and is
84 + /// unfinished. Those two outcomes need different handling, and the runner
85 + /// cannot tell them apart by counting stages, because a
86 + /// [`Resolve`](Self::Resolve) decides how many there are while the run is
87 + /// under way.
88 + ///
89 + /// So the boundary rides on the command that is the boundary rather than
90 + /// on an index into the queue. Everything a resolver produces afterwards
91 + /// is behind it by queue order, with nothing to keep in step.
92 + ///
93 + /// Set when the child is spawned, not when it exits: `bootc install
94 + /// to-disk` wipes the partition table early and runs for minutes after
95 + /// that, so a failure anywhere inside it is already past the point of no
96 + /// return. A spawn that fails outright does not commit, because a command
97 + /// that never started wrote nothing.
98 + Commit(Invocation),
99 + /// Run it, and carry on whether it worked or not.
100 + ///
101 + /// For the recovery, where the run is already failing and the job is to
102 + /// leave the disk in a defensible state. A repair that does not work is
103 + /// worth attempting and worth reporting, and it must not stop the check
104 + /// that decides whether the disk is defensible after all: the whole point
105 + /// of the recovery is the state it reaches, and a step's exit status is
106 + /// not that state.
107 + ///
108 + /// Not for the install proper. There every command's failure means the
109 + /// commands behind it are working against something that is not there.
110 + Attempt(Invocation),
77 111 /// Run it, then let `then` build what follows from its stdout.
78 112 ///
79 113 /// The resolver's `Err` fails the sequence with that message, which is how
@@ -88,7 +122,10 @@
88 122 impl Stage {
89 123 fn invocation(&self) -> &Invocation {
90 124 match self {
91 - Self::Run(invocation) | Self::Resolve { invocation, .. } => invocation,
125 + Self::Run(invocation)
126 + | Self::Commit(invocation)
127 + | Self::Attempt(invocation)
128 + | Self::Resolve { invocation, .. } => invocation,
92 129 }
93 130 }
94 131
@@ -133,6 +170,11 @@
133 170 overflowed: bool,
134 171 /// What to do with this command's output, for a [`Stage::Resolve`].
135 172 then: Option<Resolver>,
173 + /// This is the [`Stage::Commit`], so its exit status does not decide the
174 + /// run on its own. See [`Sequence::commit_failure`].
175 + commit: bool,
176 + /// This is a [`Stage::Attempt`], so its exit status does not stop the run.
177 + attempt: bool,
136 178 /// Lines from stdout and stderr, interleaved in arrival order.
137 179 ///
138 180 /// Both streams feed one channel because that is how they appear on a
@@ -296,6 +338,37 @@
296 338 outcome: Option<Result<(), String>>,
297 339 /// Commands that have exited, successfully or not. Only for progress.
298 340 done_count: usize,
341 + /// Whether a [`Stage::Commit`] has started.
342 + ///
343 + /// Latched: once the disk has been committed to, no later event makes it
344 + /// untrue again.
345 + committed: bool,
346 + /// What the commit stage reported, when it reported a failure the run
347 + /// carried on past.
348 + ///
349 + /// Kept so a later failure can say both halves. "no root partition" on its
350 + /// own invites a hunt for a bug in the discovery; "no root partition, and
351 + /// the deploy reported exit status 1" names the thing that actually went
352 + /// wrong.
353 + commit_failure: Option<String>,
354 + /// Whether any stage has completed since a commit failure was tolerated.
355 + ///
356 + /// Carrying on past a failing deploy is only defensible because something
357 + /// behind it establishes what is on the disk. If nothing does, the run has
358 + /// no grounds to call itself a success, and this is what keeps
359 + /// "continuing" from decaying into "ignoring".
360 + checked_after_commit: bool,
361 + /// What to run when a failure lands past the commit point.
362 + ///
363 + /// Taken when it runs, so a recovery that fails does not recurse into
364 + /// itself.
365 + recovery: Option<Vec<Stage>>,
366 + /// The failure being recovered from, while the recovery runs.
367 + ///
368 + /// Held rather than reported immediately: the run has not finished failing
369 + /// until the duty owed to the disk is discharged, and the outcome the user
370 + /// reads should describe both.
371 + failure: Option<String>,
299 372 }
300 373
301 374 impl Sequence {
@@ -312,9 +385,35 @@
312 385 output: Vec::new(),
313 386 outcome: None,
314 387 done_count: 0,
388 + committed: false,
389 + commit_failure: None,
390 + checked_after_commit: false,
391 + recovery: None,
392 + failure: None,
315 393 }
316 394 }
317 395
396 + /// Stages to run if the sequence fails past its commit point.
397 + ///
398 + /// A failure before the commit point leaves the machine as it was found,
399 + /// and the right response is to say so and stop. A failure after it leaves
400 + /// something on the disk that nobody asked for, and stopping there is how
401 + /// an installer produces a machine that is broken in a way its owner
402 + /// cannot see. These stages are what is owed instead.
403 + ///
404 + /// They run in place of the queue that was dropped, and their own failure
405 + /// is reported alongside the original rather than replacing it.
406 + pub(crate) fn on_commit_failure(mut self, stages: Vec<Stage>) -> Self {
407 + self.recovery = (!stages.is_empty()).then_some(stages);
408 + self
409 + }
410 +
411 + /// Whether the run is discharging its duty to a committed disk rather than
412 + /// still installing.
413 + pub(crate) fn recovering(&self) -> bool {
414 + self.failure.is_some()
415 + }
416 +
318 417 /// Lines produced so far, oldest first.
319 418 pub(crate) fn output(&self) -> &[String] {
320 419 &self.output
@@ -329,6 +428,19 @@
329 428 self.outcome.is_some()
330 429 }
331 430
431 + /// Whether the run has passed its commit point.
432 + ///
433 + /// Read with [`outcome`](Self::outcome) to tell the two kinds of failure
434 + /// apart. False and `Err` means nothing was written and the machine is as
435 + /// it was found. True and `Err` means there is something on the disk, and
436 + /// whatever is left of it is now this program's responsibility.
437 + ///
438 + /// True and `Ok` is the ordinary end of a successful install, so this is
439 + /// not on its own a report of trouble.
440 + pub(crate) fn committed(&self) -> bool {
441 + self.committed
442 + }
443 +
332 444 /// How many commands have finished, for a progress line.
333 445 ///
334 446 /// A count, not a fraction. The sequence has no total to report: a
@@ -401,8 +513,47 @@
401 513 self.done_count += 1;
402 514
403 515 if !status.success() {
404 - self.fail(format!("command exited with {status}"));
405 - return;
516 + // Every other command's exit status is its verdict. The commit
517 + // stage's is one input to a verdict the stages behind it
518 + // reach, because what matters about a deploy is the state of
519 + // the disk and not what the deploying program returned.
520 + //
521 + // The case that forced this: bootc finishes a perfectly good
522 + // install, then fails to close the LUKS device it opened,
523 + // because udev on the live medium re-probes the filesystem it
524 + // just wrote and holds the mapper. bootc reports that teardown
525 + // failure as the exit status of the whole install. Treating it
526 + // as one aborted an install whose disk was complete, and left
527 + // the machine with no account and a volume only its TPM could
528 + // open.
529 + //
530 + // Continuing is not the same as ignoring. The status is kept
531 + // and shown, it joins the message of any later failure, and
532 + // what follows is precisely the set of checks that establish
533 + // whether a deploy is really there. A bootc that failed for a
534 + // real reason gets no further than the first of them.
535 + if finished.commit {
536 + let note = format!(
537 + "the deploy reported {status}; \
538 + checking the disk rather than taking that as the answer"
539 + );
540 + push_line(&mut self.output, note);
541 + self.commit_failure = Some(format!("the deploy reported {status}"));
542 + } else if finished.attempt {
543 + // A repair that did not work. Said plainly and carried
544 + // past: what decides the recovery is the state of the disk
545 + // at the end of it, which the stages behind this one are
546 + // there to read.
547 + push_line(
548 + &mut self.output,
549 + format!("that step reported {status}; carrying on to check the disk"),
550 + );
551 + } else {
552 + self.fail(format!("command exited with {status}"));
553 + return;
554 + }
555 + } else if self.commit_failure.is_some() {
556 + self.checked_after_commit = true;
406 557 }
407 558
408 559 if let Some(resolve) = finished.then.take() {
@@ -434,16 +585,41 @@
434 585
435 586 fn start_next(&mut self, log: &mut CommandLog) {
436 587 let Some(stage) = self.queue.pop_front() else {
437 - self.outcome = Some(Ok(()));
588 + // A recovery that ran to the end discharged the duty. The install
589 + // still failed, and saying otherwise because the cleanup worked
590 + // would be the installer congratulating itself for tidying up.
591 + if let Some(original) = self.failure.take() {
592 + self.outcome = Some(Err(original));
593 + return;
594 + }
595 +
596 + // Reaching the end is the ordinary way to succeed, with one
597 + // exception: a deploy that reported a failure and had nothing
598 + // behind it to check the disk. Nothing established that the
599 + // failure was survivable, so nothing may claim it was.
600 + self.outcome = match &self.commit_failure {
601 + Some(deploy) if !self.checked_after_commit => Some(Err(format!(
602 + "{deploy}, and nothing checked the disk afterwards"
603 + ))),
604 + _ => Some(Ok(())),
605 + };
438 606 return;
439 607 };
440 608
441 609 match spawn(stage.invocation(), log) {
442 610 Ok(mut running) => {
611 + // Latched here rather than on exit. The command that commits
612 + // destroys the old contents of the disk long before it
613 + // returns, so the interesting question is whether it started.
614 + if matches!(stage, Stage::Commit(_)) {
615 + self.committed = true;
616 + running.commit = true;
617 + }
618 + running.attempt = matches!(stage, Stage::Attempt(_));
443 619 // Set before the first drain, because whether a line is
444 620 // captured depends on it.
445 621 running.then = match stage {
446 - Stage::Run(_) => None,
622 + Stage::Run(_) | Stage::Commit(_) | Stage::Attempt(_) => None,
447 623 Stage::Resolve { then, .. } => Some(then),
448 624 };
449 625 self.current = Some(running);
@@ -457,6 +633,44 @@
457 633 fn fail(&mut self, message: String) {
458 634 self.queue.clear();
459 635 self.current = None;
636 +
637 + // The recovery itself failing. The original failure is what the run is
638 + // about; this says the duty owed to the disk was not discharged, which
639 + // is the sentence that tells a user their machine needs attention
640 + // rather than another try.
641 + if let Some(original) = self.failure.take() {
642 + self.outcome = Some(Err(format!(
643 + "{original}; the disk could not be put right either: {message}"
644 + )));
645 + return;
646 + }
647 +
648 + // A run that carried on past a failing deploy and then failed anyway
649 + // reports both, in the order they happened. The deploy is the earlier
650 + // and usually the real one.
651 + let message = match &self.commit_failure {
652 + Some(deploy) => format!("{deploy}, and then {message}"),
653 + None => message,
654 + };
655 +
656 + // Past the commit point there is a disk to answer for, so the run is
657 + // not over until the recovery has had its turn.
658 + if self.committed
659 + && let Some(recovery) = self.recovery.take()
660 + {
661 + {
662 + push_line(
663 + &mut self.output,
664 + format!(
665 + "{message}. The disk has been written to, so it cannot be left as it is"
666 + ),
667 + );
668 + self.failure = Some(message);
669 + self.queue = recovery.into();
670 + return;
671 + }
672 + }
673 +
460 674 self.outcome = Some(Err(message));
461 675 }
462 676 }
@@ -482,6 +696,8 @@
482 696 captured: Vec::new(),
483 697 overflowed: false,
484 698 then: None,
699 + commit: false,
700 + attempt: false,
485 701 lines: receiver,
486 702 exited: None,
487 703 })
@@ -882,6 +1098,189 @@
882 1098 assert_eq!(lines.last().expect("appended"), "newest");
883 1099 }
884 1100
1101 + // The boundary the installer's failure handling turns on. Nothing before
1102 + // the commit stage has touched the disk.
1103 + #[test]
1104 + fn a_failure_before_the_commit_point_is_not_committed() {
1105 + let mut log = CommandLog::new();
1106 + let mut sequence = Sequence::new(vec![
1107 + Stage::Run(Invocation::new("false")),
1108 + Stage::Commit(Invocation::new("true")),
1109 + ]);
1110 +
1111 + drive(&mut sequence, &mut log);
1112 +
1113 + assert!(sequence.outcome().expect("stopped").is_err());
1114 + assert!(!sequence.committed());
1115 + }
1116 +
1117 + // The commit stage's own failure is on the far side of the boundary: it
1118 + // wipes the partition table early and fails minutes later, so a machine
1119 + // exists either way.
1120 + //
1121 + // With nothing behind it to check the disk, the run also has no grounds to
1122 + // call the failure survivable, so it does not.
1123 + #[test]
1124 + fn the_commit_stage_failing_is_still_committed() {
1125 + let mut log = CommandLog::new();
1126 + let mut sequence = Sequence::new(vec![Stage::Commit(Invocation::new("false"))]);
1127 +
1128 + drive(&mut sequence, &mut log);
1129 +
1130 + let Some(Err(message)) = sequence.outcome() else {
1131 + panic!("a deploy that failed with nothing to check it was called a success");
1132 + };
1133 + assert!(message.contains("nothing checked the disk"), "{message}");
1134 + assert!(sequence.committed());
1135 + }
1136 +
1137 + // This is the shape of the bug that prompted the boundary: the deploy
1138 + // succeeds, a stage behind it fails, and every remaining stage is dropped
1139 + // by the rule in `a_failure_stops_the_commands_behind_it`. What is left on
1140 + // the disk is a machine, not nothing.
1141 + #[test]
1142 + fn a_failure_after_the_commit_point_is_committed() {
1143 + let mut log = CommandLog::new();
1144 + let mut sequence = Sequence::new(vec![
1145 + Stage::Commit(Invocation::new("true")),
1146 + Stage::Run(Invocation::new("false")),
1147 + ]);
1148 +
1149 + drive(&mut sequence, &mut log);
1150 +
1151 + assert!(sequence.outcome().expect("stopped").is_err());
1152 + assert!(sequence.committed());
1153 + }
1154 +
1155 + // A command that never started wrote nothing, so a missing binary is a
1156 + // failure on the near side however the stage was labelled. Otherwise the
1157 + // installer would wipe a LUKS header it had never written.
1158 + #[test]
1159 + fn a_commit_stage_that_cannot_spawn_does_not_commit() {
1160 + let mut log = CommandLog::new();
1161 + let mut sequence = Sequence::new(vec![Stage::Commit(Invocation::new(
1162 + "alloy-no-such-command-exists",
1163 + ))]);
1164 +
1165 + drive(&mut sequence, &mut log);
1166 +
1167 + assert!(sequence.outcome().expect("stopped").is_err());
1168 + assert!(!sequence.committed());
1169 + }
1170 +
1171 + // Stages a resolver produces after the commit are behind it by queue
1172 + // order, with no index to keep in step.
1173 + #[test]
1174 + fn stages_a_resolver_adds_after_the_commit_are_committed() {
1175 + let mut log = CommandLog::new();
1176 + let mut sequence = Sequence::new(vec![
1177 + Stage::Commit(Invocation::new("true")),
1178 + Stage::Resolve {
1179 + invocation: Invocation::new("echo").arg("discovered"),
1180 + then: Box::new(|_| Ok(vec![Stage::Run(Invocation::new("false"))])),
1181 + },
1182 + ]);
1183 +
1184 + drive(&mut sequence, &mut log);
1185 +
1186 + assert!(sequence.outcome().expect("stopped").is_err());
1187 + assert!(sequence.committed());
1188 + }
1189 +
1190 + // The bug this whole boundary was built for. bootc finishes the install
1191 + // and then fails to close the LUKS device it opened, reporting that as the
1192 + // status of the install. The stages that establish whether a disk was
1193 + // really deployed have to get their turn.
1194 + #[test]
1195 + fn a_failing_commit_does_not_stop_the_checks_behind_it() {
1196 + let mut log = CommandLog::new();
1197 + let mut sequence = Sequence::new(vec![
1198 + Stage::Commit(Invocation::new("sh").args(["-c", "echo deployed; exit 1"])),
1199 + Stage::Run(Invocation::new("echo").arg("disk verified")),
1200 + ]);
1201 +
1202 + drive(&mut sequence, &mut log);
1203 +
1204 + assert_eq!(sequence.outcome(), Some(&Ok(())));
1205 + assert!(
1206 + sequence.output().iter().any(|l| l == "disk verified"),
1207 + "{:?}",
1208 + sequence.output()
1209 + );
1210 + }
1211 +
1212 + // Continuing is not ignoring: what the deploy reported is on screen, so a
1213 + // user watching the run is not told everything was fine.
1214 + #[test]
1215 + fn a_tolerated_commit_failure_is_still_reported_on_screen() {
1216 + let mut log = CommandLog::new();
1217 + let mut sequence = Sequence::new(vec![Stage::Commit(Invocation::new("false"))]);
1218 +
1219 + drive(&mut sequence, &mut log);
1220 +
1221 + assert!(
1222 + sequence
1223 + .output()
1224 + .iter()
1225 + .any(|l| l.contains("the deploy reported")),
1226 + "{:?}",
1227 + sequence.output()
1228 + );
1229 + }
1230 +
1231 + // A deploy that failed for a real reason gets no further than the first
1232 + // check, and the message names both halves rather than sending the reader
1233 + // hunting for a bug in the check.
1234 + #[test]
1235 + fn a_failure_after_a_tolerated_commit_reports_both() {
1236 + let mut log = CommandLog::new();
1237 + let mut sequence = Sequence::new(vec![
1238 + Stage::Commit(Invocation::new("false")),
1239 + Stage::Resolve {
1240 + invocation: Invocation::new("echo").arg("nothing"),
1241 + then: Box::new(|_| Err("disk not deployed".into())),
1242 + },
1243 + ]);
1244 +
1245 + drive(&mut sequence, &mut log);
1246 +
1247 + let Some(Err(message)) = sequence.outcome() else {
1248 + panic!("the sequence did not fail");
1249 + };
1250 + assert!(message.contains("the deploy reported"), "{message}");
1251 + assert!(message.contains("disk not deployed"), "{message}");
1252 + }
1253 +
1254 + // Only the commit stage gets this treatment. Everything else means what
1255 + // its exit status says.
1256 + #[test]
1257 + fn an_ordinary_failing_command_still_stops_the_run() {
1258 + let mut log = CommandLog::new();
1259 + let mut sequence = Sequence::new(vec![
1260 + Stage::Commit(Invocation::new("true")),
1261 + Stage::Run(Invocation::new("false")),
1262 + Stage::Run(Invocation::new("echo").arg("must not run")),
1263 + ]);
1264 +
1265 + drive(&mut sequence, &mut log);
1266 +
1267 + assert!(sequence.outcome().expect("stopped").is_err());
1268 + assert!(!sequence.output().iter().any(|l| l.contains("must not run")));
1269 + }
1270 +
1271 + // Nothing is committed on a run that has no commit stage at all, which is
1272 + // every sequence in the console that is not an install.
1273 + #[test]
1274 + fn a_sequence_without_a_commit_stage_never_commits() {
1275 + let mut log = CommandLog::new();
1276 + let mut sequence = Sequence::new(vec![Stage::Run(Invocation::new("true"))]);
1277 +
1278 + drive(&mut sequence, &mut log);
1279 +
1280 + assert_eq!(sequence.outcome(), Some(&Ok(())));
1281 + assert!(!sequence.committed());
1282 + }
1283 +
885 1284 #[test]
886 1285 fn progress_counts_completed_commands() {
887 1286 let mut log = CommandLog::new();
@@ -916,4 +1315,170 @@
916 1315 assert_eq!(sequence.outcome(), Some(&Ok(())));
917 1316 assert_eq!(sequence.completed(), 0);
918 1317 }
1318 +
1319 + // ---- the duty owed to a committed disk ----
1320 +
1321 + // A failure past the commit point does not simply stop. Something is on
1322 + // the disk that nobody asked for, and the recovery is what answers for it.
1323 + #[test]
1324 + fn a_committed_failure_runs_the_recovery() {
1325 + let mut log = CommandLog::new();
1326 + let mut sequence = Sequence::new(vec![
1327 + Stage::Commit(Invocation::new("true")),
1328 + Stage::Run(Invocation::new("false")),
Lines truncated