Skip to main content

max / alloy

Schedule updates only where updates can be fetched Over-the-air updates are a development-build feature for now. The eventual home for updates a stranger's machine can reach is a service over synckit/MNW, not the public registry UPDATE_IMAGE names, and nothing is or will be published there. The signal is the medium naming an update target, since that is what a development build is: build-iso.sh --update-target points an ISO at a registry that exists, and nothing else does. When it is named, the installer enables bootc-fetch-apply-updates.timer in the deployment; when it is not, the timer stays disabled and the machine never reaches out. The timer needs no packaging: it ships with bootc and is already in every Alloy image, disabled. It cannot be a preset entry either, because the preset ships in the image and the image is the same either way. The kernel command line is the difference, and only the installer reads it. --target-imgref stays unconditional. Dropping it writes the source path off the live medium into the origin, which fails at reference parsing instead of saying anything true; an address resolving to nothing degrades to "no update found".
Author: Max Johnson <me@maxj.phd> · 2026-07-30 15:53 UTC
Signed with PGP, not checked
Commit: 85ee0e66ee5e917d3d379042c3e2c684ef34bd92
Parent: 1952533
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.