max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 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