max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+318 insertions,
-96 deletions
| @@ -1107,7 +1107,7 @@ | |||
| 1107 | 1107 | /// resolvers, where the partition path finally exists. This one is what the | |
| 1108 | 1108 | /// caller hands over, and `Option` is what carries the user's answer: `None` is | |
| 1109 | 1109 | /// an unencrypted install, which is why nothing downstream needs a bool. | |
| 1110 | - | #[derive(Debug)] | |
| 1110 | + | #[derive(Debug, Clone)] | |
| 1111 | 1111 | struct EncryptionChoice { | |
| 1112 | 1112 | passphrase: String, | |
| 1113 | 1113 | recovery: String, | |
| @@ -1167,27 +1167,46 @@ | |||
| 1167 | 1167 | Invocation::new("cryptsetup").arg("luksDump").arg(partition) | |
| 1168 | 1168 | } | |
| 1169 | 1169 | ||
| 1170 | - | /// Remove the LUKS signature from `partition`. | |
| 1170 | + | /// Make `target` read as blank to everything that looks at it. | |
| 1171 | 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. | |
| 1172 | + | /// The last resort of [`recover_plan`], and the reason it exists: an install | |
| 1173 | + | /// that failed should leave a disk that is obviously unusable rather than one | |
| 1174 | + | /// that is subtly unusable. Both of the subtle wrecks this installer can | |
| 1175 | + | /// produce look like a working machine until the day they do not. | |
| 1176 | + | /// | |
| 1177 | + | /// An encrypted volume whose only key is a TPM slot opens every time until the | |
| 1178 | + | /// board is replaced or the TPM is cleared, and by then the eight words that | |
| 1179 | + | /// would have opened it were thrown away by someone who was told their install | |
| 1180 | + | /// had failed. A machine with no account boots to a greeter that refuses every | |
| 1181 | + | /// name typed into it, which reads as a forgotten password rather than as an | |
| 1182 | + | /// install that did not finish. | |
| 1179 | 1183 | /// | |
| 1180 | 1184 | /// `wipefs` rather than `cryptsetup luksErase`: erasing the keyslots leaves a | |
| 1181 | 1185 | /// header that still says LUKS, so the disk still presents as an encrypted | |
| 1182 | 1186 | /// 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. | |
| 1187 | + | /// wreck this is here to avoid. Removing the signatures makes the target read | |
| 1188 | + | /// as empty instead. It is also the same command either way, which is why the | |
| 1189 | + | /// unencrypted case is no longer a hole: a filesystem signature and a LUKS | |
| 1190 | + | /// signature are both signatures. | |
| 1185 | 1191 | /// | |
| 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) | |
| 1192 | + | /// Nothing recoverable is destroyed. The target is a disk the user consented to | |
| 1193 | + | /// erasing before the install began, and the install had already erased it. | |
| 1194 | + | fn wipe_signatures(target: &str) -> Invocation { | |
| 1195 | + | Invocation::new("wipefs").arg("--all").arg(target) | |
| 1196 | + | } | |
| 1197 | + | ||
| 1198 | + | /// Say what is about to be destroyed, then destroy it. | |
| 1199 | + | /// | |
| 1200 | + | /// The line matters as much as the command. A user watching the run screen sees | |
| 1201 | + | /// their install fail and then sees a disk being wiped, and without the reason | |
| 1202 | + | /// between them the second reads as the installer compounding the first. | |
| 1203 | + | fn wreck_plan(target: &str, why: &str) -> Vec<Stage> { | |
| 1204 | + | vec![ | |
| 1205 | + | Stage::Run(Invocation::new("echo").arg(format!( | |
| 1206 | + | "{why}. Clearing {target} so this disk cannot be mistaken for an install" | |
| 1207 | + | ))), | |
| 1208 | + | Stage::Run(wipe_signatures(target)), | |
| 1209 | + | ] | |
| 1191 | 1210 | } | |
| 1192 | 1211 | ||
| 1193 | 1212 | /// Whether the header in `dump` has a keyslot that is not the TPM's. | |
| @@ -1247,15 +1266,27 @@ | |||
| 1247 | 1266 | slots.iter().any(|slot| !tpm_slots.contains(slot)) | |
| 1248 | 1267 | } | |
| 1249 | 1268 | ||
| 1269 | + | /// One fact that must be true of a finished machine, and how to ask the disk. | |
| 1270 | + | /// | |
| 1271 | + | /// A value rather than a [`Stage`] because two callers want different things | |
| 1272 | + | /// from the same question. The install treats a false answer as a failure; the | |
| 1273 | + | /// recovery treats it as the reason to stop pretending there is a machine here. | |
| 1274 | + | /// Writing it once means those two can never come to disagree about what a | |
| 1275 | + | /// finished machine is. | |
| 1276 | + | struct Check { | |
| 1277 | + | invocation: Invocation, | |
| 1278 | + | verdict: Box<dyn FnOnce(&str) -> Result<(), String>>, | |
| 1279 | + | } | |
| 1280 | + | ||
| 1250 | 1281 | /// The unit that presents a login on a client-profile machine. | |
| 1251 | 1282 | const GREETER_UNIT: &str = "greetd.service"; | |
| 1252 | 1283 | ||
| 1253 | - | /// The checks that decide whether what was built is a machine. | |
| 1284 | + | /// What has to be true of the disk for it to be a machine. | |
| 1254 | 1285 | /// | |
| 1255 | - | /// Every stage before these does a piece of work and reports whether the | |
| 1286 | + | /// Every stage of the install does a piece of work and reports whether the | |
| 1256 | 1287 | /// command worked. That is a different question from whether the result is | |
| 1257 | 1288 | /// 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: | |
| 1289 | + | /// have lived. Four of them, and each is one check here: | |
| 1259 | 1290 | /// | |
| 1260 | 1291 | /// - `fb967fd2`, an install with no usable account, | |
| 1261 | 1292 | /// - `a2dfb319`, a volume openable only by its TPM, | |
| @@ -1266,53 +1297,50 @@ | |||
| 1266 | 1297 | /// possible way to learn them and the only one available while the install | |
| 1267 | 1298 | /// finished by saying the last command exited 0. | |
| 1268 | 1299 | /// | |
| 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> { | |
| 1300 | + | /// `root` is the ostree deployment directory, so every one of these reads the | |
| 1301 | + | /// target through the mount and none of them can run before it. | |
| 1302 | + | fn acceptance_checks( | |
| 1303 | + | username: &str, | |
| 1304 | + | encryption: Option<&Encryption<'_>>, | |
| 1305 | + | root: &str, | |
| 1306 | + | ) -> Vec<Check> { | |
| 1273 | 1307 | let username = username.to_string(); | |
| 1274 | - | let mut stages = vec![ | |
| 1308 | + | let mut checks = vec![ | |
| 1275 | 1309 | // An account that exists, with the ids to prove it. The install's own | |
| 1276 | 1310 | // useradd exited 0 in the case this catches. | |
| 1277 | - | Stage::Resolve { | |
| 1311 | + | Check { | |
| 1278 | 1312 | 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 | - | }), | |
| 1313 | + | verdict: Box::new(move |listing| passwd_ids(listing, &username).map(|_| ())), | |
| 1283 | 1314 | }, | |
| 1284 | 1315 | // An update address the updater could act on. Writing the file worked | |
| 1285 | 1316 | // in the case this catches; what was written was unusable. | |
| 1286 | - | Stage::Resolve { | |
| 1317 | + | // | |
| 1318 | + | // `<deployment>.origin` is ostree's own layout, not a guess: libostree | |
| 1319 | + | // writes `ostree/deploy/%s/deploy/%s.%d.origin`. | |
| 1320 | + | Check { | |
| 1287 | 1321 | invocation: Invocation::new("cat").arg(format!("{root}.origin")), | |
| 1288 | - | then: Box::new(move |origin| { | |
| 1289 | - | update_reference_parses(origin)?; | |
| 1290 | - | Ok(Vec::new()) | |
| 1291 | - | }), | |
| 1322 | + | verdict: Box::new(update_reference_parses), | |
| 1292 | 1323 | }, | |
| 1293 | 1324 | // A way in on first boot. | |
| 1294 | - | Stage::Resolve { | |
| 1325 | + | Check { | |
| 1295 | 1326 | invocation: Invocation::new("systemctl") | |
| 1296 | 1327 | .arg(format!("--root={root}")) | |
| 1297 | 1328 | .arg("list-unit-files") | |
| 1298 | 1329 | .arg(GREETER_UNIT), | |
| 1299 | - | then: Box::new(move |listing| { | |
| 1300 | - | greeter_ready(listing, GREETER_UNIT)?; | |
| 1301 | - | Ok(Vec::new()) | |
| 1302 | - | }), | |
| 1330 | + | verdict: Box::new(|listing| greeter_ready(listing, GREETER_UNIT)), | |
| 1303 | 1331 | }, | |
| 1304 | 1332 | ]; | |
| 1305 | 1333 | ||
| 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. | |
| 1334 | + | // The invariant, asserted rather than assumed: the enrollment ran two | |
| 1335 | + | // commands and both exited 0, which is not the same as the header having a | |
| 1336 | + | // slot a person can use. | |
| 1309 | 1337 | if let Some(encryption) = encryption { | |
| 1310 | 1338 | let partition = encryption.partition.to_string(); | |
| 1311 | - | stages.push(Stage::Resolve { | |
| 1339 | + | checks.push(Check { | |
| 1312 | 1340 | invocation: luks_dump(&partition), | |
| 1313 | - | then: Box::new(move |dump| { | |
| 1341 | + | verdict: Box::new(move |dump| { | |
| 1314 | 1342 | if non_tpm_slot_exists(dump) { | |
| 1315 | - | return Ok(Vec::new()); | |
| 1343 | + | return Ok(()); | |
| 1316 | 1344 | } | |
| 1317 | 1345 | Err(format!( | |
| 1318 | 1346 | "{partition} has no keyslot but the TPM's; \ | |
| @@ -1322,7 +1350,66 @@ | |||
| 1322 | 1350 | }); | |
| 1323 | 1351 | } | |
| 1324 | 1352 | ||
| 1325 | - | stages | |
| 1353 | + | checks | |
| 1354 | + | } | |
| 1355 | + | ||
| 1356 | + | /// The acceptance checks as the install runs them: a false answer fails it. | |
| 1357 | + | /// | |
| 1358 | + | /// Run before finalize and umount, because every one of them reads the target | |
| 1359 | + | /// through the mount. They are the last thing the install asserts and the first | |
| 1360 | + | /// thing it would have wanted to know. | |
| 1361 | + | fn acceptance_plan(username: &str, encryption: Option<&Encryption<'_>>, root: &str) -> Vec<Stage> { | |
| 1362 | + | acceptance_checks(username, encryption, root) | |
| 1363 | + | .into_iter() | |
| 1364 | + | .map( | |
| 1365 | + | |Check { | |
| 1366 | + | invocation, | |
| 1367 | + | verdict, | |
| 1368 | + | }| Stage::Resolve { | |
| 1369 | + | invocation, | |
| 1370 | + | then: Box::new(move |output| { | |
| 1371 | + | verdict(output)?; | |
| 1372 | + | Ok(Vec::new()) | |
| 1373 | + | }), | |
| 1374 | + | }, | |
| 1375 | + | ) | |
| 1376 | + | .collect() | |
| 1377 | + | } | |
| 1378 | + | ||
| 1379 | + | /// The same checks as the recovery runs them: a false answer wrecks the disk. | |
| 1380 | + | /// | |
| 1381 | + | /// Each check decides whether the next one is asked, so the first fact that is | |
| 1382 | + | /// not true ends it. `intact` is set only when every check has answered yes, | |
| 1383 | + | /// which is the one state in which the disk is left alone. | |
| 1384 | + | /// | |
| 1385 | + | /// The recursion happens inside a resolver rather than while the plan is built, | |
| 1386 | + | /// because a check's verdict is not knowable until its command has run. | |
| 1387 | + | fn verify_or_wreck( | |
| 1388 | + | mut checks: std::collections::VecDeque<Check>, | |
| 1389 | + | target: String, | |
| 1390 | + | intact: Arc<AtomicBool>, | |
| 1391 | + | ) -> Vec<Stage> { | |
| 1392 | + | let Some(Check { | |
| 1393 | + | invocation, | |
| 1394 | + | verdict, | |
| 1395 | + | }) = checks.pop_front() | |
| 1396 | + | else { | |
| 1397 | + | return Vec::new(); | |
| 1398 | + | }; | |
| 1399 | + | ||
| 1400 | + | vec![Stage::Resolve { | |
| 1401 | + | invocation, | |
| 1402 | + | then: Box::new(move |output| { | |
| 1403 | + | if let Err(message) = verdict(output) { | |
| 1404 | + | return Ok(wreck_plan(&target, &message)); | |
| 1405 | + | } | |
| 1406 | + | if checks.is_empty() { | |
| 1407 | + | intact.store(true, Ordering::Relaxed); | |
| 1408 | + | return Ok(Vec::new()); | |
| 1409 | + | } | |
| 1410 | + | Ok(verify_or_wreck(checks, target, intact)) | |
| 1411 | + | }), | |
| 1412 | + | }] | |
| 1326 | 1413 | } | |
| 1327 | 1414 | ||
| 1328 | 1415 | /// Whether the deployment origin names an image that can be fetched. | |
| @@ -1399,70 +1486,77 @@ | |||
| 1399 | 1486 | /// have Alloy on, and walking away from that is how three of this installer's | |
| 1400 | 1487 | /// four shipped bugs reached a person. Each was found by booting the result. | |
| 1401 | 1488 | /// | |
| 1402 | - | /// Two branches, in order, and the second only because the first did not take. | |
| 1489 | + | /// Three phases, and each only because the one before it did not settle the | |
| 1490 | + | /// matter. | |
| 1403 | 1491 | /// | |
| 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. | |
| 1492 | + | /// **Repair forward.** Find the disk again and mount it, through | |
| 1493 | + | /// [`deployed_disk_plan`], the same chain the install used, and then enroll the | |
| 1494 | + | /// keyslots bootc wipes. The repair runs as [`Stage::Attempt`]s: one that fails | |
| 1495 | + | /// must not stop the checks, because what decides this is the state of the disk | |
| 1496 | + | /// and not the exit status of a retry. | |
| 1411 | 1497 | /// | |
| 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`]. | |
| 1498 | + | /// **Ask whether it is a machine.** [`acceptance_checks`], the same four facts | |
| 1499 | + | /// the install asserts at its end. Using the install's own definition is the | |
| 1500 | + | /// point: a recovery that judged by its own standard would be a second opinion | |
| 1501 | + | /// about what "installed" means, free to drift from the first. | |
| 1415 | 1502 | /// | |
| 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. | |
| 1503 | + | /// **Leave an honest wreck.** If any of those facts is false, the disk is | |
| 1504 | + | /// cleared. See [`wipe_signatures`] for why that is kinder than it sounds, and | |
| 1505 | + | /// why it is the same answer whether the volume was encrypted or not. | |
| 1419 | 1506 | /// | |
| 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. | |
| 1507 | + | /// A dead end anywhere in the chain lands in the third phase too. If the disk | |
| 1508 | + | /// cannot even be shown to hold a deployment, it certainly cannot be shown to | |
| 1509 | + | /// hold a machine, and the whole disk is cleared rather than a partition that | |
| 1510 | + | /// was never identified. | |
| 1424 | 1511 | fn recover_plan( | |
| 1425 | 1512 | disk: &str, | |
| 1513 | + | username: &str, | |
| 1426 | 1514 | encryption: Option<&EncryptionChoice>, | |
| 1427 | - | openable: &Arc<AtomicBool>, | |
| 1515 | + | intact: &Arc<AtomicBool>, | |
| 1428 | 1516 | ) -> 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); | |
| 1517 | + | let username = username.to_string(); | |
| 1518 | + | let choice = encryption.cloned(); | |
| 1519 | + | let intact = Arc::clone(intact); | |
| 1435 | 1520 | ||
| 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(); | |
| 1521 | + | // Before the root partition is known there is nothing narrower to clear. | |
| 1522 | + | let whole_disk = disk.to_string(); | |
| 1523 | + | let dead_end: DeadEnd = std::rc::Rc::new(move |message| wreck_plan(&whole_disk, &message)); | |
| 1447 | 1524 | ||
| 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 | - | }), | |
| 1525 | + | deployed_disk_plan( | |
| 1526 | + | disk, | |
| 1527 | + | Some(dead_end), | |
| 1528 | + | Box::new(move |partition, root| { | |
| 1529 | + | let mut stages = Vec::new(); | |
| 1530 | + | ||
| 1531 | + | // The one repair that does not depend on anything the checks are | |
| 1532 | + | // about to test, and the one whose absence is irreversible: a | |
| 1533 | + | // volume with no slot but the TPM's cannot be given one later by | |
| 1534 | + | // anybody, where an account can be added from any live medium. | |
| 1535 | + | if let Some(choice) = &choice { | |
| 1536 | + | stages.extend( | |
| 1537 | + | enroll_plan(partition, &choice.passphrase, &choice.recovery) | |
| 1538 | + | .into_iter() | |
| 1539 | + | .map(|stage| match stage { | |
| 1540 | + | Stage::Run(invocation) => Stage::Attempt(invocation), | |
| 1541 | + | other => other, | |
| 1542 | + | }), | |
| 1543 | + | ); | |
| 1544 | + | } | |
| 1545 | + | ||
| 1546 | + | let encryption = choice.as_ref().map(|choice| Encryption { | |
| 1547 | + | partition, | |
| 1548 | + | passphrase: &choice.passphrase, | |
| 1549 | + | recovery: &choice.recovery, | |
| 1461 | 1550 | }); | |
| 1551 | + | stages.extend(verify_or_wreck( | |
| 1552 | + | acceptance_checks(&username, encryption.as_ref(), root).into(), | |
| 1553 | + | partition.to_string(), | |
| 1554 | + | intact, | |
| 1555 | + | )); | |
| 1462 | 1556 | ||
| 1463 | 1557 | Ok(stages) | |
| 1464 | 1558 | }), | |
| 1465 | - | }] | |
| 1559 | + | ) | |
| 1466 | 1560 | } | |
| 1467 | 1561 | ||
| 1468 | 1562 | /// What lands in the new home besides the skeleton. | |
| @@ -1959,6 +2053,146 @@ | |||
| 1959 | 2053 | .then(|| format!("oci:{}:{LIVE_SOURCE_TAG}", path.display())) | |
| 1960 | 2054 | } | |
| 1961 | 2055 | ||
| 2056 | + | /// What to do once the disk has been found to hold a deployment. | |
| 2057 | + | /// | |
| 2058 | + | /// Takes the root partition and the deployment directory, the two values that | |
| 2059 | + | /// cannot be known before the deploy has run. The install configures; the | |
| 2060 | + | /// recovery checks. | |
| 2061 | + | type Deployed = Box<dyn FnOnce(&str, &str) -> Result<Vec<Stage>, String>>; | |
| 2062 | + | ||
| 2063 | + | /// What to do when the chain cannot go on. | |
| 2064 | + | /// | |
| 2065 | + | /// The install has no answer to that and says so, which fails the run. The | |
| 2066 | + | /// recovery does: a disk that cannot be shown to hold a machine is a disk that | |
| 2067 | + | /// must not look like one. Shared rather than owned because every resolver in | |
| 2068 | + | /// the chain needs it and only one of them will ever call it. | |
| 2069 | + | type DeadEnd = std::rc::Rc<dyn Fn(String) -> Vec<Stage>>; | |
| 2070 | + | ||
| 2071 | + | /// Route a dead end to the stages that answer for it, or report it. | |
| 2072 | + | fn or_dead_end( | |
| 2073 | + | outcome: Result<Vec<Stage>, String>, | |
| 2074 | + | dead_end: Option<&DeadEnd>, | |
| 2075 | + | ) -> Result<Vec<Stage>, String> { | |
| 2076 | + | match (outcome, dead_end) { | |
| 2077 | + | (Err(message), Some(answer)) => Ok(answer(message)), | |
| 2078 | + | (outcome, _) => outcome, | |
| 2079 | + | } | |
| 2080 | + | } | |
| 2081 | + | ||
| 2082 | + | /// Find the deployment on `disk`, mount it, and hand it to `then`. | |
| 2083 | + | /// | |
| 2084 | + | /// The three discoveries the install rests on, in the only order they can | |
| 2085 | + | /// happen: bootc decides which partition holds the new root while it | |
| 2086 | + | /// partitions, whether that partition is a LUKS container is a fact about what | |
| 2087 | + | /// it built rather than about what was asked for, and the ostree deployment is | |
| 2088 | + | /// named after a checksum that does not exist until the deploy finishes. | |
| 2089 | + | /// | |
| 2090 | + | /// Shared by the install and by [`recover_plan`], which is the point. The | |
| 2091 | + | /// recovery's job is to decide whether the disk holds a machine, and asking | |
| 2092 | + | /// that question with a different chain than the one that built it would be two | |
| 2093 | + | /// definitions of the same thing, drifting. | |
| 2094 | + | fn deployed_disk_plan(disk: &str, dead_end: Option<DeadEnd>, then: Deployed) -> Vec<Stage> { | |
| 2095 | + | let settle = dead_end.clone(); | |
| 2096 | + | let contents_end = dead_end.clone(); | |
| 2097 | + | let mount_end = dead_end.clone(); | |
| 2098 | + | let deployment_end = dead_end; | |
| 2099 | + | ||
| 2100 | + | vec![ | |
| 2101 | + | // bootc returns when the install is done, not when the kernel and | |
| 2102 | + | // udev have caught up with the partition table it wrote. In that | |
| 2103 | + | // window `lsblk` answers with a `parttype` of null for every | |
| 2104 | + | // partition, so the discovery below finds no root and fails with | |
| 2105 | + | // "disk not deployed" about a disk that deployed perfectly. Observed | |
| 2106 | + | // against a real bootc 1.16.3 install: null for all three partitions | |
| 2107 | + | // immediately after, the correct GUIDs a moment later. | |
| 2108 | + | // | |
| 2109 | + | // Waiting for the queue to drain is the fix rather than a retry loop: | |
| 2110 | + | // there is a specific event to wait for, and a retry would just be | |
| 2111 | + | // this wait spelled less precisely. | |
| 2112 | + | Stage::Run(Invocation::new("udevadm").arg("settle")), | |
| 2113 | + | Stage::Run(Invocation::new("mkdir").args(["-p", TARGET_MOUNT])), | |
| 2114 | + | // First discovery: which partition bootc made the root. | |
| 2115 | + | Stage::Resolve { | |
| 2116 | + | invocation: partition_types(disk), | |
| 2117 | + | then: Box::new(move |listing| { | |
| 2118 | + | let partition = match root_partition(listing) { | |
| 2119 | + | Ok(partition) => partition, | |
| 2120 | + | Err(message) => return or_dead_end(Err(message), settle.as_ref()), | |
| 2121 | + | }; | |
| 2122 | + | // Second discovery: whether that partition holds the filesystem | |
| 2123 | + | // or a LUKS container with the filesystem inside it. Asked of | |
| 2124 | + | // the disk rather than inferred from the answers, so the mount | |
| 2125 | + | // follows what bootc actually built. See [`filesystem_device`]. | |
| 2126 | + | Ok(vec![Stage::Resolve { | |
| 2127 | + | invocation: partition_contents(&partition), | |
| 2128 | + | then: Box::new(move |contents| { | |
| 2129 | + | let device = match filesystem_device(contents, &partition) { | |
| 2130 | + | Ok(device) => device, | |
| 2131 | + | Err(message) => { | |
| 2132 | + | return or_dead_end(Err(message), contents_end.as_ref()); | |
| 2133 | + | } | |
| 2134 | + | }; | |
| 2135 | + | Ok(vec![ | |
| 2136 | + | // Release whatever bootc left mounted on the device | |
| 2137 | + | // before mounting it. Its own leftover is read-only, | |
| 2138 | + | // and a second mount of a filesystem that is already | |
| 2139 | + | // mounted shares the first one's superblock rather | |
| 2140 | + | // than getting a fresh one, so the read-only travels | |
| 2141 | + | // to this mount too. | |
| 2142 | + | Stage::Resolve { | |
| 2143 | + | invocation: mounts_of(&device), | |
| 2144 | + | then: Box::new(move |listing| { | |
| 2145 | + | Ok(leftover_mounts(listing) | |
| 2146 | + | .iter() | |
| 2147 | + | .map(|target| Stage::Run(unmount(target))) | |
| 2148 | + | .collect()) | |
| 2149 | + | }), | |
| 2150 | + | }, | |
| 2151 | + | Stage::Run( | |
| 2152 | + | Invocation::new("mount") | |
| 2153 | + | .args(["-o", "rw"]) | |
| 2154 | + | .arg(&device) | |
| 2155 | + | .arg(TARGET_MOUNT), | |
| 2156 | + | ), | |
| 2157 | + | // Prove it took. `mount` warns and exits 0 when it | |
| 2158 | + | // falls back to read-only, so success here is not | |
| 2159 | + | // evidence. | |
| 2160 | + | Stage::Resolve { | |
| 2161 | + | invocation: mount_options(TARGET_MOUNT), | |
| 2162 | + | then: Box::new(move |options| { | |
| 2163 | + | if let Err(message) = writable_mount(options) { | |
| 2164 | + | return or_dead_end(Err(message), mount_end.as_ref()); | |
| 2165 | + | } | |
| 2166 | + | // Third discovery, only possible once | |
| 2167 | + | // mounted: where the deployment is inside | |
| 2168 | + | // the sysroot. | |
| 2169 | + | Ok(vec![Stage::Resolve { | |
| 2170 | + | invocation: deployment_dir(TARGET_MOUNT), | |
| 2171 | + | then: Box::new(move |printed| { | |
| 2172 | + | let deployment = printed.trim(); | |
| 2173 | + | if deployment.is_empty() { | |
| 2174 | + | return or_dead_end( | |
| 2175 | + | Err("ostree reported no current deployment" | |
| 2176 | + | .into()), | |
| 2177 | + | deployment_end.as_ref(), | |
| 2178 | + | ); | |
| 2179 | + | } | |
| 2180 | + | or_dead_end( | |
| 2181 | + | then(&partition, deployment), | |
| 2182 | + | deployment_end.as_ref(), | |
| 2183 | + | ) |
Lines truncated