Skip to main content

max / alloy

install: pick a disk The first step of the installer wizard, and the one with the consequences. `bootc install to-disk` takes a device path; this is the screen that decides which one, and refuses the disks that must not be overwritten. lsblk is on the dev box, so unlike rpm-ostree this parser met real output instead of a fixture agreeing with itself. Three rules came out of that which would not have been written from imagination. zram0 is `type: "disk"`. A naive type filter offers compressed swap RAM as somewhere to install an operating system. Virtual devices are dropped by name prefix — zram, loop, ram, dm- — where loop also covers the squashfs a live ISO mounts itself from. cryptswap is mounted two levels below its disk, at disk > part > crypt, so gathering mountpoints recurses. Checking direct children only reports that disk as free while a filesystem on it is mounted, which is how a wipe nobody agreed to happens. It has its own test naming the trap. Blocked disks are listed and marked rather than hidden. A disk absent from the list gives the user no way to tell a filter from a hardware fault, and the refusal names the disk and the reason: an Enter that appears to do nothing is how someone decides the installer is broken and reaches for dd. Read-only is reported ahead of in-use so a write- protected medium says so rather than blaming a mount it also has. Sizes are decimal. A 2TB drive is 2.0 TB decimal and 1.8 TiB binary, and the number printed on the drive — repeated in the model string lsblk returns — is the decimal one. Matching the label in the user's hand is the whole reason to show a size. Verified against this machine, not only the fixture. The ignored test prints what it found: sda 61.5 GB SanDisk usb/removable selectable, nvme0n1 2.0 TB WD_BLACK nvme in use. zram filtered, the running root blocked, the stick selectable. STEP_COUNT is 1, so choosing records the target and stays put. Steps is already load-bearing — Esc leaves from the first step through the cancel override — but the wizard is one question long until the next lands. Not verified by eye; the TUI has not been launched. The ignored test covers the backend, which is the half with the consequences. 166 tests pass, 7 ignored, clippy clean.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 00:28 UTC
Signed with PGP, not checked
Commit: 23e2346dfd7ada6a6279358e2343401d8be56231
Parent: d3b637c
2 files changed, +507 insertions, -0 deletions
@@ -8,6 +8,7 @@
8 8
9 9 mod audio;
10 10 mod cli;
11 + mod install;
11 12 mod mesh;
12 13 mod net;
13 14 mod pkg;
@@ -56,6 +57,8 @@
56 57 // exactly what the system tab shows.
57 58 /// System image: what is booted, what is staged, and rollback
58 59 Update,
60 + /// Install Alloy to a disk
61 + Install,
59 62 }
60 63
61 64 /// Which surface of the package view to open on.
@@ -103,5 +106,9 @@
103 106 let mut view = pkg::PkgView::new(pkg::Tab::System, &mut log);
104 107 shell::run(&theme, &mut view, &mut log)
105 108 }
109 + Command::Install => {
110 + let mut view = install::InstallView::new(&mut log);
111 + shell::run(&theme, &mut view, &mut log)
112 + }
106 113 }
107 114 }
@@ -1,0 +1,784 @@
1 + //! `alloy install` — the installer, as a wizard over `bootc install to-disk`.
2 + //!
3 + //! bootc already does the real work: partitioning, the ostree deploy, and the
4 + //! bootloader via bootupd. So this is a sequence of questions ending in one
5 + //! command, not a reimplementation of Anaconda. See docs/CONSOLE.md and the
6 + //! wiki note `alloy-console`.
7 + //!
8 + //! The disk step is the first of them and the one with the consequences, so it
9 + //! lands first. Everything a later step needs — text fields, the streaming run
10 + //! screen — is absent here on purpose.
11 + //!
12 + //! <!-- wiki: alloy-console -->
13 +
14 + use alloy_tui::{AlloyBlock, AlloyList, Hint, Severity, Theme, hint, text};
15 + use anyhow::{Context, Result};
16 + use ratatui::Frame;
17 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
18 + use ratatui::layout::Rect;
19 + use ratatui::text::{Line, Span};
20 + use serde::Deserialize;
21 +
22 + use alloy_tui::Cursor;
23 +
24 + use crate::cli::{CommandLog, Invocation};
25 + use crate::shell::{Flow, View, block_title};
26 + use crate::wizard::Steps;
27 +
28 + /// How many questions the wizard asks.
29 + ///
30 + /// One while only the disk step exists. Each later step raises this as it
31 + /// lands, which is what keeps [`Steps::is_last`] honest about where Enter runs
32 + /// the install rather than advancing into a screen that is not written yet.
33 + const STEP_COUNT: usize = 1;
34 +
35 + // ---- what the installer has been told ----
36 +
37 + /// The answers collected so far.
38 + ///
39 + /// Separate from the view's own state (cursor position, error text) because
40 + /// this is what the final `bootc install to-disk` invocation is built from,
41 + /// and what the summary step reads back. Nothing else survives to the end.
42 + #[derive(Debug, Default)]
43 + pub struct Answers {
44 + /// Device path of the install target, e.g. `/dev/nvme0n1`.
45 + pub disk: Option<String>,
46 + }
47 +
48 + // ---- disks ----
49 +
50 + /// A whole disk, as an install target.
51 + #[derive(Debug, Clone, PartialEq, Eq)]
52 + pub struct Disk {
53 + /// Device path, which is what `bootc install to-disk` takes.
54 + pub path: String,
55 + pub name: String,
56 + /// Bytes. Formatted for display by [`format_size`].
57 + pub size: u64,
58 + pub model: Option<String>,
59 + pub removable: bool,
60 + /// `nvme`, `usb`, `sata`. `None` for disks lsblk cannot attribute.
61 + pub transport: Option<String>,
62 + pub read_only: bool,
63 + /// Every mountpoint anywhere beneath this disk, at any depth.
64 + pub mountpoints: Vec<String>,
65 + }
66 +
67 + /// Why a disk cannot be installed to.
68 + ///
69 + /// Blocked disks are listed rather than hidden. A user whose disk is simply
70 + /// missing from the list has no way to tell a filter from a hardware fault,
71 + /// and "your install medium is not a target" is a thing worth saying once
72 + /// rather than a row silently absent.
73 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
74 + pub enum Blocked {
75 + /// Something on it is mounted, so it is the running system or the medium
76 + /// booted from. In a live install both of those are exactly the disk a
77 + /// user must not overwrite.
78 + InUse,
79 + ReadOnly,
80 + }
81 +
82 + impl Blocked {
83 + pub const fn label(self) -> &'static str {
84 + match self {
85 + Self::InUse => "in use",
86 + Self::ReadOnly => "read-only",
87 + }
88 + }
89 +
90 + /// The refusal shown when the user tries to pick it anyway.
91 + ///
92 + /// Phrased to follow the device path, so the whole message reads as one
93 + /// sentence: "/dev/nvme0n1 is mounted; it holds the running system".
94 + pub const fn reason(self) -> &'static str {
95 + match self {
96 + Self::InUse => "is mounted; it holds the running system or the install medium",
97 + Self::ReadOnly => "is read-only",
98 + }
99 + }
100 + }
101 +
102 + impl Disk {
103 + /// Why this disk cannot be a target, or `None` if it can.
104 + ///
105 + /// Read-only is reported ahead of in-use so a write-protected medium says
106 + /// so, rather than blaming the mount it also has.
107 + pub fn blocker(&self) -> Option<Blocked> {
108 + if self.read_only {
109 + Some(Blocked::ReadOnly)
110 + } else if !self.mountpoints.is_empty() {
111 + Some(Blocked::InUse)
112 + } else {
113 + None
114 + }
115 + }
116 +
117 + /// How the disk is attached, for the row. Removable is worth surfacing on
118 + /// its own: on a live install the USB stick and the target look alike
119 + /// until one of them says "usb, removable".
120 + fn attachment(&self) -> String {
121 + match (self.transport.as_deref(), self.removable) {
122 + (Some(transport), true) => format!("{transport}, removable"),
123 + (Some(transport), false) => transport.to_string(),
124 + (None, true) => "removable".into(),
125 + (None, false) => String::new(),
126 + }
127 + }
128 + }
129 +
130 + /// Decimal units, deliberately.
131 + ///
132 + /// A 2TB drive is 2_000_398_934_016 bytes, which is "2.0 TB" decimal and
133 + /// "1.8 TiB" binary. The number printed on the drive and repeated in its own
134 + /// model string is the decimal one, and a user matching the list against the
135 + /// label in their hand is the whole point of showing a size.
136 + const UNITS: [(&str, u64); 4] = [
137 + ("TB", 1_000_000_000_000),
138 + ("GB", 1_000_000_000),
139 + ("MB", 1_000_000),
140 + ("kB", 1_000),
141 + ];
142 +
143 + pub fn format_size(bytes: u64) -> String {
144 + for (unit, scale) in UNITS {
145 + if bytes >= scale {
146 + return format!("{:.1} {unit}", bytes as f64 / scale as f64);
147 + }
148 + }
149 + format!("{bytes} B")
150 + }
151 +
152 + // ---- backend ----
153 +
154 + /// A source of disks.
155 + pub trait Backend {
156 + fn name(&self) -> &'static str;
157 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Disk>>;
158 + }
159 +
160 + /// Pick a backend: the real one when `lsblk` answers, the mock otherwise.
161 + ///
162 + /// A `--version` probe rather than a `which` check, matching `net` and `mesh`.
163 + pub fn detect() -> Box<dyn Backend> {
164 + if Invocation::new("lsblk").arg("--version").probe() {
165 + Box::new(LsBlk)
166 + } else {
167 + Box::new(Mock)
168 + }
169 + }
170 +
171 + pub struct LsBlk;
172 +
173 + impl LsBlk {
174 + /// `-b` for bytes, so the size arrives as a number to format rather than a
175 + /// string lsblk already rounded. The column list is explicit because
176 + /// lsblk's default set does not include `tran` or `path`, and `path` is
177 + /// what `bootc install to-disk` is handed.
178 + fn invocation() -> Invocation {
179 + Invocation::new("lsblk").args([
180 + "-J",
181 + "-b",
182 + "-o",
183 + "NAME,PATH,SIZE,MODEL,TYPE,RM,RO,TRAN,MOUNTPOINTS",
184 + ])
185 + }
186 + }
187 +
188 + impl Backend for LsBlk {
189 + fn name(&self) -> &'static str {
190 + "lsblk"
191 + }
192 +
193 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Disk>> {
194 + parse_disks(&Self::invocation().run(log)?)
195 + }
196 + }
197 +
198 + /// Fixed sample disks, for machines without lsblk.
199 + pub struct Mock;
200 +
201 + impl Backend for Mock {
202 + fn name(&self) -> &'static str {
203 + "mock"
204 + }
205 +
206 + fn list(&self, log: &mut CommandLog) -> Result<Vec<Disk>> {
207 + log.record("# no lsblk; showing mock disks", Severity::Warn);
208 + Ok(vec![
209 + Disk {
210 + path: "/dev/nvme0n1".into(),
211 + name: "nvme0n1".into(),
212 + size: 512_110_190_592,
213 + model: Some("SAMSUNG MZVLB512HBJQ".into()),
214 + removable: false,
215 + transport: Some("nvme".into()),
216 + read_only: false,
217 + mountpoints: Vec::new(),
218 + },
219 + Disk {
220 + path: "/dev/sda".into(),
221 + name: "sda".into(),
222 + size: 30_752_440_320,
223 + model: Some("Alloy Install Medium".into()),
224 + removable: true,
225 + transport: Some("usb".into()),
226 + read_only: false,
227 + mountpoints: vec!["/run/initramfs/live".into()],
228 + },
229 + ])
230 + }
231 + }
232 +
233 + // ---- lsblk JSON ----
234 +
235 + /// Assumes util-linux 2.33 or newer, which emits JSON scalars as numbers and
236 + /// booleans. Older lsblk quoted everything, so a size arrived as `"512110190592"`
237 + /// and `rm` as `"0"`. Fedora 43 ships 2.41; a `--version` probe would not tell
238 + /// the two apart anyway, so this surfaces as a parse error in the view rather
239 + /// than being guarded against.
240 + #[derive(Deserialize)]
241 + struct LsBlkOutput {
242 + blockdevices: Vec<LsBlkDevice>,
243 + }
244 +
245 + #[derive(Deserialize)]
246 + struct LsBlkDevice {
247 + name: String,
248 + path: String,
249 + size: u64,
250 + #[serde(default)]
251 + model: Option<String>,
252 + /// `disk`, `part`, `crypt`, `lvm`, `rom`. Named `kind` because `type` is a
253 + /// keyword.
254 + #[serde(rename = "type")]
255 + kind: String,
256 + #[serde(default)]
257 + rm: bool,
258 + #[serde(default)]
259 + ro: bool,
260 + #[serde(default)]
261 + tran: Option<String>,
262 + /// An unmounted device reports `[null]` rather than `[]`, so the nulls are
263 + /// filtered rather than assumed away.
264 + #[serde(default)]
265 + mountpoints: Vec<Option<String>>,
266 + #[serde(default)]
267 + children: Vec<LsBlkDevice>,
268 + }
269 +
270 + /// Device-name prefixes that are `type: "disk"` without being a disk.
271 + ///
272 + /// `zram0` is the case that forced this: compressed RAM used for swap, typed
273 + /// `disk` by lsblk, and offering it as an install target would be absurd.
274 + /// `loop` covers the squashfs a live ISO mounts itself from, `dm-` the
275 + /// device-mapper nodes behind LUKS and LVM, and `ram` the legacy ramdisks.
276 + const VIRTUAL_PREFIXES: [&str; 4] = ["zram", "loop", "ram", "dm-"];
277 +
278 + fn is_virtual(name: &str) -> bool {
279 + VIRTUAL_PREFIXES
280 + .iter()
281 + .any(|prefix| name.starts_with(prefix))
282 + }
283 +
284 + /// Collect every mountpoint at or beneath `device`.
285 + ///
286 + /// Recursive rather than a walk of direct children, which is not a
287 + /// generalization for its own sake: a LUKS volume sits at
288 + /// `disk > part > crypt`, two levels down, and on the box this was written
289 + /// against that is where the mounted swap lives. Checking one level reports
290 + /// such a disk as free while a filesystem on it is mounted, which is the exact
291 + /// mistake that ends with a wipe the user did not agree to.
292 + fn mountpoints_of(device: &LsBlkDevice, out: &mut Vec<String>) {
293 + out.extend(device.mountpoints.iter().flatten().cloned());
294 + for child in &device.children {
295 + mountpoints_of(child, out);
296 + }
297 + }
298 +
299 + /// Whole disks that could plausibly be install targets, in lsblk's order.
300 + ///
301 + /// Two different exclusions, deliberately kept apart. Partitions and virtual
302 + /// devices are dropped outright, because they are not disks and a row saying
303 + /// so would be noise. Real disks that are mounted or read-only are *kept* and
304 + /// marked, because their absence is the confusing case: see [`Blocked`].
305 + fn parse_disks(raw: &str) -> Result<Vec<Disk>> {
306 + let parsed: LsBlkOutput = serde_json::from_str(raw).context("lsblk emitted invalid JSON")?;
307 +
308 + Ok(parsed
309 + .blockdevices
310 + .into_iter()
311 + .filter(|device| device.kind == "disk" && !is_virtual(&device.name))
312 + .map(|device| {
313 + let mut mountpoints = Vec::new();
314 + mountpoints_of(&device, &mut mountpoints);
315 + Disk {
316 + path: device.path,
317 + name: device.name,
318 + size: device.size,
319 + // lsblk emits `null` for a disk with no model string, and an
320 + // empty one for some USB bridges. Both mean "unknown", so
321 + // neither should render as a blank column with a stray gap.
322 + model: device.model.filter(|model| !model.trim().is_empty()),
323 + removable: device.rm,
324 + transport: device.tran.filter(|tran| !tran.trim().is_empty()),
325 + read_only: device.ro,
326 + mountpoints,
327 + }
328 + })
329 + .collect())
330 + }
331 +
332 + // ---- the view ----
333 +
334 + /// The `alloy install` screen.
335 + pub struct InstallView {
336 + steps: Steps,
337 + backend: Box<dyn Backend>,
338 + disks: Vec<Disk>,
339 + cursor: Cursor,
340 + answers: Answers,
341 + error: Option<String>,
342 + }
343 +
344 + impl InstallView {
345 + pub fn new(log: &mut CommandLog) -> Self {
346 + let mut view = Self {
347 + steps: Steps::new(STEP_COUNT),
348 + backend: detect(),
349 + disks: Vec::new(),
350 + cursor: Cursor::new(),
351 + answers: Answers::default(),
352 + error: None,
353 + };
354 + view.refresh(log);
355 + view
356 + }
357 +
358 + fn refresh(&mut self, log: &mut CommandLog) {
359 + match self.backend.list(log) {
360 + Ok(disks) => {
361 + self.disks = disks;
362 + // A disk can vanish between refreshes (a stick pulled out);
363 + // the cursor clamps itself back into range.
364 + self.cursor.resize(self.disks.len());
365 + self.error = None;
366 + }
367 + Err(err) => self.error = Some(err.to_string()),
368 + }
369 + }
370 +
371 + fn selected(&self) -> Option<&Disk> {
372 + self.cursor.selected().and_then(|i| self.disks.get(i))
373 + }
374 +
375 + /// Take the disk under the cursor as the answer, and move on.
376 + ///
377 + /// A blocked disk refuses with its reason rather than silently doing
378 + /// nothing. Enter that appears to be ignored is how a user concludes the
379 + /// installer is broken and reaches for `dd`.
380 + fn choose(&mut self) -> Flow {
381 + let Some(disk) = self.selected() else {
382 + self.error = Some("no disk to install to".into());
383 + return Flow::Continue;
384 + };
385 +
386 + if let Some(blocked) = disk.blocker() {
387 + self.error = Some(format!("{} {}", disk.path, blocked.reason()));
388 + return Flow::Continue;
389 + }
390 +
391 + self.answers.disk = Some(disk.path.clone());
392 + self.error = None;
393 + self.steps.advance();
394 + Flow::Continue
395 + }
396 +
397 + fn row<'a>(&self, theme: &Theme, disk: &'a Disk) -> Line<'a> {
398 + let (status, severity) = match disk.blocker() {
399 + Some(blocked) => (blocked.label(), Severity::Warn),
400 + None => ("", Severity::Info),
401 + };
402 +
403 + let model = disk.model.as_deref().unwrap_or("-");
404 +
405 + Line::from(vec![
406 + text::bold(theme, format!("{:<14}", disk.name)),
407 + text::primary(theme, format!("{:>10} ", format_size(disk.size))),
408 + text::secondary(theme, format!("{model:<24}")),
409 + text::muted(theme, format!("{:<16}", disk.attachment())),
410 + Span::styled(status.to_string(), severity.style(theme)),
411 + ])
412 + }
413 + }
414 +
415 + impl View for InstallView {
416 + fn title(&self) -> String {
417 + format!(
418 + "install ({}) — step {} of {}: select a disk",
419 + self.backend.name(),
420 + self.steps.current() + 1,
421 + self.steps.len()
422 + )
423 + }
424 +
425 + fn hints(&self) -> Vec<Hint> {
426 + vec![
427 + hint("j/k", "select"),
428 + hint("enter", "choose"),
429 + hint("r", "refresh"),
430 + ]
431 + }
432 +
433 + fn status(&self) -> Option<(Severity, String)> {
434 + if let Some(message) = &self.error {
435 + return Some((Severity::Error, message.clone()));
436 + }
437 + self.answers
438 + .disk
439 + .as_ref()
440 + .map(|disk| (Severity::Healthy, format!("target: {disk}")))
441 + }
442 +
443 + fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
444 + let block = AlloyBlock::new(theme)
445 + .focused(true)
446 + .build()
447 + .title(block_title(&self.title()));
448 + let inner = block.inner(area);
449 + frame.render_widget(block, area);
450 +
451 + if self.disks.is_empty() {
452 + frame.render_widget(Line::from(text::muted(theme, "no disks found")), inner);
453 + return;
454 + }
455 +
456 + let rows: Vec<Line> = self
457 + .disks
458 + .iter()
459 + .map(|disk| self.row(theme, disk))
460 + .collect();
461 + frame.render_widget(
462 + AlloyList::new(theme, rows).selected(self.cursor.selected()),
463 + inner,
464 + );
465 + }
466 +
467 + fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
468 + match key.code {
469 + KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
470 + KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
471 + KeyCode::Char('r') => self.refresh(log),
472 + KeyCode::Enter => return self.choose(),
473 + _ => {}
474 + }
475 + Flow::Continue
476 + }
477 +
478 + /// Esc steps back, and leaves once there is nowhere back to go.
479 + fn cancel(&mut self) -> Flow {
480 + if self.steps.back() {
481 + self.error = None;
482 + Flow::Continue
483 + } else {
484 + Flow::Exit
485 + }
486 + }
487 + }
488 +
489 + #[cfg(test)]
490 + mod tests {
491 + use super::*;
492 +
493 + /// Captured verbatim from `lsblk -J -b -o
494 + /// NAME,PATH,SIZE,MODEL,TYPE,RM,RO,TRAN,MOUNTPOINTS` on fw13, util-linux
495 + /// 2.39.3. Kept real rather than trimmed, because the three awkward parts
496 + /// are the whole reason this parser has rules: `zram0` is typed `disk`,
497 + /// `nvme0n1` holds the running root, and `cryptswap` is a mounted volume
498 + /// two levels below its disk. A tidied fixture is where a parser passes
499 + /// tests it would fail against a real machine.
500 + const SAMPLE: &str = r#"{
Lines truncated