Skip to main content

max / alloy

install: stream the run, and correct the deployment target Two loose ends from the previous commit. One is finished; the other turned out to be a wrong answer rather than a missing one, which is worth reading before trusting install.rs. run.rs runs a queue of commands with their output streamed. A reader thread per stream pushes lines into a channel and the view drains it on the tick the shell already runs, so the synchronous event loop stays synchronous and a tick costs the same whether the child is chatty or silent for a minute. bootc takes minutes; blocking on it froze the TUI at the moment the user most needs to see progress, on the one screen where the alternative to feedback is wondering whether to power-cycle a half-written disk. Both stdout and stderr feed one channel, in arrival order, because that is how they appear on a terminal and because a failure's explanation is on stderr. The pipes are drained again after the child is reaped: output written just before exit is still in flight when try_wait first reports a status, and those are exactly the lines that say why something failed. A failure clears the queue rather than pressing on, since every command after the deploy depends on it. Tests drive real children — echo, false, sh, a command that does not exist — rather than asserting on argv, with a bounded loop so a hang fails the suite instead of wedging it. Now the correction. DEPLOYED_ROOT was invented, and upstream says plainly why that could not work: "Some installation tools may want to inject additional data, such as adding an /etc/hostname into the target root. At the current time, bootc does not offer a direct API to do this." install to-disk partitions, deploys and unmounts, and the man page has no option to leave the filesystem mounted. Worse than unverified, it was wrong. An ostree system's /etc is not at <mount>/etc. The deployment lives at <mount>/ostree/deploy/<stateroot>/ deploy/<checksum>/, and that checksum is not knowable ahead of time. Pointed at the mountpoint, systemd-firstboot would have written a hostname where nothing reads it, and the install would have booted with none of the answers applied and no error to explain it. That is a failure that looks like success, which is the kind worth catching before it ships rather than after a user's first boot. deployment_dir implements the documented way out — `ostree admin --sysroot=<target> --print-current-dir` — and bootc install finalize is now in the plan, which upstream says tools making changes should run "as the penultimate step before unmounting the target filesystem". Omitting it produces a system that boots and is subtly wrong. deployment_dir is not yet wired in, and carries allow(dead_code) saying so. Using it makes the plan dynamic: the arguments to firstboot come from the output of a command that has not run, and Sequence runs a fixed list. That is the one piece of the installer still outstanding, and it is now a known gap with a documented shape rather than a guess. The module header leads with it. Sources: bootc.dev/bootc/bootc-install.html and the install-to-disk man page. 222 tests pass, 7 ignored, clippy clean. The three rustfmt diffs in cli.rs still pre-date this work; count verified unchanged.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 01:04 UTC
Signed with PGP, not checked
Commit: cb7e962eddc7b65b904302f43a297b423d892e39
Parent: 95b309a
4 files changed, +614 insertions, -38 deletions
@@ -298,6 +298,56 @@
298 298 .with_context(|| format!("`{}` emitted non-UTF-8 output", self.display()))
299 299 }
300 300
301 + /// Start the command with both output streams piped, and log it.
302 + ///
303 + /// For [`run::Sequence`](crate::run::Sequence), which watches output arrive
304 + /// rather than collecting it at the end. Logged here rather than by the
305 + /// caller so that a streamed command appears in the pane on the same terms
306 + /// as every other one: the module docs' claim that the pane cannot miss a
307 + /// command has to survive a second way of running them.
308 + ///
309 + /// Recorded as `Healthy` because at this point the command has started and
310 + /// nothing more is known. Whether it succeeded is the sequence's to report,
311 + /// and it does so through the run screen rather than by amending a log line
312 + /// that has already scrolled.
313 + pub fn spawn_streaming(&self, log: &mut CommandLog) -> Result<std::process::Child> {
314 + let mut command = Command::new(&self.program);
315 + command
316 + .args(&self.args)
317 + .stdout(Stdio::piped())
318 + .stderr(Stdio::piped())
319 + .stdin(if self.stdin.is_some() {
320 + Stdio::piped()
321 + } else {
322 + Stdio::null()
323 + });
324 +
325 + let started = command.spawn();
326 + log.record(
327 + self.display(),
328 + if started.is_ok() {
329 + Severity::Healthy
330 + } else {
331 + Severity::Error
332 + },
333 + );
334 +
335 + let mut child =
336 + started.with_context(|| format!("failed to invoke `{}`", self.display()))?;
337 +
338 + // Same contract as `spawn`: the handle is dropped once written, so a
339 + // child reading to EOF is not left waiting on a pipe nobody will close.
340 + if let Some(secret) = &self.stdin {
341 + let mut pipe = child
342 + .stdin
343 + .take()
344 + .context("stdin was piped but no handle came back")?;
345 + pipe.write_all(secret.expose())?;
346 + }
347 +
348 + Ok(child)
349 + }
350 +
301 351 /// Run to completion, writing [`stdin`](Self::stdin) if there is any.
302 352 ///
303 353 /// `Command::output` would be enough without a secret — it is what this was
@@ -8,11 +8,21 @@
8 8 //! Four questions: which disk, what to call the machine, who logs in, and a
9 9 //! summary that shows the exact commands before running any of them.
10 10 //!
11 - //! Two things are deliberately unfinished, both marked where they matter.
12 - //! [`DEPLOYED_ROOT`] is a guess until a real bootc system can say where the
13 - //! installed root ends up mounted, and [`InstallView::confirmed`] runs the plan
14 - //! synchronously, which freezes the frame for the minutes `bootc` takes. The
15 - //! streaming run screen is what fixes the second.
11 + //! The install runs through [`Sequence`], so the frame keeps drawing for the
12 + //! minutes `bootc` takes.
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.
21 + //!
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.
16 26 //!
17 27 //! <!-- wiki: alloy-console -->
18 28
@@ -30,6 +40,7 @@
30 40
31 41 use crate::cli::{CommandLog, Invocation, Secret};
32 42 use crate::field::TextField;
43 + use crate::run::Sequence;
33 44 use crate::shell::{Confirm, Flow, View, block_title};
34 45 use crate::wizard::Steps;
35 46
@@ -192,19 +203,49 @@
192 203 pub username: Option<String>,
193 204 }
194 205
195 - /// Where the installed system's root is mounted while it is configured.
206 + /// Where the installer mounts the target's root filesystem to configure it.
196 207 ///
197 - /// **Unverified.** `bootc install to-disk` partitions, deploys and unmounts, so
198 - /// something has to mount the target root again before `systemd-firstboot` and
199 - /// `useradd` can be pointed at it with `--root`. Whether bootc leaves a
200 - /// conventional path behind, and what the partition layout is called, are
201 - /// questions that need a real bootc system to answer — there is none on the
202 - /// development box, and the command is destructive besides.
208 + /// A directory the installer creates, not one bootc provides. Upstream is
209 + /// explicit that there is no shortcut here: "Some installation tools may want
210 + /// to inject additional data, such as adding an /etc/hostname into the target
211 + /// root. At the current time, bootc does not offer a direct API to do this."
212 + /// `bootc install to-disk` partitions, deploys, and unmounts, and has no option
213 + /// to leave the filesystem mounted.
203 214 ///
204 - /// This constant is the single place that answer lands when the QEMU image can
205 - /// be booted. Everything downstream takes it as a parameter, so settling it is
206 - /// an edit here rather than a hunt.
207 - const DEPLOYED_ROOT: &str = "/mnt/alloy-target";
215 + /// So the sequence is mount, configure, finalize, unmount, and this is the
216 + /// mountpoint used throughout.
217 + const TARGET_MOUNT: &str = "/mnt/alloy-target";
218 +
219 + /// Find the ostree deployment directory beneath a mounted target root.
220 + ///
221 + /// **This is why the earlier design was wrong, not merely unverified.** An
222 + /// ostree system's `/etc` is not at `<mount>/etc`. The deployment lives at
223 + /// `<mount>/ostree/deploy/<stateroot>/deploy/<checksum>/`, and that checksum is
224 + /// not knowable ahead of time. Pointing `systemd-firstboot --root` at the
225 + /// mountpoint would have written a hostname into the sysroot, where nothing
226 + /// reads it, and the install would have booted with none of the answers applied
227 + /// and no error to explain it.
228 + ///
229 + /// Upstream names the way out: "You can use `ostree admin
230 + /// --sysroot=/path/to/target --print-current-dir` to find the newly created
231 + /// deployment directory." That makes the configuration target a value
232 + /// discovered at install time rather than a constant, which is the structural
233 + /// 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 + fn deployment_dir(mount: &str) -> Invocation {
244 + Invocation::new("ostree")
245 + .arg("admin")
246 + .arg(format!("--sysroot={mount}"))
247 + .arg("--print-current-dir")
248 + }
208 249
209 250 /// The commands an install runs, in order.
210 251 ///
@@ -252,6 +293,13 @@
252 293 Invocation::new("chpasswd")
253 294 .args(["--root", root])
254 295 .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.
300 + Invocation::new("bootc")
301 + .args(["install", "finalize"])
302 + .arg(format!("--root={TARGET_MOUNT}")),
255 303 ]
256 304 }
257 305
@@ -560,6 +608,12 @@
560 608 fields: FocusRing,
561 609 answers: Answers,
562 610 error: Option<String>,
611 + /// The install, once it has been confirmed and started.
612 + ///
613 + /// Not a [`Step`], because the steps are the questions and this is what
614 + /// happens after the last answer. While it is `Some` it owns the screen:
615 + /// there is nothing to ask and nothing to go back to.
616 + running: Option<Sequence>,
563 617 }
564 618
565 619 impl InstallView {
@@ -581,6 +635,7 @@
581 635 fields: FocusRing::new(ACCOUNT_FIELDS),
582 636 answers: Answers::default(),
583 637 error: None,
638 + running: None,
584 639 };
585 640 view.refresh(log);
586 641 view
@@ -847,6 +902,40 @@
847 902 frame.render_widget(Paragraph::new(lines), area);
848 903 }
849 904
905 + /// The run screen: progress, then the command output as it arrives.
906 + fn render_run(&self, frame: &mut Frame, area: Rect, theme: &Theme, sequence: &Sequence) {
907 + let (done, total) = sequence.progress();
908 +
909 + let status = match sequence.outcome() {
910 + None => Line::from(text::muted(
911 + theme,
912 + format!("Installing. Step {} of {total}.", done + 1),
913 + )),
914 + Some(Ok(())) => Line::from(Span::styled(
915 + "Installation finished. Reboot to start Alloy.".to_string(),
916 + Severity::Healthy.style(theme),
917 + )),
918 + Some(Err(message)) => Line::from(Span::styled(
919 + format!("Installation failed: {message}"),
920 + Severity::Error.style(theme),
921 + )),
922 + };
923 +
924 + let mut lines = vec![status, Line::default()];
925 +
926 + // The tail rather than the head. A long-running command's interesting
927 + // output is always its most recent, and a failure's explanation is its
928 + // last line.
929 + let body = area.height.saturating_sub(lines.len() as u16) as usize;
930 + let output = sequence.output();
931 + let from = output.len().saturating_sub(body);
932 + for line in &output[from..] {
933 + lines.push(Line::from(text::secondary(theme, line.clone())));
934 + }
935 +
936 + frame.render_widget(Paragraph::new(lines), area);
937 + }
938 +
850 939 /// Raise the wipe confirmation.
851 940 ///
852 941 /// Two gates of different kinds, on purpose. The summary is the one you
@@ -886,7 +975,7 @@
886 975 hostname,
887 976 username,
888 977 self.password.value(),
889 - DEPLOYED_ROOT,
978 + TARGET_MOUNT,
890 979 )
891 980 }
892 981
@@ -979,6 +1068,10 @@
979 1068 let inner = block.inner(area);
980 1069 frame.render_widget(block, area);
981 1070
1071 + if let Some(sequence) = &self.running {
1072 + return self.render_run(frame, inner, theme, sequence);
1073 + }
1074 +
982 1075 match self.step() {
983 1076 Step::Hostname => return self.render_hostname(frame, inner, theme),
984 1077 Step::Account => return self.render_account(frame, inner, theme),
@@ -1005,10 +1098,17 @@
1005 1098 /// The hostname step types, so the shell must stop reading `q` as quit
1006 1099 /// while it is on screen.
1007 1100 fn text_entry(&self) -> bool {
1008 - self.step().types()
1101 + self.running.is_none() && self.step().types()
1009 1102 }
1010 1103
1011 1104 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
1105 + // A running install answers no questions. The only key that means
1106 + // anything is the one that leaves once it has finished, and the shell's
1107 + // own `q` already does that.
1108 + if self.running.is_some() {
1109 + return Flow::Continue;
1110 + }
1111 +
1012 1112 match self.step() {
1013 1113 Step::Hostname => return self.edit_hostname(key),
1014 1114 Step::Account => return self.edit_account(key),
@@ -1031,30 +1131,40 @@
1031 1131 Flow::Continue
1032 1132 }
1033 1133
1034 - /// The user answered the wipe confirmation. Run the install.
1134 + /// The user answered the wipe confirmation. Start the install.
1035 1135 ///
1036 - /// Sequential and blocking, which is a known and temporary shortcoming:
1037 - /// `bootc install to-disk` runs for minutes and this freezes the frame for
1038 - /// all of it, with the log pane's last line the only sign of life. The
1039 - /// streaming run screen is the piece that fixes it, and it is not built.
1040 - /// Everything after bootc is sub-second, so bootc is the whole problem.
1041 - ///
1042 - /// Stops at the first failure rather than pressing on. A `useradd` that
1043 - /// runs after a failed deploy would be writing into a tree that is not
1044 - /// there, and the error from that would describe the symptom rather than
1045 - /// the cause.
1046 - fn confirmed(&mut self, log: &mut CommandLog) {
1047 - for invocation in self.plan() {
1048 - if let Err(err) = invocation.run(log) {
1049 - self.error = Some(err.to_string());
1050 - return;
1051 - }
1052 - }
1136 + /// Queued rather than run: [`Sequence`] starts the first command on the
1137 + /// next tick, so the run screen is on screen before anything touches the
1138 + /// disk. Nothing here blocks, which is the whole point — `bootc install
1139 + /// to-disk` takes minutes and the frame has to keep drawing for all of them.
1140 + fn confirmed(&mut self, _log: &mut CommandLog) {
1053 1141 self.error = None;
1142 + self.running = Some(Sequence::new(self.plan()));
1143 + }
1144 +
1145 + /// Drive the running install.
1146 + ///
1147 + /// This is the tick the shell already calls once a second, so the run
1148 + /// screen costs nothing on any other step and needs no timer of its own.
1149 + fn tick(&mut self, log: &mut CommandLog) {
1150 + if let Some(sequence) = &mut self.running {
1151 + sequence.poll(log);
1152 + }
1054 1153 }
1055 1154
1056 1155 /// Esc steps back, and leaves once there is nowhere back to go.
1156 + ///
1157 + /// Except mid-install, where there is no back: the disk has been written to
1158 + /// and the questions behind it no longer describe anything. Esc there means
1159 + /// leave, and only once it has stopped.
1057 1160 fn cancel(&mut self) -> Flow {
1161 + if let Some(sequence) = &self.running {
1162 + return if sequence.is_done() {
1163 + Flow::Exit
1164 + } else {
1165 + Flow::Continue
1166 + };
1167 + }
1058 1168 if self.steps.back() {
1059 1169 self.error = None;
1060 1170 Flow::Continue
@@ -1243,6 +1353,7 @@
1243 1353 fields: FocusRing::new(ACCOUNT_FIELDS),
1244 1354 answers: Answers::default(),
1245 1355 error: None,
1356 + running: None,
1246 1357 };
1247 1358 view.cursor.resize(view.disks.len());
1248 1359 (view, CommandLog::new())
@@ -1634,7 +1745,7 @@
1634 1745 let (view, _log) = at_summary();
1635 1746 let shown: Vec<String> = view.plan().iter().map(Invocation::display).collect();
1636 1747
1637 - assert_eq!(shown.len(), 4, "{shown:#?}");
1748 + assert_eq!(shown.len(), 5, "{shown:#?}");
1638 1749 assert_eq!(shown[0], "bootc install to-disk --wipe /dev/sda");
1639 1750 assert!(shown[1].starts_with("systemd-firstboot"), "{}", shown[1]);
1640 1751 assert!(shown[2].starts_with("useradd"), "{}", shown[2]);
@@ -1662,7 +1773,7 @@
1662 1773
1663 1774 assert!(firstboot.contains("--hostname=alloy"), "{firstboot}");
1664 1775 assert!(firstboot.contains("--force"), "{firstboot}");
1665 - assert!(firstboot.contains(DEPLOYED_ROOT), "{firstboot}");
1776 + assert!(firstboot.contains(TARGET_MOUNT), "{firstboot}");
1666 1777 }
1667 1778
1668 1779 // An account that cannot escalate leaves an install with no way to
@@ -1677,6 +1788,29 @@
1677 1788 assert!(useradd.ends_with("max"), "{useradd}");
1678 1789 }
1679 1790
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.
1795 + #[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}");
1800 + }
1801 +
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.
1805 + #[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")
1811 + );
1812 + }
1813 +
1680 1814 // Missing answers cannot happen from the summary — every step gates on its
1681 1815 // own validation — but a partial plan would render a command with a hole in
1682 1816 // it, so it returns nothing instead.
@@ -1687,6 +1821,43 @@
1687 1821 assert!(view.plan().is_empty());
1688 1822 }
1689 1823
1824 + // ---- the run screen ----
1825 +
1826 + // Confirming queues rather than runs, so the screen is up before the disk
1827 + // is touched. The first command starts on the next tick.
1828 + #[test]
1829 + fn confirming_starts_the_install_without_blocking() {
1830 + let (mut view, mut log) = at_summary();
1831 +
1832 + view.confirmed(&mut log);
1833 +
1834 + let sequence = view.running.as_ref().expect("nothing was queued");
1835 + assert!(!sequence.is_done());
1836 + assert_eq!(sequence.progress(), (0, 5));
1837 + assert!(sequence.output().is_empty(), "a command ran during confirm");
1838 + }
1839 +
1840 + // Mid-install there is nothing to go back to: the disk has been written and
1841 + // the questions behind it no longer describe anything.
1842 + #[test]
1843 + fn esc_does_not_leave_a_running_install() {
1844 + let (mut view, mut log) = at_summary();
1845 + view.confirmed(&mut log);
1846 +
1847 + assert!(matches!(view.cancel(), Flow::Continue));
1848 + }
1849 +
1850 + #[test]
1851 + fn a_running_install_answers_no_keys() {
1852 + let (mut view, mut log) = at_summary();
1853 + view.confirmed(&mut log);
1854 +
1855 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
1856 +
1857 + assert!(!view.text_entry(), "the shell should claim q while running");
1858 + assert_eq!(view.step(), Step::Summary, "a key moved the wizard");
1859 + }
1860 +
1690 1861 #[test]
1691 1862 fn the_title_names_the_backend_and_the_position() {
1692 1863 let (view, _log) = view();
@@ -13,6 +13,7 @@
13 13 mod mesh;
14 14 mod net;
15 15 mod pkg;
16 + mod run;
16 17 mod shell;
17 18 mod theme;
18 19 mod wizard;
@@ -1,0 +1,354 @@
1 + //! Running a sequence of commands while the frame keeps drawing.
2 + //!
3 + //! Every other invocation in the console finishes in well under a frame, so
4 + //! [`Invocation::run`](crate::cli::Invocation::run) blocks and nobody notices.
5 + //! `bootc install to-disk` runs for minutes. Blocking on it freezes the TUI at
6 + //! exactly the moment the user most needs to see that something is happening,
7 + //! and on the one screen where the alternative to feedback is wondering whether
8 + //! to power-cycle a half-written disk.
9 + //!
10 + //! The shell's loop is synchronous by design and this does not change that. A
11 + //! reader thread per stream pushes lines into a channel; the view drains the
12 + //! channel on the shell's tick. Nothing here blocks for longer than it takes to
13 + //! empty a queue, so the frame keeps rendering at the tick rate whether the
14 + //! child is talkative or silent for a minute.
15 +
16 + use std::collections::VecDeque;
17 + use std::io::{BufRead, BufReader};
18 + use std::process::{Child, ExitStatus};
19 + use std::sync::mpsc::{Receiver, TryRecvError, channel};
20 + use std::thread;
21 +
22 + use crate::cli::{CommandLog, Invocation};
23 +
24 + /// How many output lines are kept.
25 + ///
26 + /// bootc is not chatty, but a failing command in a loop could be. The pane
27 + /// shows the tail, so this bounds memory without bounding what the user sees:
28 + /// the interesting lines during a failure are the last ones.
29 + const SCROLLBACK: usize = 500;
30 +
31 + /// One running child and the lines it has produced.
32 + struct Running {
33 + child: Child,
34 + /// Lines from stdout and stderr, interleaved in arrival order.
35 + ///
36 + /// Both streams feed one channel because that is how they appear on a
37 + /// terminal, and a progress line on stdout followed by its error on stderr
38 + /// is only legible in that order.
39 + lines: Receiver<String>,
40 + }
41 +
42 + impl Running {
43 + /// Drain whatever has arrived, without waiting for more.
44 + fn drain(&self, into: &mut Vec<String>) {
45 + loop {
46 + match self.lines.try_recv() {
47 + Ok(line) => {
48 + if into.len() == SCROLLBACK {
49 + into.remove(0);
50 + }
51 + into.push(line);
52 + }
53 + // Disconnected means both readers are done, which is a fact
54 + // about the pipes rather than about the process: the child may
55 + // still be exiting. `finished` is what decides that.
56 + Err(TryRecvError::Empty | TryRecvError::Disconnected) => return,
57 + }
58 + }
59 + }
60 +
61 + /// The exit status if the child is done, `None` while it runs.
62 + fn finished(&mut self) -> Option<std::io::Result<ExitStatus>> {
63 + self.child.try_wait().transpose()
64 + }
65 + }
66 +
67 + /// A queue of commands, run one at a time, with their output streamed.
68 + ///
69 + /// Owns the whole sequence rather than one command because the install is a
70 + /// sequence and the failure rule spans it: a `useradd` that runs after a failed
71 + /// deploy would be writing into a tree that is not there, so a failure stops
72 + /// everything after it.
73 + pub struct Sequence {
74 + queue: VecDeque<Invocation>,
75 + current: Option<Running>,
76 + output: Vec<String>,
77 + outcome: Option<Result<(), String>>,
78 + /// Commands that have exited, successfully or not. Only for progress.
79 + done_count: usize,
80 + }
81 +
82 + impl Sequence {
83 + /// Queue `invocations` without starting any of them.
84 + ///
85 + /// Starting is [`poll`](Self::poll)'s job, so the first command begins on
86 + /// the first tick after the view appears rather than during construction.
87 + /// That way the run screen is on screen before anything runs, instead of
88 + /// the first command's output arriving for a pane nobody has seen yet.
89 + pub fn new(invocations: Vec<Invocation>) -> Self {
90 + Self {
91 + queue: invocations.into(),
92 + current: None,
93 + output: Vec::new(),
94 + outcome: None,
95 + done_count: 0,
96 + }
97 + }
98 +
99 + /// Lines produced so far, oldest first.
100 + pub fn output(&self) -> &[String] {
101 + &self.output
102 + }
103 +
104 + /// `Some` once the sequence has stopped, whether it finished or failed.
105 + pub fn outcome(&self) -> Option<&Result<(), String>> {
106 + self.outcome.as_ref()
107 + }
108 +
109 + pub fn is_done(&self) -> bool {
110 + self.outcome.is_some()
111 + }
112 +
113 + /// How many commands are behind and in total, for a progress line.
114 + pub fn progress(&self) -> (usize, usize) {
115 + let left = self.queue.len() + usize::from(self.current.is_some());
116 + let total = self.total();
117 + (total - left, total)
118 + }
119 +
120 + fn total(&self) -> usize {
121 + // Reconstructed rather than stored, so the two cannot disagree after a
122 + // failure clears the queue.
123 + self.queue.len() + usize::from(self.current.is_some()) + self.done_count
124 + }
125 +
126 + /// Advance the sequence: drain output, reap a finished child, start the
127 + /// next command.
128 + ///
129 + /// Called from the view's tick. Does no waiting, so a tick costs the same
130 + /// whether the child is producing output or has been silent for a minute.
131 + pub fn poll(&mut self, log: &mut CommandLog) {
132 + if self.outcome.is_some() {
133 + return;
134 + }
135 +
136 + if let Some(running) = &mut self.current {
137 + let mut lines = std::mem::take(&mut self.output);
138 + running.drain(&mut lines);
139 + self.output = lines;
140 +
141 + match running.finished() {
142 + None => return,
143 + Some(Err(err)) => {
144 + self.fail(format!("could not wait for the command: {err}"));
145 + return;
146 + }
147 + Some(Ok(status)) => {
148 + // The pipes can still hold output written just before exit,
149 + // so drain once more after reaping. Without this the last
150 + // lines of a failing command — the ones that say why — are
151 + // the ones that get lost.
152 + let mut lines = std::mem::take(&mut self.output);
153 + self.current
154 + .as_ref()
155 + .expect("current was Some")
156 + .drain(&mut lines);
157 + self.output = lines;
158 + self.current = None;
159 + self.done_count += 1;
160 +
161 + if !status.success() {
162 + self.fail(format!("command exited with {status}"));
163 + return;
164 + }
165 + }
166 + }
167 + }
168 +
169 + self.start_next(log);
170 + }
171 +
172 + fn start_next(&mut self, log: &mut CommandLog) {
173 + let Some(invocation) = self.queue.pop_front() else {
174 + self.outcome = Some(Ok(()));
175 + return;
176 + };
177 +
178 + match spawn(&invocation, log) {
179 + Ok(running) => self.current = Some(running),
180 + Err(err) => self.fail(err.to_string()),
181 + }
182 + }
183 +
184 + /// Stop here. The queue is dropped rather than run: every command after a
185 + /// failure depends on the one that failed.
186 + fn fail(&mut self, message: String) {
187 + self.queue.clear();
188 + self.current = None;
189 + self.outcome = Some(Err(message));
190 + }
191 + }
192 +
193 + /// Start a command with both output streams piped into one channel.
194 + fn spawn(invocation: &Invocation, log: &mut CommandLog) -> anyhow::Result<Running> {
195 + let mut child = invocation.spawn_streaming(log)?;
196 + let (sender, receiver) = channel();
197 +
198 + // One thread per stream. Threads rather than a poll loop because a blocking
199 + // read is exactly what we want off the render path, and because two pipes
200 + // cannot both be read from one thread without one starving the other.
201 + if let Some(stdout) = child.stdout.take() {
202 + let sender = sender.clone();
203 + thread::spawn(move || pump(stdout, &sender));
204 + }
205 + if let Some(stderr) = child.stderr.take() {
206 + thread::spawn(move || pump(stderr, &sender));
207 + }
208 +
209 + Ok(Running {
210 + child,
211 + lines: receiver,
212 + })
213 + }
214 +
215 + /// Read `stream` line by line until it closes, forwarding each line.
216 + ///
217 + /// A send failure means the view is gone, which is not an error worth
218 + /// reporting to anyone: there is nobody left to report it to. The loop just
219 + /// stops, which also lets the thread exit rather than reading a pipe nobody
220 + /// will drain.
221 + fn pump(stream: impl std::io::Read, sender: &std::sync::mpsc::Sender<String>) {
222 + for line in BufReader::new(stream).lines() {
223 + let Ok(line) = line else { return };
224 + if sender.send(line).is_err() {
225 + return;
226 + }
227 + }
228 + }
229 +
230 + #[cfg(test)]
231 + mod tests {
232 + use super::*;
233 +
234 + fn drive(sequence: &mut Sequence, log: &mut CommandLog) {
235 + // Bounded so a hang fails the test rather than wedging the suite.
236 + for _ in 0..2_000 {
237 + sequence.poll(log);
238 + if sequence.is_done() {
239 + return;
240 + }
241 + thread::sleep(std::time::Duration::from_millis(2));
242 + }
243 + panic!("sequence never finished");
244 + }
245 +
246 + #[test]
247 + fn a_sequence_runs_every_command_in_order() {
248 + let mut log = CommandLog::new();
249 + let mut sequence = Sequence::new(vec![
250 + Invocation::new("echo").arg("first"),
251 + Invocation::new("echo").arg("second"),
252 + ]);
253 +
254 + drive(&mut sequence, &mut log);
255 +
256 + assert_eq!(sequence.outcome(), Some(&Ok(())));
257 + assert_eq!(sequence.output(), ["first", "second"]);
258 + }
259 +
260 + // The rule the type exists to enforce. Everything after a failed deploy
261 + // would be writing into a tree that is not there.
262 + #[test]
263 + fn a_failure_stops_the_commands_behind_it() {
264 + let mut log = CommandLog::new();
265 + let mut sequence = Sequence::new(vec![
266 + Invocation::new("false"),
267 + Invocation::new("echo").arg("must not run"),
268 + ]);
269 +
270 + drive(&mut sequence, &mut log);
271 +
272 + assert!(sequence.outcome().expect("stopped").is_err());
273 + assert!(
274 + !sequence.output().iter().any(|l| l.contains("must not run")),
275 + "a command after the failure ran: {:?}",
276 + sequence.output()
277 + );
278 + }
279 +
280 + // stderr is not lost. A command that fails says why on stderr, and a run
281 + // screen showing only stdout would show a failure with no explanation.
282 + #[test]
283 + fn stderr_is_captured_alongside_stdout() {
284 + let mut log = CommandLog::new();
285 + let mut sequence = Sequence::new(vec![
286 + Invocation::new("sh").args(["-c", "echo out; echo problem >&2"]),
287 + ]);
288 +
289 + drive(&mut sequence, &mut log);
290 +
291 + let output = sequence.output();
292 + assert!(output.iter().any(|l| l == "out"), "{output:?}");
293 + assert!(output.iter().any(|l| l == "problem"), "{output:?}");
294 + }
295 +
296 + // The drain-after-reap case: output written immediately before exit is
297 + // still in the pipe when try_wait first reports the status.
298 + #[test]
299 + fn the_last_lines_before_exit_are_not_lost() {
300 + let mut log = CommandLog::new();
301 + let mut sequence = Sequence::new(vec![
302 + Invocation::new("sh").args(["-c", "echo a; echo b; echo c"]),
303 + ]);
304 +
305 + drive(&mut sequence, &mut log);
306 +
307 + assert_eq!(sequence.output(), ["a", "b", "c"]);
308 + }
309 +
310 + #[test]
311 + fn a_command_that_does_not_exist_fails_the_sequence() {
312 + let mut log = CommandLog::new();
313 + let mut sequence = Sequence::new(vec![Invocation::new("alloy-no-such-command-exists")]);
314 +
315 + drive(&mut sequence, &mut log);
316 +
317 + assert!(sequence.outcome().expect("stopped").is_err());
318 + }
319 +
320 + #[test]
321 + fn progress_counts_completed_commands() {
322 + let mut log = CommandLog::new();
323 + let mut sequence = Sequence::new(vec![
324 + Invocation::new("true"),
325 + Invocation::new("true"),
326 + Invocation::new("true"),
327 + ]);
328 + assert_eq!(sequence.progress(), (0, 3));
329 +
330 + drive(&mut sequence, &mut log);
331 +
332 + assert_eq!(sequence.progress(), (3, 3));
333 + }
334 +
335 + // Nothing starts during construction, so the run screen is on screen before
336 + // the first command produces anything.
337 + #[test]
338 + fn queueing_does_not_start_anything() {
339 + let sequence = Sequence::new(vec![Invocation::new("echo").arg("x")]);
340 + assert!(sequence.output().is_empty());
341 + assert!(sequence.outcome().is_none());
342 + }
343 +
344 + #[test]
345 + fn an_empty_sequence_completes_immediately() {
346 + let mut log = CommandLog::new();
347 + let mut sequence = Sequence::new(Vec::new());
348 +
349 + sequence.poll(&mut log);
350 +
351 + assert_eq!(sequence.outcome(), Some(&Ok(())));
352 + assert_eq!(sequence.progress(), (0, 0));
353 + }
354 + }