Skip to main content

max / alloy

install: discover the deployment instead of assuming it Finishes the correction from the previous commit. The plan is no longer a flat list, because two of its arguments cannot be written in advance. bootc decides which partition holds the new root while it partitions, and names the ostree deployment after a checksum that does not exist until the deploy finishes. Both have to be read out of a command's output, and the second is only reachable once the first one's answer has been mounted. So run.rs grows Stage: either a command, or a command plus a resolver that builds what follows from its stdout. Resolvers return Stages rather than Invocations so discovery nests, which is exactly the shape here — find the partition, mount it, then ask ostree where the deployment is. A resolver sees only its own command's output, not the transcript so far, or the second discovery would be parsing the first one's lines too. It does not run at all for a command that failed, since its input would be whatever the command managed to print before dying. Its Err fails the sequence with that message, which is how "no root partition" becomes something the run screen says rather than a mount failing later about a device that does not exist. Finding the root partition is by DPS partition type GUID. As of bootc 1.11 the default layout follows the Discoverable Partitions Specification, setting "the appropriate DPS partition type GUID based on the target architecture" — there is no filesystem label to match on. The GUID differs per architecture and Alloy builds natively for both of its targets, so it is a cfg!, resolved at compile time because the installer runs on the machine it installs. Matching on the GUID rather than on position or size: "the third partition" and "the largest one" are both true of a default layout today and neither is a promise. The configuration commands now target the deployment directory rather than the mountpoint, which was the actual bug. A test asserts every configuring command's --root contains /ostree/deploy/, since a hostname written to the sysroot is read by nothing and produces a system that boots wrong with no error. finalize is followed by umount. Leaving the target mounted would strand the filesystem dirty across the reboot the user is about to perform. The summary now shows three lines and stops at the first discovery. It cannot honestly show more: everything past that point depends on values that do not exist yet, and rendering invented arguments would be the summary claiming to know things it does not. A test holds that no undiscovered command appears there. Still unrun against real bootc. The shape follows upstream's documentation; the one part that is a guess rather than a quote is finalize's arguments, which that page does not document, and the module header says so. 232 tests pass, 7 ignored, clippy clean. cli.rs still has its three pre-existing rustfmt diffs and no new ones. Sources: bootc.dev/bootc/bootc-install.html, bootc-install-to-disk(8).
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 01:13 UTC
Signed with PGP, not checked
Commit: faa98e9278caa1c684d7e77f9cedcff0826b34c2
Parent: cb7e962
2 files changed, +460 insertions, -106 deletions
@@ -11,18 +11,22 @@
11 11 //! The install runs through [`Sequence`], so the frame keeps drawing for the
12 12 //! minutes `bootc` takes.
13 13 //!
14 - //! **One thing is known-wrong rather than merely unverified**, and it is worth
15 - //! reading before trusting this file. The configuration commands are pointed at
16 - //! [`TARGET_MOUNT`], but an ostree system's `/etc` is not at `<mount>/etc` — it
17 - //! is inside the deployment, at `<mount>/ostree/deploy/<stateroot>/deploy/
18 - //! <checksum>/`, and that checksum is not knowable in advance. As written,
19 - //! `systemd-firstboot` would write a hostname where nothing reads it and the
20 - //! install would boot with none of the answers applied and no error to say so.
14 + //! Two of the install's arguments cannot be written in advance, so the plan is
15 + //! [`Stage`]s rather than a flat list. bootc decides which partition holds the
16 + //! new root while it partitions, and names the ostree deployment after a
17 + //! checksum that does not exist until the deploy finishes. Both are discovered
18 + //! by running a command and reading its output, and the second discovery is
19 + //! only possible after the first one's result has been mounted.
21 20 //!
22 - //! [`deployment_dir`] is the documented way out and is implemented, but not
23 - //! wired in: using it makes the plan dynamic, since the arguments to one
24 - //! command come from the output of another, and [`Sequence`] runs a fixed list.
25 - //! Closing that is the remaining work.
21 + //! The deployment directory is the part worth understanding. An ostree system's
22 + //! `/etc` is not at `<mount>/etc`; it is inside the deployment, at
23 + //! `<mount>/ostree/deploy/<stateroot>/deploy/<checksum>/`. Configuring the
24 + //! mountpoint instead would write a hostname where nothing reads it and boot a
25 + //! system with none of the answers applied and no error to say why.
26 + //!
27 + //! **Nothing here has run against real bootc.** The shape follows upstream's
28 + //! documentation, and the pieces that are guesses rather than quotes are the
29 + //! arguments to `bootc install finalize`, which that page does not document.
26 30 //!
27 31 //! <!-- wiki: alloy-console -->
28 32
@@ -40,7 +44,7 @@
40 44
41 45 use crate::cli::{CommandLog, Invocation, Secret};
42 46 use crate::field::TextField;
43 - use crate::run::Sequence;
47 + use crate::run::{Sequence, Stage};
44 48 use crate::shell::{Confirm, Flow, View, block_title};
45 49 use crate::wizard::Steps;
46 50
@@ -231,15 +235,6 @@
231 235 /// deployment directory." That makes the configuration target a value
232 236 /// discovered at install time rather than a constant, which is the structural
233 237 /// change this function represents.
234 - ///
235 - /// Not yet wired into [`install_plan`], which still passes [`TARGET_MOUNT`]
236 - /// straight to `--root`. Doing it properly means the plan can no longer be a
237 - /// flat list built up front: the arguments to `systemd-firstboot` depend on the
238 - /// output of a command that has not run yet, so [`Sequence`] needs to carry a
239 - /// step whose result parameterizes the steps behind it. That is the one piece
240 - /// of the installer still outstanding, and it is a known wrong answer rather
241 - /// than an unknown one — see the module header.
242 - #[allow(dead_code)]
243 238 fn deployment_dir(mount: &str) -> Invocation {
244 239 Invocation::new("ostree")
245 240 .arg("admin")
@@ -258,20 +253,78 @@
258 253 ///
259 254 /// `chpasswd` takes the password on stdin rather than as an argument. See
260 255 /// [`Secret`] for why argv is not an option.
261 - fn install_plan(
262 - disk: &str,
263 - hostname: &str,
264 - username: &str,
265 - password: &str,
266 - root: &str,
267 - ) -> Vec<Invocation> {
256 + /// DPS partition type GUID for the root filesystem on this architecture.
257 + ///
258 + /// From bootc 1.11 the default layout follows the Discoverable Partitions
259 + /// Specification, setting "the appropriate DPS partition type GUID based on the
260 + /// target architecture" rather than a filesystem label. So the root partition is
261 + /// identified by type GUID, which `lsblk` reports as `PARTTYPE`.
262 + ///
263 + /// Per-architecture because the specification assigns a different GUID to each,
264 + /// and Alloy builds natively for both of its targets. `cfg!` rather than a
265 + /// runtime check: the installer runs on the machine it is installing.
266 + const ROOT_PARTITION_TYPE: &str = if cfg!(target_arch = "aarch64") {
267 + "b921b045-1df0-41c3-af44-4c6f280d3fae"
268 + } else {
269 + "4f68bce3-e8cd-4db1-96e7-fbcaf984b709"
270 + };
271 +
272 + /// Ask lsblk for the partitions of `disk` and their type GUIDs.
273 + fn partition_types(disk: &str) -> Invocation {
274 + Invocation::new("lsblk")
275 + .args(["-J", "-o", "PATH,PARTTYPE"])
276 + .arg(disk)
277 + }
278 +
279 + /// Pick the root partition out of an `lsblk -o PATH,PARTTYPE` listing.
280 + ///
281 + /// Matching on the DPS type GUID rather than on position or size: "the third
282 + /// one" and "the biggest one" are both true of a default bootc layout today and
283 + /// neither is a promise. The GUID is the thing bootc actually sets.
284 + fn root_partition(listing: &str) -> Result<String, String> {
285 + #[derive(Deserialize)]
286 + struct Listing {
287 + blockdevices: Vec<Node>,
288 + }
289 + #[derive(Deserialize)]
290 + struct Node {
291 + path: String,
292 + #[serde(default)]
293 + parttype: Option<String>,
294 + #[serde(default)]
295 + children: Vec<Node>,
296 + }
297 +
298 + fn find(nodes: &[Node]) -> Option<String> {
299 + for node in nodes {
300 + if node
301 + .parttype
302 + .as_deref()
303 + .is_some_and(|t| t.eq_ignore_ascii_case(ROOT_PARTITION_TYPE))
304 + {
305 + return Some(node.path.clone());
306 + }
307 + if let Some(found) = find(&node.children) {
308 + return Some(found);
309 + }
310 + }
311 + None
312 + }
313 +
314 + let parsed: Listing = serde_json::from_str(listing)
315 + .map_err(|err| format!("lsblk emitted invalid JSON: {err}"))?;
316 +
317 + find(&parsed.blockdevices).ok_or_else(|| {
318 + format!("no root partition ({ROOT_PARTITION_TYPE}) found after install; disk not deployed")
319 + })
320 + }
321 +
322 + /// The commands that configure an already-deployed system.
323 + ///
324 + /// `root` is the ostree deployment directory, not the mountpoint. See
325 + /// [`deployment_dir`] for why that distinction is the whole ballgame.
326 + fn configure_plan(hostname: &str, username: &str, password: &str, root: &str) -> Vec<Invocation> {
268 327 vec![
269 - // --wipe is explicit rather than implied by the confirm the user just
270 - // answered: the flag that destroys the disk should be visible on the
271 - // line the summary displays, not hidden in a default.
272 - Invocation::new("bootc")
273 - .args(["install", "to-disk", "--wipe"])
274 - .arg(disk),
275 328 Invocation::new("systemd-firstboot")
276 329 .arg(format!("--root={root}"))
277 330 .arg(format!("--hostname={hostname}"))
@@ -293,13 +346,67 @@
293 346 Invocation::new("chpasswd")
294 347 .args(["--root", root])
295 348 .stdin(Secret::new(format!("{username}:{password}\n"))),
296 - // Upstream: tools that make changes should run this "as the penultimate
297 - // step before unmounting the target filesystem". Skipping it is the
298 - // kind of omission that produces a system which boots and is subtly
299 - // wrong, so it is in the plan rather than in a comment about the plan.
349 + // Upstream: "optional, but recommended to run as the penultimate step
350 + // before unmounting the target filesystem. This command will perform
351 + // some basic sanity checks and may also perform fixups on the target
352 + // root." Its arguments are not documented on that page; the mountpoint
353 + // is passed positionally, which is the one thing it plausibly wants and
354 + // is flagged in the module header as unverified.
300 355 Invocation::new("bootc")
301 356 .args(["install", "finalize"])
302 - .arg(format!("--root={TARGET_MOUNT}")),
357 + .arg(TARGET_MOUNT),
358 + // Leaving the target mounted would strand the filesystem dirty across
359 + // the reboot the user is about to perform.
360 + Invocation::new("umount").arg(TARGET_MOUNT),
361 + ]
362 + }
363 +
364 + /// The whole install, as stages.
365 + ///
366 + /// Two discoveries, nested. bootc decides which partition holds the new root
367 + /// while it partitions, and names the ostree deployment after a checksum that
368 + /// does not exist until the deploy finishes. Neither can be an argument written
369 + /// in advance, which is why this returns [`Stage`]s rather than a flat list.
370 + fn install_plan(disk: &str, hostname: &str, username: &str, password: &str) -> Vec<Stage> {
371 + let hostname = hostname.to_string();
372 + let username = username.to_string();
373 + let password = password.to_string();
374 +
375 + vec![
376 + // --wipe is explicit rather than implied by the confirm the user just
377 + // answered: the flag that destroys the disk should be visible on the
378 + // line the summary displays, not hidden in a default.
379 + Stage::Run(
380 + Invocation::new("bootc")
381 + .args(["install", "to-disk", "--wipe"])
382 + .arg(disk),
383 + ),
384 + Stage::Run(Invocation::new("mkdir").args(["-p", TARGET_MOUNT])),
385 + // First discovery: which partition bootc made the root.
386 + Stage::Resolve {
387 + invocation: partition_types(disk),
388 + then: Box::new(move |listing| {
389 + let partition = root_partition(listing)?;
390 + Ok(vec![
391 + Stage::Run(Invocation::new("mount").arg(partition).arg(TARGET_MOUNT)),
392 + // Second discovery, only possible once mounted: where the
393 + // deployment actually is inside the sysroot.
394 + Stage::Resolve {
395 + invocation: deployment_dir(TARGET_MOUNT),
396 + then: Box::new(move |printed| {
397 + let deployment = printed.trim();
398 + if deployment.is_empty() {
399 + return Err("ostree reported no current deployment".into());
400 + }
401 + Ok(configure_plan(&hostname, &username, &password, deployment)
402 + .into_iter()
403 + .map(Stage::Run)
404 + .collect())
405 + }),
406 + },
407 + ])
408 + }),
409 + },
303 410 ]
304 411 }
305 412
@@ -961,7 +1068,7 @@
961 1068 /// Empty if an answer is missing, which cannot happen from the summary step
962 1069 /// — every earlier step gates on its own validation — but returning nothing
963 1070 /// beats rendering a command line with a hole in it.
964 - fn plan(&self) -> Vec<Invocation> {
1071 + fn plan(&self) -> Vec<Stage> {
965 1072 let (Some(disk), Some(hostname), Some(username)) = (
966 1073 self.answers.disk.as_deref(),
967 1074 self.answers.hostname.as_deref(),
@@ -970,13 +1077,7 @@
970 1077 return Vec::new();
971 1078 };
972 1079
973 - install_plan(
974 - disk,
975 - hostname,
976 - username,
977 - self.password.value(),
978 - TARGET_MOUNT,
979 - )
1080 + install_plan(disk, hostname, username, self.password.value())
980 1081 }
981 1082
982 1083 /// The hostname pane: a prompt, the field with its caret, and what the
@@ -1741,74 +1842,146 @@
1741 1842 // ---- the plan ----
1742 1843
1743 1844 #[test]
1744 - fn the_plan_runs_bootc_first_then_configures_the_deployed_root() {
1845 + fn the_plan_starts_with_the_deploy_then_discovers() {
1745 1846 let (view, _log) = at_summary();
1746 - let shown: Vec<String> = view.plan().iter().map(Invocation::display).collect();
1847 + let shown: Vec<String> = view.plan().iter().map(Stage::display).collect();
1747 1848
1748 - assert_eq!(shown.len(), 5, "{shown:#?}");
1849 + assert_eq!(shown.len(), 3, "{shown:#?}");
1749 1850 assert_eq!(shown[0], "bootc install to-disk --wipe /dev/sda");
1750 - assert!(shown[1].starts_with("systemd-firstboot"), "{}", shown[1]);
1751 - assert!(shown[2].starts_with("useradd"), "{}", shown[2]);
1752 - assert!(shown[3].starts_with("chpasswd"), "{}", shown[3]);
1851 + assert_eq!(shown[1], "mkdir -p /mnt/alloy-target");
1852 + assert!(shown[2].starts_with("lsblk"), "{}", shown[2]);
1753 1853 }
1754 1854
1755 - // The whole reason Secret exists. The summary renders these lines and the
1756 - // log pane records them.
1855 + // The summary can only show what is known before anything runs. Everything
1856 + // past the first discovery depends on values that do not exist yet, and
1857 + // inventing lines for them would be the summary claiming to know more than
1858 + // it does.
1859 + #[test]
1860 + fn the_plan_shown_up_front_stops_at_the_first_discovery() {
1861 + let (view, _log) = at_summary();
1862 + let shown: Vec<String> = view.plan().iter().map(Stage::display).collect();
1863 +
1864 + assert!(
1865 + !shown.iter().any(|line| line.contains("firstboot")),
1866 + "a command with undiscovered arguments was shown: {shown:#?}"
1867 + );
1868 + }
1869 +
1870 + // The whole reason Secret exists: the summary renders these and the log
1871 + // pane records them.
1757 1872 #[test]
1758 1873 fn no_line_of_the_plan_carries_the_password() {
1759 1874 let (view, _log) = at_summary();
1760 - for invocation in view.plan() {
1875 + for stage in view.plan() {
1876 + assert!(!stage.display().contains("hunter2"), "{}", stage.display());
1877 + }
1878 + for invocation in configure_plan("alloy", "max", "hunter2", "/deploy") {
1761 1879 let shown = invocation.display();
1762 1880 assert!(!shown.contains("hunter2"), "password on screen: {shown}");
1763 1881 }
1764 1882 }
1765 1883
1884 + /// The configure half, against a deployment directory as discovered.
1885 + fn configured() -> Vec<String> {
1886 + configure_plan(
1887 + "workshop",
1888 + "max",
1889 + "hunter2",
1890 + "/mnt/alloy-target/ostree/deploy/x/deploy/abc",
1891 + )
1892 + .iter()
1893 + .map(Invocation::display)
1894 + .collect()
1895 + }
1896 +
1766 1897 // --force matters: etc/hostname ships with "alloy" already in it, and
1767 - // firstboot skips any setting that is already present. Without the flag the
1768 - // user's answer is silently discarded.
1898 + // firstboot skips any setting already present. Without the flag the user's
1899 + // answer is silently discarded.
1769 1900 #[test]
1770 1901 fn firstboot_forces_over_the_images_baked_in_hostname() {
1771 - let (view, _log) = at_summary();
1772 - let firstboot = view.plan().remove(1).display();
1902 + let firstboot = configured().remove(0);
1773 1903
1774 - assert!(firstboot.contains("--hostname=alloy"), "{firstboot}");
1904 + assert!(firstboot.contains("--hostname=workshop"), "{firstboot}");
1775 1905 assert!(firstboot.contains("--force"), "{firstboot}");
1776 - assert!(firstboot.contains(TARGET_MOUNT), "{firstboot}");
1906 + }
1907 +
1908 + // The correction that started all this: configuration is pointed at the
1909 + // ostree deployment, never at the mountpoint. A hostname written to the
1910 + // sysroot is read by nothing.
1911 + #[test]
1912 + fn configuration_targets_the_deployment_not_the_mountpoint() {
1913 + for line in configured() {
1914 + if line.starts_with("bootc") || line.starts_with("umount") {
1915 + continue;
1916 + }
1917 + assert!(
1918 + line.contains("/ostree/deploy/"),
1919 + "configured the sysroot instead of the deployment: {line}"
1920 + );
1921 + }
1777 1922 }
1778 1923
1779 1924 // An account that cannot escalate leaves an install with no way to
1780 - // administer itself, and -m is what makes the home directory exist.
1925 + // administer itself, and --create-home is what makes the home exist.
1781 1926 #[test]
1782 1927 fn the_account_gets_a_home_and_a_way_to_sudo() {
1783 - let (view, _log) = at_summary();
1784 - let useradd = view.plan().remove(2).display();
1928 + let useradd = configured().remove(1);
1785 1929
1786 1930 assert!(useradd.contains("--create-home"), "{useradd}");
1787 1931 assert!(useradd.contains("wheel"), "{useradd}");
1788 1932 assert!(useradd.ends_with("max"), "{useradd}");
1789 1933 }
1790 1934
1791 - // Upstream tells installers that make changes to run this "as the
1792 - // penultimate step before unmounting the target filesystem". Omitting it
1793 - // yields a system that boots and is subtly wrong, which is the worst kind
1794 - // of missing step.
1935 + // Upstream tells installers that make changes to run finalize before
1936 + // unmounting, and leaving the target mounted would strand it dirty across
1937 + // the reboot.
1795 1938 #[test]
1796 - fn the_plan_finalizes_before_the_target_is_unmounted() {
1797 - let (view, _log) = at_summary();
1798 - let last = view.plan().pop().expect("a plan").display();
1799 - assert!(last.starts_with("bootc install finalize"), "{last}");
1939 + fn the_target_is_finalized_and_then_unmounted() {
1940 + let lines = configured();
1941 + let finalize = &lines[lines.len() - 2];
1942 + let last = &lines[lines.len() - 1];
1943 +
1944 + assert!(finalize.starts_with("bootc install finalize"), "{finalize}");
1945 + assert!(last.starts_with("umount"), "{last}");
1800 1946 }
1801 1947
1802 - // The deployment directory is discovered, never assumed. An ostree /etc is
1803 - // at <mount>/ostree/deploy/<stateroot>/deploy/<checksum>/etc, and that
1804 - // checksum cannot be known in advance.
1948 + // ---- discovering the root partition ----
1949 +
1950 + // Matched on the DPS type GUID rather than position or size: "the third
1951 + // one" and "the biggest one" are both true of a default bootc layout and
1952 + // neither is a promise.
1805 1953 #[test]
1806 - fn the_deployment_directory_is_discovered_from_the_mounted_target() {
1807 - let shown = deployment_dir(TARGET_MOUNT).display();
1808 - assert_eq!(
1809 - shown,
1810 - format!("ostree admin --sysroot={TARGET_MOUNT} --print-current-dir")
1954 + fn the_root_partition_is_found_by_its_dps_type_guid() {
1955 + let listing = format!(
1956 + r#"{{"blockdevices":[{{"path":"/dev/sda","parttype":null,"children":[
1957 + {{"path":"/dev/sda1","parttype":"c12a7328-f81f-11d2-ba4b-00a0c93ec93b"}},
1958 + {{"path":"/dev/sda2","parttype":"{ROOT_PARTITION_TYPE}"}}]}}]}}"#
1811 1959 );
1960 + assert_eq!(root_partition(&listing).unwrap(), "/dev/sda2");
1961 + }
1962 +
1963 + // lsblk reports GUIDs lowercase, but the specification writes them upper.
1964 + #[test]
1965 + fn the_type_guid_match_ignores_case() {
1966 + let listing = format!(
1967 + r#"{{"blockdevices":[{{"path":"/dev/sda1","parttype":"{}"}}]}}"#,
1968 + ROOT_PARTITION_TYPE.to_uppercase()
1969 + );
1970 + assert_eq!(root_partition(&listing).unwrap(), "/dev/sda1");
1971 + }
1972 +
1973 + // A deploy that did not happen must say so here, rather than letting mount
1974 + // fail with something about a device that does not exist.
1975 + #[test]
1976 + fn no_root_partition_is_a_named_failure() {
1977 + let listing = r#"{"blockdevices":[{"path":"/dev/sda1","parttype":null}]}"#;
1978 + let error = root_partition(listing).unwrap_err();
1979 + assert!(error.contains("no root partition"), "{error}");
1980 + }
1981 +
1982 + #[test]
1983 + fn a_malformed_listing_is_a_named_failure() {
1984 + assert!(root_partition("not json").unwrap_err().contains("JSON"));
1812 1985 }
1813 1986
1814 1987 // Missing answers cannot happen from the summary — every step gates on its
@@ -1833,7 +2006,8 @@
1833 2006
1834 2007 let sequence = view.running.as_ref().expect("nothing was queued");
1835 2008 assert!(!sequence.is_done());
1836 - assert_eq!(sequence.progress(), (0, 5));
2009 + // Three up front; the rest appear as the discoveries resolve.
2010 + assert_eq!(sequence.progress(), (0, 3));
1837 2011 assert!(sequence.output().is_empty(), "a command ran during confirm");
1838 2012 }
1839 2013
@@ -28,9 +28,60 @@
28 28 /// the interesting lines during a failure are the last ones.
29 29 const SCROLLBACK: usize = 500;
30 30
31 + /// A command, and optionally what its output decides.
32 + ///
33 + /// The installer needs this because two of its arguments cannot be known when
34 + /// the plan is built. Which partition holds the new root is decided by bootc
35 + /// while it partitions, and the ostree deployment directory is named after a
36 + /// checksum that does not exist until the deploy finishes. Both are discovered
37 + /// by running a command and reading its output.
38 + ///
39 + /// A resolver returns [`Stage`]s rather than [`Invocation`]s so discovery can
40 + /// nest: finding the partition produces a mount, and mounting makes the second
41 + /// discovery possible.
42 + pub enum Stage {
43 + /// Run it. Nothing downstream depends on what it prints.
44 + Run(Invocation),
45 + /// Run it, then let `then` build what follows from its stdout.
46 + ///
47 + /// The resolver's `Err` fails the sequence with that message, which is how
48 + /// "the root partition is not where it should be" becomes something the run
49 + /// screen can say rather than a later command failing obscurely.
50 + Resolve {
51 + invocation: Invocation,
52 + then: Resolver,
53 + },
54 + }
55 +
56 + impl Stage {
57 + fn invocation(&self) -> &Invocation {
58 + match self {
59 + Self::Run(invocation) | Self::Resolve { invocation, .. } => invocation,
60 + }
61 + }
62 +
63 + /// The command line, for a summary shown before anything runs.
64 + ///
65 + /// A [`Resolve`](Self::Resolve) displays as its own command only. What it
66 + /// decides has not been decided yet, and inventing a line for a command
67 + /// whose arguments do not exist would be the summary lying about what it
68 + /// knows. The installer's summary says so in the surrounding copy instead.
69 + pub fn display(&self) -> String {
70 + self.invocation().display()
71 + }
72 + }
73 +
74 + /// Builds the stages that follow, from the captured stdout of the one before.
75 + pub type Resolver = Box<dyn FnOnce(&str) -> Result<Vec<Stage>, String>>;
76 +
31 77 /// One running child and the lines it has produced.
32 78 struct Running {
33 79 child: Child,
80 + /// Where this command's own output starts in the collected lines, so a
81 + /// resolver sees only what its command printed.
82 + first_line: usize,
83 + /// What to do with this command's output, for a [`Stage::Resolve`].
84 + then: Option<Resolver>,
34 85 /// Lines from stdout and stderr, interleaved in arrival order.
35 86 ///
36 87 /// Both streams feed one channel because that is how they appear on a
@@ -71,7 +122,7 @@
71 122 /// deploy would be writing into a tree that is not there, so a failure stops
72 123 /// everything after it.
73 124 pub struct Sequence {
74 - queue: VecDeque<Invocation>,
125 + queue: VecDeque<Stage>,
75 126 current: Option<Running>,
76 127 output: Vec<String>,
77 128 outcome: Option<Result<(), String>>,
@@ -86,9 +137,9 @@
86 137 /// the first tick after the view appears rather than during construction.
87 138 /// That way the run screen is on screen before anything runs, instead of
88 139 /// the first command's output arriving for a pane nobody has seen yet.
89 - pub fn new(invocations: Vec<Invocation>) -> Self {
140 + pub fn new(stages: Vec<Stage>) -> Self {
90 141 Self {
91 - queue: invocations.into(),
142 + queue: stages.into(),
92 143 current: None,
93 144 output: Vec::new(),
94 145 outcome: None,
@@ -150,18 +201,32 @@
150 201 // lines of a failing command — the ones that say why — are
151 202 // the ones that get lost.
152 203 let mut lines = std::mem::take(&mut self.output);
153 - self.current
154 - .as_ref()
155 - .expect("current was Some")
156 - .drain(&mut lines);
204 + let mut finished = self.current.take().expect("current was Some");
205 + finished.drain(&mut lines);
157 206 self.output = lines;
158 - self.current = None;
159 207 self.done_count += 1;
160 208
161 209 if !status.success() {
162 210 self.fail(format!("command exited with {status}"));
163 211 return;
164 212 }
213 +
214 + if let Some(resolve) = finished.then.take() {
215 + let captured = self.output[finished.first_line..].join("\n");
216 + match resolve(&captured) {
217 + // Pushed to the front: what a discovery decides
218 + // runs before whatever was queued behind it.
219 + Ok(stages) => {
220 + for stage in stages.into_iter().rev() {
221 + self.queue.push_front(stage);
222 + }
223 + }
224 + Err(message) => {
225 + self.fail(message);
226 + return;
227 + }
228 + }
229 + }
165 230 }
166 231 }
167 232 }
@@ -170,13 +235,21 @@
170 235 }
171 236
172 237 fn start_next(&mut self, log: &mut CommandLog) {
173 - let Some(invocation) = self.queue.pop_front() else {
238 + let Some(stage) = self.queue.pop_front() else {
174 239 self.outcome = Some(Ok(()));
175 240 return;
176 241 };
177 242
178 - match spawn(&invocation, log) {
179 - Ok(running) => self.current = Some(running),
243 + let first_line = self.output.len();
244 + match spawn(stage.invocation(), log) {
245 + Ok(mut running) => {
246 + running.first_line = first_line;
247 + running.then = match stage {
248 + Stage::Run(_) => None,
249 + Stage::Resolve { then, .. } => Some(then),
250 + };
251 + self.current = Some(running);
252 + }
180 253 Err(err) => self.fail(err.to_string()),
181 254 }
182 255 }
@@ -208,6 +281,8 @@
208 281
209 282 Ok(Running {
210 283 child,
284 + first_line: 0,
285 + then: None,
211 286 lines: receiver,
212 287 })
213 288 }
@@ -247,8 +322,8 @@
247 322 fn a_sequence_runs_every_command_in_order() {
248 323 let mut log = CommandLog::new();
249 324 let mut sequence = Sequence::new(vec![
250 - Invocation::new("echo").arg("first"),
251 - Invocation::new("echo").arg("second"),
325 + Stage::Run(Invocation::new("echo").arg("first")),
326 + Stage::Run(Invocation::new("echo").arg("second")),
252 327 ]);
253 328
254 329 drive(&mut sequence, &mut log);
@@ -263,8 +338,8 @@
263 338 fn a_failure_stops_the_commands_behind_it() {
264 339 let mut log = CommandLog::new();
265 340 let mut sequence = Sequence::new(vec![
266 - Invocation::new("false"),
267 - Invocation::new("echo").arg("must not run"),
341 + Stage::Run(Invocation::new("false")),
342 + Stage::Run(Invocation::new("echo").arg("must not run")),
268 343 ]);
269 344
270 345 drive(&mut sequence, &mut log);
@@ -282,9 +357,9 @@
282 357 #[test]
283 358 fn stderr_is_captured_alongside_stdout() {
284 359 let mut log = CommandLog::new();
285 - let mut sequence = Sequence::new(vec![
360 + let mut sequence = Sequence::new(vec![Stage::Run(
286 361 Invocation::new("sh").args(["-c", "echo out; echo problem >&2"]),
287 - ]);
362 + )]);
288 363
289 364 drive(&mut sequence, &mut log);
290 365
@@ -298,9 +373,9 @@
298 373 #[test]
299 374 fn the_last_lines_before_exit_are_not_lost() {
300 375 let mut log = CommandLog::new();
301 - let mut sequence = Sequence::new(vec![
376 + let mut sequence = Sequence::new(vec![Stage::Run(
302 377 Invocation::new("sh").args(["-c", "echo a; echo b; echo c"]),
303 - ]);
378 + )]);
304 379
305 380 drive(&mut sequence, &mut log);
306 381
@@ -310,7 +385,112 @@
310 385 #[test]
311 386 fn a_command_that_does_not_exist_fails_the_sequence() {
312 387 let mut log = CommandLog::new();
313 - let mut sequence = Sequence::new(vec![Invocation::new("alloy-no-such-command-exists")]);
388 + let mut sequence = Sequence::new(vec![Stage::Run(Invocation::new(
389 + "alloy-no-such-command-exists",
390 + ))]);
391 +
392 + drive(&mut sequence, &mut log);
393 +
394 + assert!(sequence.outcome().expect("stopped").is_err());
395 + }
396 +
397 + // The mechanism the installer's two discoveries rest on: a command's output
398 + // decides the commands that follow it.
399 + #[test]
400 + fn a_resolver_builds_what_follows_from_the_output() {
401 + let mut log = CommandLog::new();
402 + let mut sequence = Sequence::new(vec![Stage::Resolve {
403 + invocation: Invocation::new("echo").arg("world"),
404 + then: Box::new(|captured| {
405 + Ok(vec![Stage::Run(
406 + Invocation::new("echo").arg(format!("hello {}", captured.trim())),
407 + )])
408 + }),
409 + }]);
410 +
411 + drive(&mut sequence, &mut log);
412 +
413 + assert_eq!(sequence.outcome(), Some(&Ok(())));
414 + assert_eq!(sequence.output(), ["world", "hello world"]);
415 + }
416 +
417 + // Nesting: the installer finds a partition, mounts it, and only then can
418 + // ask where the deployment is.
419 + #[test]
420 + fn a_resolver_can_produce_another_resolver() {
421 + let mut log = CommandLog::new();
422 + let mut sequence = Sequence::new(vec![Stage::Resolve {
423 + invocation: Invocation::new("echo").arg("one"),
424 + then: Box::new(|first| {
425 + let first = first.trim().to_string();
426 + Ok(vec![Stage::Resolve {
427 + invocation: Invocation::new("echo").arg("two"),
428 + then: Box::new(move |second| {
429 + Ok(vec![Stage::Run(
430 + Invocation::new("echo").arg(format!("{first}-{}", second.trim())),
431 + )])
432 + }),
433 + }])
434 + }),
435 + }]);
436 +
437 + drive(&mut sequence, &mut log);
438 +
439 + assert_eq!(sequence.output(), ["one", "two", "one-two"]);
440 + }
441 +
442 + // A resolver sees only its own command's output, not everything printed so
443 + // far. Otherwise the second discovery would parse the first one's lines too.
444 + #[test]
445 + fn a_resolver_sees_only_its_own_commands_output() {
446 + let mut log = CommandLog::new();
447 + let mut sequence = Sequence::new(vec![
448 + Stage::Run(Invocation::new("echo").arg("earlier noise")),
449 + Stage::Resolve {
450 + invocation: Invocation::new("echo").arg("mine"),
451 + then: Box::new(|captured| {
452 + assert_eq!(captured.trim(), "mine", "saw another command's output");
453 + Ok(Vec::new())
454 + }),
455 + },
456 + ]);
457 +
458 + drive(&mut sequence, &mut log);
459 +
460 + assert_eq!(sequence.outcome(), Some(&Ok(())));
461 + }
462 +
463 + // A resolver that cannot make sense of the output fails the sequence with
464 + // its own message, rather than letting a later command fail obscurely.
465 + #[test]
466 + fn a_resolver_can_fail_the_sequence_with_a_reason() {
467 + let mut log = CommandLog::new();
468 + let mut sequence = Sequence::new(vec![
469 + Stage::Resolve {
470 + invocation: Invocation::new("echo").arg("nothing useful"),
471 + then: Box::new(|_| Err("no root partition found".into())),
472 + },
473 + Stage::Run(Invocation::new("echo").arg("must not run")),
474 + ]);
475 +
476 + drive(&mut sequence, &mut log);
477 +
478 + let Some(Err(message)) = sequence.outcome() else {
479 + panic!("the sequence did not fail");
480 + };
481 + assert_eq!(message, "no root partition found");
482 + assert!(!sequence.output().iter().any(|l| l.contains("must not run")));
483 + }
484 +
485 + // A resolver never runs for a command that failed: its output would be
486 + // whatever the command managed to print before dying.
487 + #[test]
488 + fn a_failed_command_does_not_resolve() {
489 + let mut log = CommandLog::new();
490 + let mut sequence = Sequence::new(vec![Stage::Resolve {
491 + invocation: Invocation::new("false"),
492 + then: Box::new(|_| panic!("resolved a failed command")),
493 + }]);
314 494
315 495 drive(&mut sequence, &mut log);
316 496
@@ -321,9 +501,9 @@
321 501 fn progress_counts_completed_commands() {
322 502 let mut log = CommandLog::new();
323 503 let mut sequence = Sequence::new(vec![
324 - Invocation::new("true"),
325 - Invocation::new("true"),
326 - Invocation::new("true"),
504 + Stage::Run(Invocation::new("true")),
505 + Stage::Run(Invocation::new("true")),
506 + Stage::Run(Invocation::new("true")),
327 507 ]);
328 508 assert_eq!(sequence.progress(), (0, 3));
329 509
@@ -336,7 +516,7 @@
336 516 // the first command produces anything.
337 517 #[test]
338 518 fn queueing_does_not_start_anything() {
339 - let sequence = Sequence::new(vec![Invocation::new("echo").arg("x")]);
519 + let sequence = Sequence::new(vec![Stage::Run(Invocation::new("echo").arg("x"))]);
340 520 assert!(sequence.output().is_empty());
341 521 assert!(sequence.outcome().is_none());
342 522 }