max / alloy
1 file changed,
+124 insertions,
-11 deletions
| @@ -1325,6 +1325,12 @@ | |||
| 1325 | 1325 | // network. Nothing below it depends on the result. | |
| 1326 | 1326 | stages.extend(timezone_stages(locate_timezone, root)); | |
| 1327 | 1327 | ||
| 1328 | + | // After the account for the same reason the timezone is: nothing depends on | |
| 1329 | + | // it, and an install that fails here has still produced a machine somebody | |
| 1330 | + | // can log into. Before the enrollment below, so the two stages that must run | |
| 1331 | + | // while the volume is open stay last. | |
| 1332 | + | stages.extend(update_timer_stages(updates_scheduled(), root)); | |
| 1333 | + | ||
| 1328 | 1334 | // Before finalize and umount, because the TPM slot that authorizes these is | |
| 1329 | 1335 | // only guaranteed to answer while the volume this deployment sits in is | |
| 1330 | 1336 | // still open. After the account, because an install that gets this far and | |
| @@ -1401,10 +1407,19 @@ | |||
| 1401 | 1407 | /// same channel as ordinary updates. `:latest` is for casual users who opt into | |
| 1402 | 1408 | /// it; a fresh install follows the version it was built against. | |
| 1403 | 1409 | /// | |
| 1404 | - | /// The registry does not exist yet (GO task 1372b159 provisions it with cosign | |
| 1405 | - | /// signing). Naming it anyway is deliberate: an unpublished reference degrades to | |
| 1406 | - | /// "no update found", which is recoverable and legible, while the alternative | |
| 1407 | - | /// degrades to "updates can never work". | |
| 1410 | + | /// **This address is a deliberate dead end, not a plan.** Nothing is published | |
| 1411 | + | /// here and nothing is going to be: over-the-air updates are a development-build | |
| 1412 | + | /// feature for now, reaching the registry that `build/build-iso.sh | |
| 1413 | + | /// --update-target` names, and the eventual home for updates that a stranger's | |
| 1414 | + | /// machine can reach is a service over synckit/MNW rather than a public | |
| 1415 | + | /// container registry. GO alloy task 57628fd3 carries that. | |
| 1416 | + | /// | |
| 1417 | + | /// It is still written into every install, because the alternative is worse. Omit | |
| 1418 | + | /// `--target-imgref` and bootc writes the source path off the live medium into the | |
| 1419 | + | /// origin, which is the bug the paragraphs above describe: a reference that fails | |
| 1420 | + | /// at parsing. An address that resolves to nothing degrades to "no update found", | |
| 1421 | + | /// which is legible. Machines built without an update target never ask anyway, | |
| 1422 | + | /// since [`update_timer_stages`] leaves their timer disabled. | |
| 1408 | 1423 | const UPDATE_IMAGE: &str = "quay.io/alloy/alloy:43"; | |
| 1409 | 1424 | ||
| 1410 | 1425 | /// The kernel command line parameter that overrides [`UPDATE_IMAGE`]. | |
| @@ -1462,6 +1477,62 @@ | |||
| 1462 | 1477 | .unwrap_or_else(|| UPDATE_IMAGE.to_string()) | |
| 1463 | 1478 | } | |
| 1464 | 1479 | ||
| 1480 | + | /// The timer that pulls and applies updates on its own. | |
| 1481 | + | /// | |
| 1482 | + | /// Ships with bootc, so it is present in every Alloy image already; what decides | |
| 1483 | + | /// whether a machine has over-the-air updates is whether anything enables it. | |
| 1484 | + | /// | |
| 1485 | + | /// Its cadence is bootc's own, taken as-is: an hour after boot, then every eight, | |
| 1486 | + | /// with a randomized delay of up to two. Read off the unit in the built image | |
| 1487 | + | /// rather than assumed. Alloy picking its own number is GO task 57628fd3, and | |
| 1488 | + | /// there is nothing to tune until updates reach machines that are not this one. | |
| 1489 | + | const UPDATE_TIMER: &str = "bootc-fetch-apply-updates.timer"; | |
| 1490 | + | ||
| 1491 | + | /// Whether this install should schedule its own updates. | |
| 1492 | + | /// | |
| 1493 | + | /// The medium naming an update target is the signal, because that is exactly what | |
| 1494 | + | /// a development build is: `build/build-iso.sh --update-target` points an ISO at a | |
| 1495 | + | /// registry that exists, and nothing else does. A machine installed from an | |
| 1496 | + | /// ordinary build has no reachable place to update from (see [`UPDATE_IMAGE`]), so | |
| 1497 | + | /// scheduling the attempt would buy a daily failure and a daily reach for the | |
| 1498 | + | /// network. | |
| 1499 | + | /// | |
| 1500 | + | /// An unreadable `/proc/cmdline` reads as no target, which leaves the timer alone. | |
| 1501 | + | /// The disk is already committed by the time this runs, and not enabling a timer | |
| 1502 | + | /// is the recoverable half of the choice: `systemctl enable` afterwards is one | |
| 1503 | + | /// command, where an update that fires on a machine nobody meant to be updating is | |
| 1504 | + | /// not something the user asked for. | |
| 1505 | + | fn updates_scheduled() -> bool { | |
| 1506 | + | std::fs::read_to_string("/proc/cmdline") | |
| 1507 | + | .ok() | |
| 1508 | + | .is_some_and(|cmdline| update_target_in(&cmdline).is_some()) | |
| 1509 | + | } | |
| 1510 | + | ||
| 1511 | + | /// Enable the update timer in the deployment at `root`, when this build has | |
| 1512 | + | /// somewhere to update from. | |
| 1513 | + | /// | |
| 1514 | + | /// `systemctl --root` rather than a chroot: this is systemd's own offline mode, | |
| 1515 | + | /// it writes the same `timers.target.wants` symlink the target would write for | |
| 1516 | + | /// itself, and it does not need the target's binaries to run under the live | |
| 1517 | + | /// system's kernel. | |
| 1518 | + | /// | |
| 1519 | + | /// Not a preset entry, which is where every other enable/disable decision in Alloy | |
| 1520 | + | /// lives (`etc/systemd/system-preset/50-alloy.preset`). A preset ships in the | |
| 1521 | + | /// image, and the image is the same image either way: the difference between a | |
| 1522 | + | /// development build and an ordinary one is the ISO's kernel command line, which | |
| 1523 | + | /// only the installer can read. So the decision has to be made here, at install | |
| 1524 | + | /// time, or not at all. | |
| 1525 | + | fn update_timer_stages(scheduled: bool, root: &str) -> Vec<Stage> { | |
| 1526 | + | if !scheduled { | |
| 1527 | + | return Vec::new(); | |
| 1528 | + | } | |
| 1529 | + | vec![Stage::Run( | |
| 1530 | + | Invocation::new("systemctl") | |
| 1531 | + | .arg(format!("--root={root}")) | |
| 1532 | + | .args(["enable", UPDATE_TIMER]), | |
| 1533 | + | )] | |
| 1534 | + | } | |
| 1535 | + | ||
| 1465 | 1536 | /// The `--source-imgref` for this run, or `None` when bootc's own default | |
| 1466 | 1537 | /// applies. | |
| 1467 | 1538 | /// | |
| @@ -1534,14 +1605,18 @@ | |||
| 1534 | 1605 | } | |
| 1535 | 1606 | // Unconditional, unlike --source-imgref above. That one is detected | |
| 1536 | 1607 | // because the source genuinely differs between the ISO and a dev | |
| 1537 | - | // container; where updates come from does not, and a machine | |
| 1538 | - | // installed during development wanting the same update stream as | |
| 1539 | - | // everyone else is correct rather than a side effect. | |
| 1608 | + | // container; where updates would come from does not. | |
| 1540 | 1609 | // | |
| 1541 | - | // The value is still overridable, but on the command line of the | |
| 1542 | - | // medium rather than by detection: media built for a tailnet | |
| 1543 | - | // registry install machines that update from it. See | |
| 1544 | - | // [`update_image`]. | |
| 1610 | + | // Unconditional does not mean every machine updates. Whether one | |
| 1611 | + | // ever asks is decided separately, by [`update_timer_stages`], and | |
| 1612 | + | // for an ordinary build the answer is no. What this flag prevents is | |
| 1613 | + | // the origin being written as a path off the live medium, which | |
| 1614 | + | // fails at reference parsing rather than saying anything true. See | |
| 1615 | + | // [`UPDATE_IMAGE`]. | |
| 1616 | + | // | |
| 1617 | + | // The value is overridable on the command line of the medium rather | |
| 1618 | + | // than by detection: media built for a tailnet registry install | |
| 1619 | + | // machines that update from it. See [`update_image`]. | |
| 1545 | 1620 | install = install.args(["--target-imgref", &update_image()]); | |
| 1546 | 1621 | // The only encryption `to-disk` offers, and the reason it is worth | |
| 1547 | 1622 | // taking as-is: the alternative is `to-filesystem`, which would make | |
| @@ -5485,6 +5560,44 @@ | |||
| 5485 | 5560 | ); | |
| 5486 | 5561 | } | |
| 5487 | 5562 | ||
| 5563 | + | // Over-the-air updates are a development-build feature: only media that | |
| 5564 | + | // names a registry it can actually reach leaves the machine asking for | |
| 5565 | + | // updates. An ordinary build installs a target reference it can never fetch | |
| 5566 | + | // (see UPDATE_IMAGE) and must not schedule the attempt. | |
| 5567 | + | #[test] | |
| 5568 | + | fn an_ordinary_build_schedules_no_updates() { | |
| 5569 | + | assert!(update_timer_stages(false, "/mnt/target/ostree/deploy/x").is_empty()); | |
| 5570 | + | } | |
| 5571 | + | ||
| 5572 | + | #[test] | |
| 5573 | + | fn a_build_with_an_update_target_enables_the_timer() { | |
| 5574 | + | let stages = update_timer_stages(true, "/mnt/target/ostree/deploy/x"); | |
| 5575 | + | let rendered: Vec<String> = stages.iter().map(Stage::display).collect(); | |
| 5576 | + | assert_eq!( | |
| 5577 | + | rendered, | |
| 5578 | + | vec![format!( | |
| 5579 | + | "systemctl --root=/mnt/target/ostree/deploy/x enable {UPDATE_TIMER}" | |
| 5580 | + | )], | |
| 5581 | + | ); | |
| 5582 | + | } | |
| 5583 | + | ||
| 5584 | + | // The timer has to be written into the deployment rather than the mountpoint, | |
| 5585 | + | // for the reason documented on deployment_dir: the mountpoint's own /etc is | |
| 5586 | + | // the sysroot's, which is not the /etc the booted system reads. | |
| 5587 | + | #[test] | |
| 5588 | + | fn the_timer_is_enabled_in_the_deployment_not_the_mountpoint() { | |
| 5589 | + | let stages = update_timer_stages(true, "/mnt/target/ostree/deploy/alloy/deploy/abc.0"); | |
| 5590 | + | let rendered = Stage::display(&stages[0]); | |
| 5591 | + | assert!( | |
| 5592 | + | rendered.contains("--root=/mnt/target/ostree/deploy/"), | |
| 5593 | + | "{rendered}" | |
| 5594 | + | ); | |
| 5595 | + | assert!( | |
| 5596 | + | !rendered.contains(&format!("--root={TARGET_MOUNT} ")), | |
| 5597 | + | "{rendered}" | |
| 5598 | + | ); | |
| 5599 | + | } | |
| 5600 | + | ||
| 5488 | 5601 | // Missing answers cannot happen from the summary — every step gates on its | |
| 5489 | 5602 | // own validation — but a partial plan would render a command with a hole in | |
| 5490 | 5603 | // it, so it returns nothing instead. |