| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
975 |
|
hostname,
|
| 887 |
976 |
|
username,
|
| 888 |
977 |
|
self.password.value(),
|
| 889 |
|
- |
DEPLOYED_ROOT,
|
|
978 |
+ |
TARGET_MOUNT,
|
| 890 |
979 |
|
)
|
| 891 |
980 |
|
}
|
| 892 |
981 |
|
|
| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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();
|