max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
10 files changed,
+1753 insertions,
-970 deletions
| @@ -72,1386 +72,25 @@ | |||
| 72 | 72 | //! | |
| 73 | 73 | //! <!-- wiki: alloy-distribution --> | |
| 74 | 74 | ||
| 75 | - | use std::collections::BTreeSet; | |
| 76 | - | use std::fmt::Write as _; | |
| 77 | - | use std::path::{Path, PathBuf}; | |
| 78 | - | ||
| 79 | - | use alloy_tui::keys::{Action, classify}; | |
| 80 | - | use alloy_tui::{ | |
| 81 | - | AlloyBlock, AlloyForm, AlloyList, Cursor, FieldKind, FormRow, Hint, Severity, TextField, Theme, | |
| 82 | - | hint, text, | |
| 83 | - | }; | |
| 84 | - | use anyhow::{Context, Result}; | |
| 85 | - | use ratatui::Frame; | |
| 86 | - | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 87 | - | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 88 | - | use ratatui::text::Line; | |
| 89 | - | ||
| 90 | - | use crate::cli::{CommandLog, Invocation}; | |
| 91 | - | use crate::run::{Sequence, Stage}; | |
| 92 | - | use crate::shell::{Confirm, Flow, View, block_title}; | |
| 93 | - | ||
| 94 | 75 | /// Where a built image records the choices that made it. | |
| 95 | 76 | /// | |
| 96 | 77 | /// Read back at startup so a rebuild starts from what the machine already is | |
| 97 | 78 | /// rather than from defaults. The design note accepts that a user with neither | |
| 98 | 79 | /// a previous image nor an ISO to hand starts from defaults, which is what an | |
| 99 | 80 | /// absent file means here. | |
| 100 | - | const RECORD: &str = "/usr/lib/alloy/build.toml"; | |
| 81 | + | pub(super) const RECORD: &str = "/usr/lib/alloy/build.toml"; | |
| 101 | 82 | ||
| 102 | 83 | /// The same record, in a checkout, where the builder writes it. | |
| 103 | - | const RECORD_LOCAL: &str = "build/alloy-build.toml"; | |
| 104 | - | ||
| 105 | - | /// Where the running system states which Alloy it is. | |
| 106 | - | /// | |
| 107 | - | /// `/usr/lib` and not `/etc`: the Containerfile ships it there and symlinks | |
| 108 | - | /// `/etc/os-release` at it, because bootc reads the deployment's copy when it | |
| 109 | - | /// writes a boot menu entry. Reading the source rather than the link. | |
| 110 | - | const OS_RELEASE: &str = "/usr/lib/os-release"; | |
| 111 | - | ||
| 112 | - | /// The image's version: the product, the build it came from, and the Fedora | |
| 113 | - | /// base it was built on, composed into one line. | |
| 114 | - | /// | |
| 115 | - | /// Three fields because they move on three clocks. `VERSION_ID` is the product | |
| 116 | - | /// and moves on a release; `IMAGE_VERSION` is stamped by every build; | |
| 117 | - | /// `ALLOY_BASE` follows the Containerfile's `FROM`. Two machines on the same | |
| 118 | - | /// product version can be different images, and that is exactly the pair a | |
| 119 | - | /// support conversation has to tell apart. | |
| 120 | - | /// | |
| 121 | - | /// Separate from the console's `CARGO_PKG_VERSION`, and the two diverge on | |
| 122 | - | /// purpose: the hotfix channel exists to put a newer console on an older | |
| 123 | - | /// image, so a support conversation that cannot see both cannot tell a layered | |
| 124 | - | /// machine from a rebuilt one, so `alloy --version` reports both. | |
| 125 | - | /// | |
| 126 | - | /// `None` off an Alloy machine, which is why `ID` is checked rather than | |
| 127 | - | /// assumed: every Linux host has an os-release, and reading a dev box's Fedora | |
| 128 | - | /// or Pop!_OS `VERSION_ID` would report a confident wrong answer. There is no | |
| 129 | - | /// image there to have a version, so the line is omitted rather than filled in | |
| 130 | - | /// with "unknown". | |
| 131 | - | pub(crate) fn version() -> Option<String> { | |
| 132 | - | version_from(&std::fs::read_to_string(OS_RELEASE).ok()?) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | /// The parse, split from the read so it can be tested against the os-release | |
| 136 | - | /// this repo actually ships rather than against the host's. | |
| 137 | - | /// | |
| 138 | - | /// Composed here and not in `notice_text`, which stays a formatter with no | |
| 139 | - | /// os-release knowledge. | |
| 140 | - | /// | |
| 141 | - | /// Each of the two trailing fields is dropped rather than filled in when it is | |
| 142 | - | /// missing. An unstamped image is a real state — a bare `podman build` past the | |
| 143 | - | /// wrapper scripts — and `0.1 (Fedora 43)` says less than the full line while | |
| 144 | - | /// saying nothing false, which is what `unknown` would do. | |
| 145 | - | fn version_from(text: &str) -> Option<String> { | |
| 146 | - | // `strip_prefix` on the key and then on `=`, in that order, so a key that | |
| 147 | - | // is a prefix of another does not match it: `ID` against `ID_LIKE=fedora` | |
| 148 | - | // leaves `_LIKE=fedora`, which has no leading `=` and is skipped. | |
| 149 | - | let field = |key: &str| { | |
| 150 | - | text.lines() | |
| 151 | - | .find_map(|line| line.strip_prefix(key)?.strip_prefix('=')) | |
| 152 | - | .map(|value| value.trim_matches('"').to_string()) | |
| 153 | - | }; | |
| 154 | - | if field("ID").as_deref() != Some("alloy") { | |
| 155 | - | return None; | |
| 156 | - | } | |
| 157 | - | let product = field("VERSION_ID")?; | |
| 158 | - | let detail: Vec<String> = [ | |
| 159 | - | field("IMAGE_VERSION").map(|build| format!("build {build}")), | |
| 160 | - | field("ALLOY_BASE").map(|base| format!("Fedora {base}")), | |
| 161 | - | ] | |
| 162 | - | .into_iter() | |
| 163 | - | .flatten() | |
| 164 | - | .collect(); | |
| 165 | - | if detail.is_empty() { | |
| 166 | - | return Some(product); | |
| 167 | - | } | |
| 168 | - | Some(format!("{product} ({})", detail.join(", "))) | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | /// Which machine is being built. | |
| 172 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 173 | - | pub(crate) enum Profile { | |
| 174 | - | /// The desktop: compositor, greeter, session, browser. | |
| 175 | - | #[default] | |
| 176 | - | Client, | |
| 177 | - | /// Headless. The console, the shell stack, the hardware-health group and | |
| 178 | - | /// the themed bare console, and nothing that needs a screen. | |
| 179 | - | Server, | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | impl Profile { | |
| 183 | - | const ALL: [Profile; 2] = [Profile::Client, Profile::Server]; | |
| 184 | - | ||
| 185 | - | const fn value(self) -> &'static str { | |
| 186 | - | match self { | |
| 187 | - | Profile::Client => "client", | |
| 188 | - | Profile::Server => "server", | |
| 189 | - | } | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | const fn label(self) -> &'static str { | |
| 193 | - | match self { | |
| 194 | - | Profile::Client => "client (desktop)", | |
| 195 | - | Profile::Server => "server (headless)", | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | fn parse(value: &str) -> Option<Self> { | |
| 200 | - | Self::ALL.into_iter().find(|p| p.value() == value) | |
| 201 | - | } | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | /// The browser: a pick, and whether to carry it. | |
| 205 | - | /// | |
| 206 | - | /// Alloy picks Firefox and defends it (wiki `alloy-byo-principle`). It makes no | |
| 207 | - | /// claim that Gecko beats Blink; what it defends is the candidate. Firefox is the only browser reachable on an image-based | |
| 208 | - | /// system whose defaults Alloy can stand behind, and it stands behind them in | |
| 209 | - | /// one file rather than by adopting another project's patchset. | |
| 210 | - | /// | |
| 211 | - | /// So the screen still asks, but it is asking whether to carry Alloy's browser | |
| 212 | - | /// rather than which browser Alloy should have. | |
| 213 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 214 | - | pub(crate) enum Browser { | |
| 215 | - | /// Gecko, from Fedora's own repos, configured by | |
| 216 | - | /// `/etc/firefox/pref/alloy.js` to remove anti-features, restore one | |
| 217 | - | /// control Mozilla hides, and take one gate off a reversible act. | |
| 218 | - | #[default] | |
| 219 | - | Firefox, | |
| 220 | - | /// No browser in the image. Still a real answer: someone who wants to | |
| 221 | - | /// install their own from a flatpak remote should not pay for one they | |
| 222 | - | /// will remove. | |
| 223 | - | None, | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | impl Browser { | |
| 227 | - | const ALL: [Browser; 2] = [Browser::Firefox, Browser::None]; | |
| 228 | - | ||
| 229 | - | const fn value(self) -> &'static str { | |
| 230 | - | match self { | |
| 231 | - | Browser::Firefox => "firefox", | |
| 232 | - | Browser::None => "none", | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | const fn label(self) -> &'static str { | |
| 237 | - | match self { | |
| 238 | - | Browser::Firefox => "firefox (Gecko, from Fedora)", | |
| 239 | - | Browser::None => "none", | |
| 240 | - | } | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | fn parse(value: &str) -> Option<Self> { | |
| 244 | - | Self::ALL.into_iter().find(|b| b.value() == value) | |
| 245 | - | } | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | /// A language toolchain the image can carry. | |
| 249 | - | /// | |
| 250 | - | /// The curated set, not an arbitrary package list. Each is a toolchain someone | |
| 251 | - | /// would reasonably want to build with on the machine itself rather than in a | |
| 252 | - | /// box, which is the line between this and `alloy pkg box`. | |
| 253 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | |
| 254 | - | pub(crate) enum Lang { | |
| 255 | - | Rust, | |
| 256 | - | C, | |
| 257 | - | Go, | |
| 258 | - | Python, | |
| 259 | - | Zig, | |
| 260 | - | Js, | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | impl Lang { | |
| 264 | - | const ALL: [Lang; 6] = [ | |
| 265 | - | Lang::Rust, | |
| 266 | - | Lang::C, | |
| 267 | - | Lang::Go, | |
| 268 | - | Lang::Python, | |
| 269 | - | Lang::Zig, | |
| 270 | - | Lang::Js, | |
| 271 | - | ]; | |
| 272 | - | ||
| 273 | - | const fn value(self) -> &'static str { | |
| 274 | - | match self { | |
| 275 | - | Lang::Rust => "rust", | |
| 276 | - | Lang::C => "c", | |
| 277 | - | Lang::Go => "go", | |
| 278 | - | Lang::Python => "python", | |
| 279 | - | Lang::Zig => "zig", | |
| 280 | - | Lang::Js => "js", | |
| 281 | - | } | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | /// What the label says about cost, because the cost is the point. | |
| 285 | - | /// | |
| 286 | - | /// Rust's figure is the Containerfile's own measurement, and it is the | |
| 287 | - | /// reason this row exists: 610 MiB is an order of magnitude above the | |
| 288 | - | /// hardware-health group and by a distance the largest thing in the image. | |
| 289 | - | /// | |
| 290 | - | /// Go's is its marginal cost by exclusive closure, what turning this row on | |
| 291 | - | /// adds rather than what `golang` declares. Every toolchain is off by | |
| 292 | - | /// default, so each number is one somebody is deciding to spend. | |
| 293 | - | const fn label(self) -> &'static str { | |
| 294 | - | match self { | |
| 295 | - | Lang::Rust => "rust (610 MiB; the build-host role asks for this)", | |
| 296 | - | Lang::C => "c (193 MiB; g++, make, cmake, meson, ninja, autotools)", | |
| 297 | - | Lang::Go => "go (356 MB; rebuilds syncthing, restic, tailscale)", | |
| 298 | - | Lang::Python => "python", | |
| 299 | - | Lang::Zig => "zig", | |
| 300 | - | Lang::Js => "js (93 MiB; node and npm, for building MNW's frontends)", | |
| 301 | - | } | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | fn parse(value: &str) -> Option<Self> { | |
| 305 | - | Self::ALL.into_iter().find(|l| l.value() == value) | |
| 306 | - | } | |
| 307 | - | } | |
| 308 | - | ||
| 309 | - | /// What to do about the base image's own dead weight. | |
| 310 | - | /// | |
| 311 | - | /// The only choice on this screen that is about `fedora-bootc` rather than | |
| 312 | - | /// about Alloy. The base is a general-purpose server image and carries an AWS | |
| 313 | - | /// SDK, eighteen architectures of `qemu-user-static`, and toolbox, none of | |
| 314 | - | /// which anything in Alloy reaches; the Containerfile's `ARG TRIM` argues the | |
| 315 | - | /// list and holds the measurement. | |
| 316 | - | /// | |
| 317 | - | /// Read the measurement before quoting a number at anyone. It takes 304 MiB | |
| 318 | - | /// out of `/usr` and 3.8 MiB off the image, because the base hardlinks its | |
| 319 | - | /// `/usr` into an ostree repo the removal cannot prune. The installed system | |
| 320 | - | /// is where it should pay, and that has not been weighed yet. | |
| 321 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 322 | - | pub(crate) enum Trim { | |
| 323 | - | /// Drop them. The default, because the capability lost with them is | |
| 324 | - | /// foreign-architecture emulation, which the house rules forbid using. | |
| 325 | - | #[default] | |
| 326 | - | Unused, | |
| 327 | - | /// Ship the base as it comes, for whoever disagrees on their own machine. | |
| 328 | - | Keep, | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | impl Trim { | |
| 332 | - | const ALL: [Trim; 2] = [Trim::Unused, Trim::Keep]; | |
| 333 | - | ||
| 334 | - | const fn value(self) -> &'static str { | |
| 335 | - | match self { | |
| 336 | - | Trim::Unused => "unused", | |
| 337 | - | Trim::Keep => "keep", | |
| 338 | - | } | |
| 339 | - | } | |
| 340 | - | ||
| 341 | - | const fn label(self) -> &'static str { | |
| 342 | - | match self { | |
| 343 | - | Trim::Unused => "drop what nothing here reaches", | |
| 344 | - | Trim::Keep => "keep the base as it ships", | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | fn parse(value: &str) -> Option<Self> { | |
| 349 | - | Self::ALL.into_iter().find(|t| t.value() == value) | |
| 350 | - | } | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | /// Whether the image carries a database, and which major. | |
| 354 | - | /// | |
| 355 | - | /// The major is the whole point of the dial. Fedora's default | |
| 356 | - | /// `postgresql-server` is 18, and Sando's scratch cluster exists to test MNW | |
| 357 | - | /// against what production runs, which is PostgreSQL 16.14 on `alpha-west-1`. | |
| 358 | - | /// So `postgres16` names `postgresql16` and `postgresql16-server` rather than | |
| 359 | - | /// the unversioned packages: three packages, 38 MiB installed. | |
| 360 | - | /// | |
| 361 | - | /// Binaries only. Nothing in the image enables a unit or runs initdb; a | |
| 362 | - | /// cluster is machine state and belongs to whoever runs the machine. | |
| 363 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 364 | - | pub(crate) enum Db { | |
| 365 | - | /// No database. The default, and what every image before this dial was. | |
| 366 | - | #[default] | |
| 367 | - | None, | |
| 368 | - | /// PostgreSQL 16, the major production runs. | |
| 369 | - | Postgres16, | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | impl Db { | |
| 373 | - | const ALL: [Db; 2] = [Db::None, Db::Postgres16]; | |
| 374 | - | ||
| 375 | - | const fn value(self) -> &'static str { | |
| 376 | - | match self { | |
| 377 | - | Db::None => "none", | |
| 378 | - | Db::Postgres16 => "postgres16", | |
| 379 | - | } | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | const fn label(self) -> &'static str { | |
| 383 | - | match self { | |
| 384 | - | Db::None => "none", | |
| 385 | - | Db::Postgres16 => "postgresql 16 (38 MiB; the major prod runs)", | |
| 386 | - | } | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | fn parse(value: &str) -> Option<Self> { | |
| 390 | - | Self::ALL.into_iter().find(|d| d.value() == value) | |
| 391 | - | } | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | /// What the build produces. | |
| 395 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 396 | - | pub(crate) enum Artifact { | |
| 397 | - | /// The installer ISO that boots into `alloy install`. The decided path | |
| 398 | - | /// (wiki `alloy-distribution`): ISO plus dd. | |
| 399 | - | #[default] | |
| 400 | - | Iso, | |
| 401 | - | /// A raw disk image, for dd'ing a whole installed system. | |
| 402 | - | Raw, | |
| 403 | - | /// qcow2, for QEMU. | |
| 404 | - | Qcow2, | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | impl Artifact { | |
| 408 | - | const ALL: [Artifact; 3] = [Artifact::Iso, Artifact::Raw, Artifact::Qcow2]; | |
| 409 | - | ||
| 410 | - | const fn value(self) -> &'static str { | |
| 411 | - | match self { | |
| 412 | - | Artifact::Iso => "iso", | |
| 413 | - | Artifact::Raw => "raw", | |
| 414 | - | Artifact::Qcow2 => "qcow2", | |
| 415 | - | } | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | const fn label(self) -> &'static str { | |
| 419 | - | match self { | |
| 420 | - | Artifact::Iso => "installer ISO (boots into alloy install)", | |
| 421 | - | Artifact::Raw => "raw disk image", | |
| 422 | - | Artifact::Qcow2 => "qcow2 (QEMU)", | |
| 423 | - | } | |
| 424 | - | } | |
| 425 | - | ||
| 426 | - | /// Which script owns it. The ISO is not bib's: every ISO type | |
| 427 | - | /// bootc-image-builder offers ends in Anaconda, and an install from one | |
| 428 | - | /// leaves root locked with no account, so `build-iso.sh` builds it outside | |
| 429 | - | /// bib. `build-image.sh` refuses the ISO types at the argument for the | |
| 430 | - | /// same reason. | |
| 431 | - | const fn script(self) -> &'static str { | |
| 432 | - | match self { | |
| 433 | - | Artifact::Iso => "build/build-iso.sh", | |
| 434 | - | Artifact::Raw | Artifact::Qcow2 => "build/build-image.sh", | |
| 435 | - | } | |
| 436 | - | } | |
| 437 | - | ||
| 438 | - | fn parse(value: &str) -> Option<Self> { | |
| 439 | - | Self::ALL.into_iter().find(|a| a.value() == value) | |
| 440 | - | } | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | /// Everything the builder decides. | |
| 444 | - | /// | |
| 445 | - | /// Derived `Default`, so every row's default is declared on its own enum and | |
| 446 | - | /// there is one place per choice rather than two that can disagree. The two | |
| 447 | - | /// that are not enums default to nothing, which is what "not set" means for | |
| 448 | - | /// both. | |
| 449 | - | #[derive(Debug, Clone, Default)] | |
| 450 | - | pub(crate) struct Choices { | |
| 451 | - | pub(crate) profile: Profile, | |
| 452 | - | pub(crate) browser: Browser, | |
| 453 | - | /// Toolchains, empty by default. | |
| 454 | - | /// | |
| 455 | - | /// The default is what the image itself requires, which is nothing: | |
| 456 | - | /// measured, the shipped image compiles nothing at runtime, since the | |
| 457 | - | /// `cargo install` that builds shop runs in a builder stage the final image | |
| 458 | - | /// is not built from. A toolchain that is merely useful is picked here, at | |
| 459 | - | /// mint time, and a build host is a thing a mint asks for: fw13 and astra | |
| 460 | - | /// both take `LANGS=rust`. `ARG LANGS` in the Containerfile carries the | |
| 461 | - | /// reasoning. | |
| 462 | - | pub(crate) langs: BTreeSet<Lang>, | |
| 463 | - | pub(crate) hostname: String, | |
| 464 | - | /// Path to a public key on the building machine, not the key itself. | |
| 465 | - | /// | |
| 466 | - | /// The path is what the user picks and the bytes are read at build time, | |
| 467 | - | /// which keeps the record portable: a saved `build.toml` that embedded a | |
| 468 | - | /// key would be a different file on every machine for no reason. It is a | |
| 469 | - | /// *public* key, so nothing here is a secret either way — see the module | |
| 470 | - | /// docs on why that property is load-bearing. | |
| 471 | - | pub(crate) pubkey: String, | |
| 472 | - | pub(crate) artifact: Artifact, | |
| 473 | - | pub(crate) trim: Trim, | |
| 474 | - | pub(crate) db: Db, | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | impl Choices { | |
| 478 | - | /// The `--build-arg` pairs, in a fixed order so the displayed command is | |
| 479 | - | /// stable between frames and between runs. | |
| 480 | - | /// | |
| 481 | - | /// `LANGS` is a comma-joined list rather than one ARG per language: the | |
| 482 | - | /// Containerfile validates the whole list against its own curated set, so | |
| 483 | - | /// the gate lives there rather than in the number of arguments. That is | |
| 484 | - | /// the same layering `PROFILE` uses, and it means the build refuses an | |
| 485 | - | /// unknown language even if this screen is bypassed entirely. | |
| 486 | - | pub(crate) fn build_args(&self) -> Vec<(String, String)> { | |
| 487 | - | let mut args = vec![ | |
| 488 | - | ("PROFILE".to_string(), self.profile.value().to_string()), | |
| 489 | - | ("BROWSER".to_string(), self.browser.value().to_string()), | |
| 490 | - | ( | |
| 491 | - | "LANGS".to_string(), | |
| 492 | - | self.langs | |
| 493 | - | .iter() | |
| 494 | - | .map(|lang| lang.value()) | |
| 495 | - | .collect::<Vec<_>>() | |
| 496 | - | .join(","), | |
| 497 | - | ), | |
| 498 | - | ("TRIM".to_string(), self.trim.value().to_string()), | |
| 499 | - | ("DB".to_string(), self.db.value().to_string()), | |
| 500 | - | ]; | |
| 501 | - | if !self.hostname.is_empty() { | |
| 502 | - | args.push(("ALLOY_HOSTNAME".to_string(), self.hostname.clone())); | |
| 503 | - | } | |
| 504 | - | // The KEY, not the path to it. `pubkey` is a path on the building | |
| 505 | - | // machine and the build container cannot see it: the file is not in | |
| 506 | - | // the build context, and putting it there would mean writing a key | |
| 507 | - | // into the repo. So the bytes are read here and travel as the value, | |
| 508 | - | // which is also why the record on disk keeps the path instead — a | |
| 509 | - | // saved `build.toml` holding an embedded key would be a different | |
| 510 | - | // file on every machine for no reason. | |
| 511 | - | // | |
| 512 | - | // Unreadable is silently omitted rather than guessed at, because | |
| 513 | - | // `blockers` is the gate and has already refused to start a build | |
| 514 | - | // whose key cannot be read. Passing the path through as if it were a | |
| 515 | - | // key would make the Containerfile's own validation reject it, which | |
| 516 | - | // is a confusing way to learn the file is missing. | |
| 517 | - | if !self.pubkey.is_empty() | |
| 518 | - | && let Ok(contents) = std::fs::read_to_string(&self.pubkey) | |
| 519 | - | { | |
| 520 | - | let key = contents.trim(); | |
| 521 | - | if !key.is_empty() { | |
| 522 | - | args.push(("ALLOY_SSH_KEY".to_string(), key.to_string())); | |
| 523 | - | } | |
| 524 | - | } | |
| 525 | - | args | |
| 526 | - | } | |
| 527 | - | ||
| 528 | - | /// The command that builds this. | |
| 529 | - | /// | |
| 530 | - | /// One `--build-arg` per pair rather than a packed string, so the log pane | |
| 531 | - | /// shows exactly what podman will receive and the line is copy-pasteable. | |
| 532 | - | pub(crate) fn invocation(&self) -> Invocation { | |
| 533 | - | let mut invocation = Invocation::new(self.artifact.script()); | |
| 534 | - | ||
| 535 | - | // The disk-image script needs a type; the ISO script builds one thing | |
| 536 | - | // and takes no type at all, and passing one would be an error rather | |
| 537 | - | // than a no-op. | |
| 538 | - | if self.artifact != Artifact::Iso { | |
| 539 | - | invocation = invocation.args(["--type", self.artifact.value()]); | |
| 540 | - | } | |
| 541 | - | ||
| 542 | - | for (key, value) in self.build_args() { | |
| 543 | - | invocation = invocation.arg("--build-arg").arg(format!("{key}={value}")); | |
| 544 | - | } | |
| 545 | - | invocation | |
| 546 | - | } | |
| 547 | - | ||
| 548 | - | /// Reasons this cannot be built, in the order a user would hit them. | |
| 549 | - | /// | |
| 550 | - | /// Returned rather than rendered so the summary and the build gate read | |
| 551 | - | /// the same list. An empty vector is the only thing that starts a build. | |
| 552 | - | pub(crate) fn blockers(&self, repo: Option<&Path>) -> Vec<String> { | |
| 553 | - | let mut blockers = Vec::new(); | |
| 554 | - | ||
| 555 | - | match repo { | |
| 556 | - | None => blockers.push( | |
| 557 | - | "no Alloy checkout here: run this from a clone, or pass one with --repo. \ | |
| 558 | - | The builder drives build/build-iso.sh from the source tree." | |
| 559 | - | .to_string(), | |
| 560 | - | ), | |
| 561 | - | Some(root) => { | |
| 562 | - | let script = root.join(self.artifact.script()); | |
| 563 | - | if !script.is_file() { | |
| 564 | - | blockers.push(format!("{} is not in this checkout", script.display())); | |
| 565 | - | } | |
| 566 | - | } | |
| 567 | - | } | |
| 568 | - | ||
| 569 | - | if !self.hostname.is_empty() && !valid_hostname(&self.hostname) { | |
| 570 | - | blockers.push(format!( |
Lines truncated
| @@ -1,0 +1,441 @@ | |||
| 1 | + | //! The builder's choices: one enum per row, and what they become as argv. | |
| 2 | + | ||
| 3 | + | use std::collections::BTreeSet; | |
| 4 | + | use std::path::{Path, PathBuf}; | |
| 5 | + | ||
| 6 | + | use crate::cli::Invocation; | |
| 7 | + | ||
| 8 | + | use super::discover::looks_like_pubkey; | |
| 9 | + | ||
| 10 | + | /// Which machine is being built. | |
| 11 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 12 | + | pub(crate) enum Profile { | |
| 13 | + | /// The desktop: compositor, greeter, session, browser. | |
| 14 | + | #[default] | |
| 15 | + | Client, | |
| 16 | + | /// Headless. The console, the shell stack, the hardware-health group and | |
| 17 | + | /// the themed bare console, and nothing that needs a screen. | |
| 18 | + | Server, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl Profile { | |
| 22 | + | pub(super) const ALL: [Profile; 2] = [Profile::Client, Profile::Server]; | |
| 23 | + | ||
| 24 | + | pub(super) const fn value(self) -> &'static str { | |
| 25 | + | match self { | |
| 26 | + | Profile::Client => "client", | |
| 27 | + | Profile::Server => "server", | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | pub(super) const fn label(self) -> &'static str { | |
| 32 | + | match self { | |
| 33 | + | Profile::Client => "client (desktop)", | |
| 34 | + | Profile::Server => "server (headless)", | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | pub(super) fn parse(value: &str) -> Option<Self> { | |
| 39 | + | Self::ALL.into_iter().find(|p| p.value() == value) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// The browser: a pick, and whether to carry it. | |
| 44 | + | /// | |
| 45 | + | /// Alloy picks Firefox and defends it (wiki `alloy-byo-principle`). It makes no | |
| 46 | + | /// claim that Gecko beats Blink; what it defends is the candidate. Firefox is the only browser reachable on an image-based | |
| 47 | + | /// system whose defaults Alloy can stand behind, and it stands behind them in | |
| 48 | + | /// one file rather than by adopting another project's patchset. | |
| 49 | + | /// | |
| 50 | + | /// So the screen still asks, but it is asking whether to carry Alloy's browser | |
| 51 | + | /// rather than which browser Alloy should have. | |
| 52 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 53 | + | pub(crate) enum Browser { | |
| 54 | + | /// Gecko, from Fedora's own repos, configured by | |
| 55 | + | /// `/etc/firefox/pref/alloy.js` to remove anti-features, restore one | |
| 56 | + | /// control Mozilla hides, and take one gate off a reversible act. | |
| 57 | + | #[default] | |
| 58 | + | Firefox, | |
| 59 | + | /// No browser in the image. Still a real answer: someone who wants to | |
| 60 | + | /// install their own from a flatpak remote should not pay for one they | |
| 61 | + | /// will remove. | |
| 62 | + | None, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | impl Browser { | |
| 66 | + | pub(super) const ALL: [Browser; 2] = [Browser::Firefox, Browser::None]; | |
| 67 | + | ||
| 68 | + | pub(super) const fn value(self) -> &'static str { | |
| 69 | + | match self { | |
| 70 | + | Browser::Firefox => "firefox", | |
| 71 | + | Browser::None => "none", | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | pub(super) const fn label(self) -> &'static str { | |
| 76 | + | match self { | |
| 77 | + | Browser::Firefox => "firefox (Gecko, from Fedora)", | |
| 78 | + | Browser::None => "none", | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | pub(super) fn parse(value: &str) -> Option<Self> { | |
| 83 | + | Self::ALL.into_iter().find(|b| b.value() == value) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// A language toolchain the image can carry. | |
| 88 | + | /// | |
| 89 | + | /// The curated set, not an arbitrary package list. Each is a toolchain someone | |
| 90 | + | /// would reasonably want to build with on the machine itself rather than in a | |
| 91 | + | /// box, which is the line between this and `alloy pkg box`. | |
| 92 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | |
| 93 | + | pub(crate) enum Lang { | |
| 94 | + | Rust, | |
| 95 | + | C, | |
| 96 | + | Go, | |
| 97 | + | Python, | |
| 98 | + | Zig, | |
| 99 | + | Js, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | impl Lang { | |
| 103 | + | pub(super) const ALL: [Lang; 6] = [ | |
| 104 | + | Lang::Rust, | |
| 105 | + | Lang::C, | |
| 106 | + | Lang::Go, | |
| 107 | + | Lang::Python, | |
| 108 | + | Lang::Zig, | |
| 109 | + | Lang::Js, | |
| 110 | + | ]; | |
| 111 | + | ||
| 112 | + | pub(super) const fn value(self) -> &'static str { | |
| 113 | + | match self { | |
| 114 | + | Lang::Rust => "rust", | |
| 115 | + | Lang::C => "c", | |
| 116 | + | Lang::Go => "go", | |
| 117 | + | Lang::Python => "python", | |
| 118 | + | Lang::Zig => "zig", | |
| 119 | + | Lang::Js => "js", | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// What the label says about cost, because the cost is the point. | |
| 124 | + | /// | |
| 125 | + | /// Rust's figure is the Containerfile's own measurement, and it is the | |
| 126 | + | /// reason this row exists: 610 MiB is an order of magnitude above the | |
| 127 | + | /// hardware-health group and by a distance the largest thing in the image. | |
| 128 | + | /// | |
| 129 | + | /// Go's is its marginal cost by exclusive closure, what turning this row on | |
| 130 | + | /// adds rather than what `golang` declares. Every toolchain is off by | |
| 131 | + | /// default, so each number is one somebody is deciding to spend. | |
| 132 | + | pub(super) const fn label(self) -> &'static str { | |
| 133 | + | match self { | |
| 134 | + | Lang::Rust => "rust (610 MiB; the build-host role asks for this)", | |
| 135 | + | Lang::C => "c (193 MiB; g++, make, cmake, meson, ninja, autotools)", | |
| 136 | + | Lang::Go => "go (356 MB; rebuilds syncthing, restic, tailscale)", | |
| 137 | + | Lang::Python => "python", | |
| 138 | + | Lang::Zig => "zig", | |
| 139 | + | Lang::Js => "js (93 MiB; node and npm, for building MNW's frontends)", | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | pub(super) fn parse(value: &str) -> Option<Self> { | |
| 144 | + | Self::ALL.into_iter().find(|l| l.value() == value) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// What to do about the base image's own dead weight. | |
| 149 | + | /// | |
| 150 | + | /// The only choice on this screen that is about `fedora-bootc` rather than | |
| 151 | + | /// about Alloy. The base is a general-purpose server image and carries an AWS | |
| 152 | + | /// SDK, eighteen architectures of `qemu-user-static`, and toolbox, none of | |
| 153 | + | /// which anything in Alloy reaches; the Containerfile's `ARG TRIM` argues the | |
| 154 | + | /// list and holds the measurement. | |
| 155 | + | /// | |
| 156 | + | /// Read the measurement before quoting a number at anyone. It takes 304 MiB | |
| 157 | + | /// out of `/usr` and 3.8 MiB off the image, because the base hardlinks its | |
| 158 | + | /// `/usr` into an ostree repo the removal cannot prune. The installed system | |
| 159 | + | /// is where it should pay, and that has not been weighed yet. | |
| 160 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 161 | + | pub(crate) enum Trim { | |
| 162 | + | /// Drop them. The default, because the capability lost with them is | |
| 163 | + | /// foreign-architecture emulation, which the house rules forbid using. | |
| 164 | + | #[default] | |
| 165 | + | Unused, | |
| 166 | + | /// Ship the base as it comes, for whoever disagrees on their own machine. | |
| 167 | + | Keep, | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | impl Trim { | |
| 171 | + | pub(super) const ALL: [Trim; 2] = [Trim::Unused, Trim::Keep]; | |
| 172 | + | ||
| 173 | + | pub(super) const fn value(self) -> &'static str { | |
| 174 | + | match self { | |
| 175 | + | Trim::Unused => "unused", | |
| 176 | + | Trim::Keep => "keep", | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | pub(super) const fn label(self) -> &'static str { | |
| 181 | + | match self { | |
| 182 | + | Trim::Unused => "drop what nothing here reaches", | |
| 183 | + | Trim::Keep => "keep the base as it ships", | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | pub(super) fn parse(value: &str) -> Option<Self> { | |
| 188 | + | Self::ALL.into_iter().find(|t| t.value() == value) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// Whether the image carries a database, and which major. | |
| 193 | + | /// | |
| 194 | + | /// The major is the whole point of the dial. Fedora's default | |
| 195 | + | /// `postgresql-server` is 18, and Sando's scratch cluster exists to test MNW | |
| 196 | + | /// against what production runs, which is PostgreSQL 16.14 on `alpha-west-1`. | |
| 197 | + | /// So `postgres16` names `postgresql16` and `postgresql16-server` rather than | |
| 198 | + | /// the unversioned packages: three packages, 38 MiB installed. | |
| 199 | + | /// | |
| 200 | + | /// Binaries only. Nothing in the image enables a unit or runs initdb; a | |
| 201 | + | /// cluster is machine state and belongs to whoever runs the machine. | |
| 202 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 203 | + | pub(crate) enum Db { | |
| 204 | + | /// No database. The default, and what every image before this dial was. | |
| 205 | + | #[default] | |
| 206 | + | None, | |
| 207 | + | /// PostgreSQL 16, the major production runs. | |
| 208 | + | Postgres16, | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | impl Db { | |
| 212 | + | pub(super) const ALL: [Db; 2] = [Db::None, Db::Postgres16]; | |
| 213 | + | ||
| 214 | + | pub(super) const fn value(self) -> &'static str { | |
| 215 | + | match self { | |
| 216 | + | Db::None => "none", | |
| 217 | + | Db::Postgres16 => "postgres16", | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | pub(super) const fn label(self) -> &'static str { | |
| 222 | + | match self { | |
| 223 | + | Db::None => "none", | |
| 224 | + | Db::Postgres16 => "postgresql 16 (38 MiB; the major prod runs)", | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | pub(super) fn parse(value: &str) -> Option<Self> { | |
| 229 | + | Self::ALL.into_iter().find(|d| d.value() == value) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | /// What the build produces. | |
| 234 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 235 | + | pub(crate) enum Artifact { | |
| 236 | + | /// The installer ISO that boots into `alloy install`. The decided path | |
| 237 | + | /// (wiki `alloy-distribution`): ISO plus dd. | |
| 238 | + | #[default] | |
| 239 | + | Iso, | |
| 240 | + | /// A raw disk image, for dd'ing a whole installed system. | |
| 241 | + | Raw, | |
| 242 | + | /// qcow2, for QEMU. | |
| 243 | + | Qcow2, | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | impl Artifact { | |
| 247 | + | pub(super) const ALL: [Artifact; 3] = [Artifact::Iso, Artifact::Raw, Artifact::Qcow2]; | |
| 248 | + | ||
| 249 | + | pub(super) const fn value(self) -> &'static str { | |
| 250 | + | match self { | |
| 251 | + | Artifact::Iso => "iso", | |
| 252 | + | Artifact::Raw => "raw", | |
| 253 | + | Artifact::Qcow2 => "qcow2", | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | pub(super) const fn label(self) -> &'static str { | |
| 258 | + | match self { | |
| 259 | + | Artifact::Iso => "installer ISO (boots into alloy install)", | |
| 260 | + | Artifact::Raw => "raw disk image", | |
| 261 | + | Artifact::Qcow2 => "qcow2 (QEMU)", | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | /// Which script owns it. The ISO is not bib's: every ISO type | |
| 266 | + | /// bootc-image-builder offers ends in Anaconda, and an install from one | |
| 267 | + | /// leaves root locked with no account, so `build-iso.sh` builds it outside | |
| 268 | + | /// bib. `build-image.sh` refuses the ISO types at the argument for the | |
| 269 | + | /// same reason. | |
| 270 | + | pub(super) const fn script(self) -> &'static str { | |
| 271 | + | match self { | |
| 272 | + | Artifact::Iso => "build/build-iso.sh", | |
| 273 | + | Artifact::Raw | Artifact::Qcow2 => "build/build-image.sh", | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | pub(super) fn parse(value: &str) -> Option<Self> { | |
| 278 | + | Self::ALL.into_iter().find(|a| a.value() == value) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | /// Everything the builder decides. | |
| 283 | + | /// | |
| 284 | + | /// Derived `Default`, so every row's default is declared on its own enum and | |
| 285 | + | /// there is one place per choice rather than two that can disagree. The two | |
| 286 | + | /// that are not enums default to nothing, which is what "not set" means for | |
| 287 | + | /// both. | |
| 288 | + | #[derive(Debug, Clone, Default)] | |
| 289 | + | pub(crate) struct Choices { | |
| 290 | + | pub(crate) profile: Profile, | |
| 291 | + | pub(crate) browser: Browser, | |
| 292 | + | /// Toolchains, empty by default. | |
| 293 | + | /// | |
| 294 | + | /// The default is what the image itself requires, which is nothing: | |
| 295 | + | /// measured, the shipped image compiles nothing at runtime, since the | |
| 296 | + | /// `cargo install` that builds shop runs in a builder stage the final image | |
| 297 | + | /// is not built from. A toolchain that is merely useful is picked here, at | |
| 298 | + | /// mint time, and a build host is a thing a mint asks for: fw13 and astra | |
| 299 | + | /// both take `LANGS=rust`. `ARG LANGS` in the Containerfile carries the | |
| 300 | + | /// reasoning. | |
| 301 | + | pub(crate) langs: BTreeSet<Lang>, | |
| 302 | + | pub(crate) hostname: String, | |
| 303 | + | /// Path to a public key on the building machine, not the key itself. | |
| 304 | + | /// | |
| 305 | + | /// The path is what the user picks and the bytes are read at build time, | |
| 306 | + | /// which keeps the record portable: a saved `build.toml` that embedded a | |
| 307 | + | /// key would be a different file on every machine for no reason. It is a | |
| 308 | + | /// *public* key, so nothing here is a secret either way — see the module | |
| 309 | + | /// docs on why that property is load-bearing. | |
| 310 | + | pub(crate) pubkey: String, | |
| 311 | + | pub(crate) artifact: Artifact, | |
| 312 | + | pub(crate) trim: Trim, | |
| 313 | + | pub(crate) db: Db, | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | impl Choices { | |
| 317 | + | /// The `--build-arg` pairs, in a fixed order so the displayed command is | |
| 318 | + | /// stable between frames and between runs. | |
| 319 | + | /// | |
| 320 | + | /// `LANGS` is a comma-joined list rather than one ARG per language: the | |
| 321 | + | /// Containerfile validates the whole list against its own curated set, so | |
| 322 | + | /// the gate lives there rather than in the number of arguments. That is | |
| 323 | + | /// the same layering `PROFILE` uses, and it means the build refuses an | |
| 324 | + | /// unknown language even if this screen is bypassed entirely. | |
| 325 | + | pub(crate) fn build_args(&self) -> Vec<(String, String)> { | |
| 326 | + | let mut args = vec![ | |
| 327 | + | ("PROFILE".to_string(), self.profile.value().to_string()), | |
| 328 | + | ("BROWSER".to_string(), self.browser.value().to_string()), | |
| 329 | + | ( | |
| 330 | + | "LANGS".to_string(), | |
| 331 | + | self.langs | |
| 332 | + | .iter() | |
| 333 | + | .map(|lang| lang.value()) | |
| 334 | + | .collect::<Vec<_>>() | |
| 335 | + | .join(","), | |
| 336 | + | ), | |
| 337 | + | ("TRIM".to_string(), self.trim.value().to_string()), | |
| 338 | + | ("DB".to_string(), self.db.value().to_string()), | |
| 339 | + | ]; | |
| 340 | + | if !self.hostname.is_empty() { | |
| 341 | + | args.push(("ALLOY_HOSTNAME".to_string(), self.hostname.clone())); | |
| 342 | + | } | |
| 343 | + | // The KEY, not the path to it. `pubkey` is a path on the building | |
| 344 | + | // machine and the build container cannot see it: the file is not in | |
| 345 | + | // the build context, and putting it there would mean writing a key | |
| 346 | + | // into the repo. So the bytes are read here and travel as the value, | |
| 347 | + | // which is also why the record on disk keeps the path instead — a | |
| 348 | + | // saved `build.toml` holding an embedded key would be a different | |
| 349 | + | // file on every machine for no reason. | |
| 350 | + | // | |
| 351 | + | // Unreadable is silently omitted rather than guessed at, because | |
| 352 | + | // `blockers` is the gate and has already refused to start a build | |
| 353 | + | // whose key cannot be read. Passing the path through as if it were a | |
| 354 | + | // key would make the Containerfile's own validation reject it, which | |
| 355 | + | // is a confusing way to learn the file is missing. | |
| 356 | + | if !self.pubkey.is_empty() | |
| 357 | + | && let Ok(contents) = std::fs::read_to_string(&self.pubkey) | |
| 358 | + | { | |
| 359 | + | let key = contents.trim(); | |
| 360 | + | if !key.is_empty() { | |
| 361 | + | args.push(("ALLOY_SSH_KEY".to_string(), key.to_string())); | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | args | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | /// The command that builds this. | |
| 368 | + | /// | |
| 369 | + | /// One `--build-arg` per pair rather than a packed string, so the log pane | |
| 370 | + | /// shows exactly what podman will receive and the line is copy-pasteable. | |
| 371 | + | pub(crate) fn invocation(&self) -> Invocation { | |
| 372 | + | let mut invocation = Invocation::new(self.artifact.script()); | |
| 373 | + | ||
| 374 | + | // The disk-image script needs a type; the ISO script builds one thing | |
| 375 | + | // and takes no type at all, and passing one would be an error rather | |
| 376 | + | // than a no-op. | |
| 377 | + | if self.artifact != Artifact::Iso { | |
| 378 | + | invocation = invocation.args(["--type", self.artifact.value()]); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | for (key, value) in self.build_args() { | |
| 382 | + | invocation = invocation.arg("--build-arg").arg(format!("{key}={value}")); | |
| 383 | + | } | |
| 384 | + | invocation | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /// Reasons this cannot be built, in the order a user would hit them. | |
| 388 | + | /// | |
| 389 | + | /// Returned rather than rendered so the summary and the build gate read | |
| 390 | + | /// the same list. An empty vector is the only thing that starts a build. | |
| 391 | + | pub(crate) fn blockers(&self, repo: Option<&Path>) -> Vec<String> { | |
| 392 | + | let mut blockers = Vec::new(); | |
| 393 | + | ||
| 394 | + | match repo { | |
| 395 | + | None => blockers.push( | |
| 396 | + | "no Alloy checkout here: run this from a clone, or pass one with --repo. \ | |
| 397 | + | The builder drives build/build-iso.sh from the source tree." | |
| 398 | + | .to_string(), | |
| 399 | + | ), | |
| 400 | + | Some(root) => { | |
| 401 | + | let script = root.join(self.artifact.script()); | |
| 402 | + | if !script.is_file() { | |
| 403 | + | blockers.push(format!("{} is not in this checkout", script.display())); | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | if !self.hostname.is_empty() | |
| 409 | + | && let Err(why) = crate::install::validate_hostname(&self.hostname) | |
| 410 | + | { | |
| 411 | + | blockers.push(format!("`{}` is not a hostname: {why}", self.hostname)); | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | if !self.pubkey.is_empty() { | |
| 415 | + | let path = PathBuf::from(&self.pubkey); | |
| 416 | + | if !path.is_file() { | |
| 417 | + | blockers.push(format!("no public key at {}", path.display())); | |
| 418 | + | } else if std::fs::read_to_string(&path) | |
| 419 | + | .is_ok_and(|contents| !looks_like_pubkey(&contents)) | |
| 420 | + | { | |
| 421 | + | blockers.push(format!( | |
| 422 | + | "{} does not look like an SSH public key. If this is a PRIVATE key, do not \ | |
| 423 | + | bake it in: the artifact is meant to be copyable without care.", | |
| 424 | + | path.display() | |
| 425 | + | )); | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | if self.profile == Profile::Server && self.browser != Browser::None { | |
| 430 | + | blockers.push( | |
| 431 | + | "the server profile ships no graphical session, so it cannot carry a browser" | |
| 432 | + | .to_string(), | |
| 433 | + | ); | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | blockers | |
| 437 | + | } | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | #[cfg(test)] | |
| 441 | + | mod tests; |
| @@ -1,0 +1,203 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | #[test] | |
| 6 | + | fn the_default_is_the_desktop_with_what_the_image_requires() { | |
| 7 | + | let choices = Choices::default(); | |
| 8 | + | assert_eq!(choices.profile, Profile::Client); | |
| 9 | + | assert_eq!(choices.browser, Browser::Firefox); | |
| 10 | + | // No toolchain. The image requires none to function, and a build | |
| 11 | + | // host asks for one at mint. See the comment on `Choices::default`. | |
| 12 | + | assert!(choices.langs.is_empty()); | |
| 13 | + | assert_eq!(choices.artifact, Artifact::Iso); | |
| 14 | + | // Trimmed by default. What it costs is foreign-architecture emulation, | |
| 15 | + | // which the house rules forbid using in the first place. | |
| 16 | + | assert_eq!(choices.trim, Trim::Unused); | |
| 17 | + | // No database. It is opt-in for the build-host role and every image | |
| 18 | + | // before the dial existed carried none. | |
| 19 | + | assert_eq!(choices.db, Db::None); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// The build args are the whole contract with the Containerfile, so their | |
| 23 | + | /// names and their order are asserted rather than left to whatever the | |
| 24 | + | /// struct happens to iterate. | |
| 25 | + | #[test] | |
| 26 | + | fn the_build_args_name_what_the_containerfile_reads() { | |
| 27 | + | let args = Choices::default().build_args(); | |
| 28 | + | let names: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).collect(); | |
| 29 | + | assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM", "DB"]); | |
| 30 | + | assert_eq!(args[0].1, "client"); | |
| 31 | + | // Comma-joined in the enum's declared order, and this is also the | |
| 32 | + | // literal the Containerfile's own `ARG LANGS` default has to match: | |
| 33 | + | // the two defaults are one decision written in two files, and a build | |
| 34 | + | // that bypasses the TUI must get the same stack the TUI would have | |
| 35 | + | // asked for. | |
| 36 | + | assert_eq!(args[2].1, ""); | |
| 37 | + | assert_eq!(args[3].1, "unused"); | |
| 38 | + | assert_eq!(args[4].1, "none"); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[test] | |
| 42 | + | fn identity_args_appear_only_once_they_are_set() { | |
| 43 | + | let mut choices = Choices::default(); | |
| 44 | + | assert!( | |
| 45 | + | !choices | |
| 46 | + | .build_args() | |
| 47 | + | .iter() | |
| 48 | + | .any(|(k, _)| k == "ALLOY_HOSTNAME") | |
| 49 | + | ); | |
| 50 | + | choices.hostname = "bench".to_string(); | |
| 51 | + | assert!( | |
| 52 | + | choices | |
| 53 | + | .build_args() | |
| 54 | + | .iter() | |
| 55 | + | .any(|(k, _)| k == "ALLOY_HOSTNAME") | |
| 56 | + | ); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[test] | |
| 60 | + | fn several_languages_join_into_one_arg() { | |
| 61 | + | let choices = Choices { | |
| 62 | + | langs: BTreeSet::from([Lang::Rust, Lang::Go, Lang::Zig]), | |
| 63 | + | ..Choices::default() | |
| 64 | + | }; | |
| 65 | + | let args = choices.build_args(); | |
| 66 | + | let langs = &args.iter().find(|(k, _)| k == "LANGS").unwrap().1; | |
| 67 | + | // BTreeSet order, which is the enum's declared order, so the arg is | |
| 68 | + | // stable between runs rather than however a HashSet felt that day. | |
| 69 | + | assert_eq!(langs, "rust,go,zig"); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// The ISO does not come from bootc-image-builder, and passing it a type | |
| 73 | + | /// would be an error rather than a no-op. | |
| 74 | + | #[test] | |
| 75 | + | fn the_iso_and_the_disk_images_use_different_scripts() { | |
| 76 | + | let mut choices = Choices::default(); | |
| 77 | + | assert_eq!(choices.artifact.script(), "build/build-iso.sh"); | |
| 78 | + | assert!(!choices.invocation().display().contains("--type")); | |
| 79 | + | ||
| 80 | + | choices.artifact = Artifact::Raw; | |
| 81 | + | assert_eq!(choices.artifact.script(), "build/build-image.sh"); | |
| 82 | + | assert!(choices.invocation().display().contains("--type raw")); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | #[test] | |
| 86 | + | fn the_command_is_copy_pasteable() { | |
| 87 | + | let choices = Choices { | |
| 88 | + | hostname: "bench".to_string(), | |
| 89 | + | ..Choices::default() | |
| 90 | + | }; | |
| 91 | + | let shown = choices.invocation().display(); | |
| 92 | + | assert!(shown.starts_with("build/build-iso.sh")); | |
| 93 | + | assert!(shown.contains("--build-arg PROFILE=client")); | |
| 94 | + | assert!(shown.contains("--build-arg ALLOY_HOSTNAME=bench")); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// The hostname rule is the installer's, so a name refused on one screen is | |
| 98 | + | /// refused on the other. Dots included: `/etc/hostname` holding a dotted name | |
| 99 | + | /// makes `hostname -s` and `hostname -f` disagree. | |
| 100 | + | #[test] | |
| 101 | + | fn hostnames_are_checked_the_way_the_installer_checks_them() { | |
| 102 | + | let blocked = |name: &str| { | |
| 103 | + | Choices { | |
| 104 | + | hostname: name.to_string(), | |
| 105 | + | ..Choices::default() | |
| 106 | + | } | |
| 107 | + | .blockers(Some(Path::new("/tmp"))) | |
| 108 | + | .iter() | |
| 109 | + | .any(|blocker| blocker.contains("is not a hostname")) | |
| 110 | + | }; | |
| 111 | + | assert!(!blocked("bench")); | |
| 112 | + | assert!(!blocked("build-host-2")); | |
| 113 | + | assert!(!blocked(&"a".repeat(63))); | |
| 114 | + | assert!(blocked("-leading")); | |
| 115 | + | assert!(blocked("trailing-")); | |
| 116 | + | assert!(blocked("under_score")); | |
| 117 | + | assert!(blocked("has space")); | |
| 118 | + | assert!(blocked(&"a".repeat(64))); | |
| 119 | + | assert!(blocked("bench.local"), "a dotted name is not a short name"); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// A server has no session, so it cannot carry a browser. The blocker | |
| 123 | + | /// exists for a record loaded off disk; the key handler prevents reaching | |
| 124 | + | /// the state interactively. | |
| 125 | + | #[test] | |
| 126 | + | fn a_server_carrying_a_browser_is_blocked() { | |
| 127 | + | let choices = Choices { | |
| 128 | + | profile: Profile::Server, | |
| 129 | + | browser: Browser::Firefox, | |
| 130 | + | ..Choices::default() | |
| 131 | + | }; | |
| 132 | + | let blockers = choices.blockers(Some(Path::new("/nonexistent"))); | |
| 133 | + | assert!( | |
| 134 | + | blockers.iter().any(|b| b.contains("no graphical session")), | |
| 135 | + | "{blockers:?}" | |
| 136 | + | ); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn no_checkout_is_the_first_thing_reported() { | |
| 141 | + | let blockers = Choices::default().blockers(None); | |
| 142 | + | assert!(blockers[0].contains("no Alloy checkout"), "{blockers:?}"); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// The build container cannot see a path on the building machine — the | |
| 146 | + | /// file is not in the build context — so the key travels as its bytes. | |
| 147 | + | /// Passing the path would make the Containerfile's own validation reject | |
| 148 | + | /// it, which is a confusing way to learn a file is missing. | |
| 149 | + | #[test] | |
| 150 | + | fn the_key_travels_as_bytes_and_the_record_keeps_the_path() { | |
| 151 | + | let dir = std::env::temp_dir().join(format!("alloy-image-key-{}", std::process::id())); | |
| 152 | + | std::fs::create_dir_all(&dir).expect("scratch dir"); | |
| 153 | + | let path = dir.join("id_ed25519.pub"); | |
| 154 | + | std::fs::write( | |
| 155 | + | &path, | |
| 156 | + | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n", | |
| 157 | + | ) | |
| 158 | + | .expect("write key"); | |
| 159 | + | ||
| 160 | + | let choices = Choices { | |
| 161 | + | pubkey: path.display().to_string(), | |
| 162 | + | ..Choices::default() | |
| 163 | + | }; | |
| 164 | + | ||
| 165 | + | let key = choices | |
| 166 | + | .build_args() | |
| 167 | + | .into_iter() | |
| 168 | + | .find(|(name, _)| name == "ALLOY_SSH_KEY") | |
| 169 | + | .expect("the key is passed") | |
| 170 | + | .1; | |
| 171 | + | assert!(key.starts_with("ssh-ed25519 AAAA"), "{key}"); | |
| 172 | + | // Trimmed: a trailing newline inside a --build-arg value would land in | |
| 173 | + | // the authorized_keys file and in the validation `case`. | |
| 174 | + | assert!(!key.ends_with('\n')); | |
| 175 | + | ||
| 176 | + | // The record keeps the path, so a saved build.toml is not a different | |
| 177 | + | // file on every machine. | |
| 178 | + | assert!(choices.to_toml().contains("id_ed25519.pub")); | |
| 179 | + | ||
| 180 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /// An unreadable key is omitted rather than guessed at, because `blockers` | |
| 184 | + | /// is the gate and has already refused to start the build. | |
| 185 | + | #[test] | |
| 186 | + | fn an_unreadable_key_is_omitted_and_blocked() { | |
| 187 | + | let choices = Choices { | |
| 188 | + | pubkey: "/nonexistent/id_ed25519.pub".to_string(), | |
| 189 | + | ..Choices::default() | |
| 190 | + | }; | |
| 191 | + | assert!( | |
| 192 | + | !choices | |
| 193 | + | .build_args() | |
| 194 | + | .iter() | |
| 195 | + | .any(|(name, _)| name == "ALLOY_SSH_KEY") | |
| 196 | + | ); | |
| 197 | + | assert!( | |
| 198 | + | choices | |
| 199 | + | .blockers(None) | |
| 200 | + | .iter() | |
| 201 | + | .any(|blocker| blocker.contains("no public key at")), | |
| 202 | + | ); | |
| 203 | + | } |
| @@ -1,0 +1,147 @@ | |||
| 1 | + | //! What the building machine can be asked about itself: the public keys it | |
| 2 | + | //! carries, and whether it is standing in an Alloy checkout. | |
| 3 | + | ||
| 4 | + | use std::path::PathBuf; | |
| 5 | + | ||
| 6 | + | /// Whether a file looks like an SSH *public* key. | |
| 7 | + | /// | |
| 8 | + | /// Loose on purpose about which algorithm, strict about the shape: two or | |
| 9 | + | /// three whitespace-separated fields whose first names a key type. The | |
| 10 | + | /// question worth answering is not "is this ed25519" but "is this the private | |
| 11 | + | /// half by mistake", because that is the error that turns a shareable artifact | |
| 12 | + | /// into a leaked credential. | |
| 13 | + | pub(super) fn looks_like_pubkey(contents: &str) -> bool { | |
| 14 | + | let Some(line) = contents.lines().find(|line| !line.trim().is_empty()) else { | |
| 15 | + | return false; | |
| 16 | + | }; | |
| 17 | + | if line.contains("PRIVATE KEY") { | |
| 18 | + | return false; | |
| 19 | + | } | |
| 20 | + | let mut fields = line.split_whitespace(); | |
| 21 | + | let Some(kind) = fields.next() else { | |
| 22 | + | return false; | |
| 23 | + | }; | |
| 24 | + | let known = kind.starts_with("ssh-") || kind.starts_with("ecdsa-") || kind.starts_with("sk-"); | |
| 25 | + | known && fields.next().is_some_and(|body| body.len() > 16) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// The public keys sitting in `~/.ssh` on the minting host. | |
| 29 | + | /// | |
| 30 | + | /// Sorted, and filtered by [`looks_like_pubkey`] rather than by the `.pub` | |
| 31 | + | /// suffix alone: `~/.ssh` collects other people's keys, `known_hosts` fragments | |
| 32 | + | /// and the occasional misnamed private half, and offering one of those as a | |
| 33 | + | /// candidate is how a private key gets baked into an artifact whose whole | |
| 34 | + | /// premise is that it holds no secrets. | |
| 35 | + | /// | |
| 36 | + | /// **This never reaches the network.** `gh:username` was considered and takes | |
| 37 | + | /// the explicit opt-in the installer's timezone geolocation already has; until | |
| 38 | + | /// someone builds that, the only keys offered are ones already on this disk. | |
| 39 | + | pub(super) fn discover_pubkeys() -> Vec<String> { | |
| 40 | + | let Some(home) = std::env::var_os("HOME") else { | |
| 41 | + | return Vec::new(); | |
| 42 | + | }; | |
| 43 | + | let Ok(entries) = std::fs::read_dir(PathBuf::from(home).join(".ssh")) else { | |
| 44 | + | return Vec::new(); | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | let mut found: Vec<String> = entries | |
| 48 | + | .flatten() | |
| 49 | + | .map(|entry| entry.path()) | |
| 50 | + | .filter(|path| path.extension().is_some_and(|ext| ext == "pub")) | |
| 51 | + | .filter(|path| { | |
| 52 | + | std::fs::read_to_string(path).is_ok_and(|contents| looks_like_pubkey(&contents)) | |
| 53 | + | }) | |
| 54 | + | .map(|path| path.display().to_string()) | |
| 55 | + | .collect(); | |
| 56 | + | found.sort(); | |
| 57 | + | found | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Find the Alloy checkout to drive. | |
| 61 | + | /// | |
| 62 | + | /// Walks up from the working directory looking for the two things that make a | |
| 63 | + | /// directory this repo rather than any repo. Both are named: a Containerfile | |
| 64 | + | /// alone is most container projects, and `build/build-iso.sh` alone would | |
| 65 | + | /// match a fork that moved it. | |
| 66 | + | pub(super) fn find_repo() -> Option<PathBuf> { | |
| 67 | + | let mut dir = std::env::current_dir().ok()?; | |
| 68 | + | loop { | |
| 69 | + | if dir.join("Containerfile").is_file() && dir.join("build/build-iso.sh").is_file() { | |
| 70 | + | return Some(dir); | |
| 71 | + | } | |
| 72 | + | if !dir.pop() { | |
| 73 | + | return None; | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[cfg(test)] | |
| 79 | + | mod tests { | |
| 80 | + | use super::*; | |
| 81 | + | ||
| 82 | + | /// The one check that matters here: a private key must never be mistaken | |
| 83 | + | /// for a public one, because the artifact is meant to be copyable without | |
| 84 | + | /// care and baking a private key in would silently make that false. | |
| 85 | + | #[test] | |
| 86 | + | fn a_private_key_is_not_mistaken_for_a_public_one() { | |
| 87 | + | let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n"; // gitleaks:allow | |
| 88 | + | assert!(!looks_like_pubkey(private)); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | #[test] | |
| 92 | + | fn a_public_key_is_recognized() { | |
| 93 | + | assert!(looks_like_pubkey( | |
| 94 | + | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n" | |
| 95 | + | )); | |
| 96 | + | assert!(looks_like_pubkey( | |
| 97 | + | "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY= max@fw13" | |
| 98 | + | )); | |
| 99 | + | assert!(!looks_like_pubkey("")); | |
| 100 | + | assert!(!looks_like_pubkey("hello world")); | |
| 101 | + | // A key type with no body is not a key. | |
| 102 | + | assert!(!looks_like_pubkey("ssh-ed25519")); | |
| 103 | + | assert!(!looks_like_pubkey("ssh-ed25519 short")); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Discovery is filtered by shape, not by suffix. `~/.ssh` collects other | |
| 107 | + | /// files, and a misnamed private half offered as a candidate is how a | |
| 108 | + | /// secret reaches an artifact that promises to hold none. | |
| 109 | + | #[test] | |
| 110 | + | fn discovery_refuses_anything_that_is_not_a_public_key() { | |
| 111 | + | let dir = std::env::temp_dir().join(format!("alloy-image-scan-{}", std::process::id())); | |
| 112 | + | let ssh = dir.join(".ssh"); | |
| 113 | + | std::fs::create_dir_all(&ssh).expect("scratch dir"); | |
| 114 | + | ||
| 115 | + | std::fs::write( | |
| 116 | + | ssh.join("id_ed25519.pub"), | |
| 117 | + | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n", | |
| 118 | + | ) | |
| 119 | + | .expect("write key"); | |
| 120 | + | // A private key that someone named `.pub`. The suffix is not the check. | |
| 121 | + | std::fs::write( | |
| 122 | + | ssh.join("oops.pub"), | |
| 123 | + | "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n", | |
| 124 | + | ) | |
| 125 | + | .expect("write private"); | |
| 126 | + | // And the ordinary neighbours, which have no `.pub` at all. | |
| 127 | + | std::fs::write(ssh.join("known_hosts"), "github.com ssh-ed25519 AAAA\n") | |
| 128 | + | .expect("write known_hosts"); | |
| 129 | + | ||
| 130 | + | // SAFETY: single-threaded within this test's own scratch HOME. The | |
| 131 | + | // discovery reads HOME rather than taking a directory because that is | |
| 132 | + | // what it does in the program, and testing a different function would | |
| 133 | + | // test nothing. | |
| 134 | + | let restore = std::env::var_os("HOME"); | |
| 135 | + | unsafe { std::env::set_var("HOME", &dir) }; | |
| 136 | + | let found = discover_pubkeys(); | |
| 137 | + | match restore { | |
| 138 | + | Some(home) => unsafe { std::env::set_var("HOME", home) }, | |
| 139 | + | None => unsafe { std::env::remove_var("HOME") }, | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | assert_eq!(found.len(), 1, "{found:?}"); | |
| 143 | + | assert!(found[0].ends_with("id_ed25519.pub"), "{found:?}"); | |
| 144 | + | ||
| 145 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 146 | + | } | |
| 147 | + | } |
| @@ -1,0 +1,154 @@ | |||
| 1 | + | //! The build.toml round trip: choices to a file a person can read, and back. | |
| 2 | + | ||
| 3 | + | use std::collections::BTreeSet; | |
| 4 | + | use std::fmt::Write as _; | |
| 5 | + | ||
| 6 | + | use anyhow::{Context, Result}; | |
| 7 | + | ||
| 8 | + | use super::choices::{Artifact, Browser, Choices, Db, Lang, Profile, Trim}; | |
| 9 | + | use super::{RECORD, RECORD_LOCAL}; | |
| 10 | + | ||
| 11 | + | impl Choices { | |
| 12 | + | /// The record, as TOML. | |
| 13 | + | /// | |
| 14 | + | /// Hand-written rather than serialized, for one reason: the file is read | |
| 15 | + | /// by a person deciding whether to rebuild, so it carries the commentary | |
| 16 | + | /// that makes it answerable. A derived `Serialize` would emit the same | |
| 17 | + | /// keys and none of the why. | |
| 18 | + | pub(crate) fn to_toml(&self) -> String { | |
| 19 | + | let mut out = String::new(); | |
| 20 | + | out.push_str("# Alloy build record — the choices that made this image.\n"); | |
| 21 | + | out.push_str("# Written by `alloy image`, and read back by it so a rebuild starts\n"); | |
| 22 | + | out.push_str("# from what this machine already is rather than from defaults.\n"); | |
| 23 | + | out.push_str("#\n"); | |
| 24 | + | out.push_str("# This is the CHOICES half. It is not a lockfile: it does not pin the\n"); | |
| 25 | + | out.push_str("# resolved RPM set, so rebuilding from it gives you the same decisions\n"); | |
| 26 | + | out.push_str("# against today's packages, not the same image. See wiki\n"); | |
| 27 | + | out.push_str("# `alloy-distribution` for why the resolutions half is still open.\n\n"); | |
| 28 | + | ||
| 29 | + | let _ = writeln!(out, "profile = {:?}", self.profile.value()); | |
| 30 | + | let _ = writeln!(out, "browser = {:?}", self.browser.value()); | |
| 31 | + | let langs: Vec<String> = self | |
| 32 | + | .langs | |
| 33 | + | .iter() | |
| 34 | + | .map(|lang| format!("{:?}", lang.value())) | |
| 35 | + | .collect(); | |
| 36 | + | let _ = writeln!(out, "langs = [{}]", langs.join(", ")); | |
| 37 | + | let _ = writeln!(out, "trim = {:?}", self.trim.value()); | |
| 38 | + | let _ = writeln!(out, "db = {:?}", self.db.value()); | |
| 39 | + | let _ = writeln!(out, "artifact = {:?}", self.artifact.value()); | |
| 40 | + | let _ = writeln!(out, "hostname = {:?}", self.hostname); | |
| 41 | + | let _ = writeln!(out, "pubkey = {:?}", self.pubkey); | |
| 42 | + | out | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Parse a record back. | |
| 46 | + | /// | |
| 47 | + | /// An unknown value for an enum is an error rather than a silent fall back | |
| 48 | + | /// to the default. A record naming a browser this build does not offer is | |
| 49 | + | /// a record from a different version of Alloy, and quietly building | |
| 50 | + | /// something else is how a user ends up with an image they did not ask | |
| 51 | + | /// for and cannot explain. | |
| 52 | + | pub(crate) fn from_toml(raw: &str) -> Result<Self> { | |
| 53 | + | let doc: toml::Value = toml::from_str(raw).context("the build record is not valid TOML")?; | |
| 54 | + | ||
| 55 | + | let word = |key: &str| -> Option<&str> { doc.get(key).and_then(toml::Value::as_str) }; | |
| 56 | + | ||
| 57 | + | let mut choices = Self::default(); | |
| 58 | + | ||
| 59 | + | if let Some(value) = word("profile") { | |
| 60 | + | choices.profile = | |
| 61 | + | Profile::parse(value).with_context(|| format!("unknown profile `{value}`"))?; | |
| 62 | + | } | |
| 63 | + | if let Some(value) = word("browser") { | |
| 64 | + | choices.browser = | |
| 65 | + | Browser::parse(value).with_context(|| format!("unknown browser `{value}`"))?; | |
| 66 | + | } | |
| 67 | + | if let Some(value) = word("trim") { | |
| 68 | + | choices.trim = Trim::parse(value).with_context(|| format!("unknown trim `{value}`"))?; | |
| 69 | + | } | |
| 70 | + | if let Some(value) = word("db") { | |
| 71 | + | choices.db = Db::parse(value).with_context(|| format!("unknown db `{value}`"))?; | |
| 72 | + | } | |
| 73 | + | if let Some(value) = word("artifact") { | |
| 74 | + | choices.artifact = | |
| 75 | + | Artifact::parse(value).with_context(|| format!("unknown artifact `{value}`"))?; | |
| 76 | + | } | |
| 77 | + | if let Some(value) = word("hostname") { | |
| 78 | + | choices.hostname = value.to_string(); | |
| 79 | + | } | |
| 80 | + | if let Some(value) = word("pubkey") { | |
| 81 | + | choices.pubkey = value.to_string(); | |
| 82 | + | } | |
| 83 | + | if let Some(values) = doc.get("langs").and_then(toml::Value::as_array) { | |
| 84 | + | let mut langs = BTreeSet::new(); | |
| 85 | + | for value in values { | |
| 86 | + | let name = value | |
| 87 | + | .as_str() | |
| 88 | + | .context("the langs list holds something that is not a string")?; | |
| 89 | + | langs.insert( | |
| 90 | + | Lang::parse(name).with_context(|| format!("unknown language `{name}`"))?, | |
| 91 | + | ); | |
| 92 | + | } | |
| 93 | + | choices.langs = langs; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | Ok(choices) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// The record a checkout or a built image carries, and where it came from. | |
| 100 | + | /// | |
| 101 | + | /// Checkout first, image second. Both can be present at once — a clone on | |
| 102 | + | /// an Alloy machine — and the checkout is the more recent statement of | |
| 103 | + | /// intent: it is what the builder last saved, where the image record is | |
| 104 | + | /// what was last built. | |
| 105 | + | /// | |
| 106 | + | /// **A record that exists and does not parse is reported, not skipped.** | |
| 107 | + | /// Falling through to the next path and then to defaults is the | |
| 108 | + | /// silent-forgetting failure this whole feature exists to avoid: a builder | |
| 109 | + | /// that quietly loses the machine's configuration and says nothing. A | |
| 110 | + | /// record from a newer Alloy naming a browser this build does not offer is | |
| 111 | + | /// exactly how that happens. | |
| 112 | + | pub(super) fn load() -> Loaded { | |
| 113 | + | for path in [RECORD_LOCAL, RECORD] { | |
| 114 | + | let Ok(raw) = std::fs::read_to_string(path) else { | |
| 115 | + | continue; | |
| 116 | + | }; | |
| 117 | + | return match Self::from_toml(&raw) { | |
| 118 | + | Ok(choices) => Loaded { | |
| 119 | + | choices, | |
| 120 | + | from: Some(path.to_string()), | |
| 121 | + | problem: None, | |
| 122 | + | }, | |
| 123 | + | Err(err) => Loaded { | |
| 124 | + | choices: Self::default(), | |
| 125 | + | from: None, | |
| 126 | + | problem: Some(format!( | |
| 127 | + | "{path} could not be read, so these are defaults rather than \ | |
| 128 | + | this machine's choices: {err}" | |
| 129 | + | )), | |
| 130 | + | }, | |
| 131 | + | }; | |
| 132 | + | } | |
| 133 | + | Loaded { | |
| 134 | + | choices: Self::default(), | |
| 135 | + | from: None, | |
| 136 | + | problem: None, | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// What [`Choices::load`] found, including the case where it found something | |
| 142 | + | /// broken. Separate from `Option<Choices>` so a bad record cannot be confused | |
| 143 | + | /// with no record: one is worth saying out loud and the other is ordinary. | |
| 144 | + | pub(super) struct Loaded { | |
| 145 | + | pub(super) choices: Choices, | |
| 146 | + | /// Which file the choices came from, for the screen to name. `None` means | |
| 147 | + | /// they are defaults. | |
| 148 | + | pub(super) from: Option<String>, | |
| 149 | + | /// A record that exists and did not parse. | |
| 150 | + | pub(super) problem: Option<String>, | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[cfg(test)] | |
| 154 | + | mod tests; |
| @@ -1,0 +1,77 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | #[test] | |
| 6 | + | fn a_record_round_trips() { | |
| 7 | + | let choices = Choices { | |
| 8 | + | profile: Profile::Server, | |
| 9 | + | browser: Browser::None, | |
| 10 | + | langs: BTreeSet::from([Lang::Go]), | |
| 11 | + | artifact: Artifact::Qcow2, | |
| 12 | + | trim: Trim::Keep, | |
| 13 | + | db: Db::Postgres16, | |
| 14 | + | hostname: "bench".to_string(), | |
| 15 | + | pubkey: "/home/max/.ssh/id_ed25519.pub".to_string(), | |
| 16 | + | }; | |
| 17 | + | ||
| 18 | + | let parsed = Choices::from_toml(&choices.to_toml()).expect("round trip"); | |
| 19 | + | assert_eq!(parsed.profile, Profile::Server); | |
| 20 | + | assert_eq!(parsed.browser, Browser::None); | |
| 21 | + | assert_eq!(parsed.langs, BTreeSet::from([Lang::Go])); | |
| 22 | + | assert_eq!(parsed.artifact, Artifact::Qcow2); | |
| 23 | + | assert_eq!(parsed.trim, Trim::Keep); | |
| 24 | + | assert_eq!(parsed.db, Db::Postgres16); | |
| 25 | + | assert_eq!(parsed.hostname, "bench"); | |
| 26 | + | assert_eq!(parsed.pubkey, "/home/max/.ssh/id_ed25519.pub"); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// A record from a different version of Alloy fails loudly. Falling back | |
| 30 | + | /// to the default would build something the user did not ask for and | |
| 31 | + | /// could not explain. | |
| 32 | + | #[test] | |
| 33 | + | fn an_unknown_value_is_an_error_rather_than_a_default() { | |
| 34 | + | let err = Choices::from_toml("browser = \"netscape\"").unwrap_err(); | |
| 35 | + | assert!(format!("{err}").contains("netscape"), "{err}"); | |
| 36 | + | ||
| 37 | + | let err = Choices::from_toml("langs = [\"cobol\"]").unwrap_err(); | |
| 38 | + | assert!(format!("{err}").contains("cobol"), "{err}"); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[test] | |
| 42 | + | fn an_empty_record_is_the_default() { | |
| 43 | + | let parsed = Choices::from_toml("").expect("empty is valid"); | |
| 44 | + | assert_eq!(parsed.profile, Profile::Client); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// What a built image records, parsed by the same code that reads it back. | |
| 48 | + | /// The image writes `artifact` nowhere (it does not know which one it was | |
| 49 | + | /// packed into) and `pubkey` empty (the path meant something on another | |
| 50 | + | /// machine), so both have to land on their defaults rather than erroring. | |
| 51 | + | #[test] | |
| 52 | + | fn the_record_an_image_carries_reads_back() { | |
| 53 | + | let from_image = "\ | |
| 54 | + | profile = \"server\" | |
| 55 | + | browser = \"none\" | |
| 56 | + | langs = [\"rust\", \"go\"] | |
| 57 | + | hostname = \"bench\" | |
| 58 | + | pubkey = \"\" | |
| 59 | + | "; | |
| 60 | + | let parsed = Choices::from_toml(from_image).expect("an image record parses"); | |
| 61 | + | assert_eq!(parsed.profile, Profile::Server); | |
| 62 | + | assert_eq!(parsed.browser, Browser::None); | |
| 63 | + | assert_eq!(parsed.langs, BTreeSet::from([Lang::Rust, Lang::Go])); | |
| 64 | + | assert_eq!(parsed.hostname, "bench"); | |
| 65 | + | assert!(parsed.pubkey.is_empty()); | |
| 66 | + | // Absent, so the default. Not an error, and not a guess. | |
| 67 | + | assert_eq!(parsed.artifact, Artifact::Iso); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[test] | |
| 71 | + | fn the_record_says_it_is_not_a_lockfile() { | |
| 72 | + | let toml = Choices::default().to_toml(); | |
| 73 | + | assert!( | |
| 74 | + | toml.contains("not a lockfile"), | |
| 75 | + | "the record must not read as one: it pins choices, not resolutions", | |
| 76 | + | ); | |
| 77 | + | } |
| @@ -1,482 +1,0 @@ | |||
| 1 | - | //! Tests for [`super`]. | |
| 2 | - | ||
| 3 | - | use super::*; | |
| 4 | - | ||
| 5 | - | #[test] | |
| 6 | - | fn the_default_is_the_desktop_with_what_the_image_requires() { | |
| 7 | - | let choices = Choices::default(); | |
| 8 | - | assert_eq!(choices.profile, Profile::Client); | |
| 9 | - | assert_eq!(choices.browser, Browser::Firefox); | |
| 10 | - | // No toolchain. The image requires none to function, and a build | |
| 11 | - | // host asks for one at mint. See the comment on `Choices::default`. | |
| 12 | - | assert!(choices.langs.is_empty()); | |
| 13 | - | assert_eq!(choices.artifact, Artifact::Iso); | |
| 14 | - | // Trimmed by default. What it costs is foreign-architecture emulation, | |
| 15 | - | // which the house rules forbid using in the first place. | |
| 16 | - | assert_eq!(choices.trim, Trim::Unused); | |
| 17 | - | // No database. It is opt-in for the build-host role and every image | |
| 18 | - | // before the dial existed carried none. | |
| 19 | - | assert_eq!(choices.db, Db::None); | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | /// The build args are the whole contract with the Containerfile, so their | |
| 23 | - | /// names and their order are asserted rather than left to whatever the | |
| 24 | - | /// struct happens to iterate. | |
| 25 | - | #[test] | |
| 26 | - | fn the_build_args_name_what_the_containerfile_reads() { | |
| 27 | - | let args = Choices::default().build_args(); | |
| 28 | - | let names: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).collect(); | |
| 29 | - | assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM", "DB"]); | |
| 30 | - | assert_eq!(args[0].1, "client"); | |
| 31 | - | // Comma-joined in the enum's declared order, and this is also the | |
| 32 | - | // literal the Containerfile's own `ARG LANGS` default has to match: | |
| 33 | - | // the two defaults are one decision written in two files, and a build | |
| 34 | - | // that bypasses the TUI must get the same stack the TUI would have | |
| 35 | - | // asked for. | |
| 36 | - | assert_eq!(args[2].1, ""); | |
| 37 | - | assert_eq!(args[3].1, "unused"); | |
| 38 | - | assert_eq!(args[4].1, "none"); | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | #[test] | |
| 42 | - | fn identity_args_appear_only_once_they_are_set() { | |
| 43 | - | let mut choices = Choices::default(); | |
| 44 | - | assert!( | |
| 45 | - | !choices | |
| 46 | - | .build_args() | |
| 47 | - | .iter() | |
| 48 | - | .any(|(k, _)| k == "ALLOY_HOSTNAME") | |
| 49 | - | ); | |
| 50 | - | choices.hostname = "bench".to_string(); | |
| 51 | - | assert!( | |
| 52 | - | choices | |
| 53 | - | .build_args() | |
| 54 | - | .iter() | |
| 55 | - | .any(|(k, _)| k == "ALLOY_HOSTNAME") | |
| 56 | - | ); | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | #[test] | |
| 60 | - | fn several_languages_join_into_one_arg() { | |
| 61 | - | let choices = Choices { | |
| 62 | - | langs: BTreeSet::from([Lang::Rust, Lang::Go, Lang::Zig]), | |
| 63 | - | ..Choices::default() | |
| 64 | - | }; | |
| 65 | - | let args = choices.build_args(); | |
| 66 | - | let langs = &args.iter().find(|(k, _)| k == "LANGS").unwrap().1; | |
| 67 | - | // BTreeSet order, which is the enum's declared order, so the arg is | |
| 68 | - | // stable between runs rather than however a HashSet felt that day. | |
| 69 | - | assert_eq!(langs, "rust,go,zig"); | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | /// The ISO does not come from bootc-image-builder, and passing it a type | |
| 73 | - | /// would be an error rather than a no-op. | |
| 74 | - | #[test] | |
| 75 | - | fn the_iso_and_the_disk_images_use_different_scripts() { | |
| 76 | - | let mut choices = Choices::default(); | |
| 77 | - | assert_eq!(choices.artifact.script(), "build/build-iso.sh"); | |
| 78 | - | assert!(!choices.invocation().display().contains("--type")); | |
| 79 | - | ||
| 80 | - | choices.artifact = Artifact::Raw; | |
| 81 | - | assert_eq!(choices.artifact.script(), "build/build-image.sh"); | |
| 82 | - | assert!(choices.invocation().display().contains("--type raw")); | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | #[test] | |
| 86 | - | fn the_command_is_copy_pasteable() { | |
| 87 | - | let choices = Choices { | |
| 88 | - | hostname: "bench".to_string(), | |
| 89 | - | ..Choices::default() | |
| 90 | - | }; | |
| 91 | - | let shown = choices.invocation().display(); | |
| 92 | - | assert!(shown.starts_with("build/build-iso.sh")); | |
| 93 | - | assert!(shown.contains("--build-arg PROFILE=client")); | |
| 94 | - | assert!(shown.contains("--build-arg ALLOY_HOSTNAME=bench")); | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | #[test] | |
| 98 | - | fn a_record_round_trips() { | |
| 99 | - | let choices = Choices { | |
| 100 | - | profile: Profile::Server, | |
| 101 | - | browser: Browser::None, | |
| 102 | - | langs: BTreeSet::from([Lang::Go]), | |
| 103 | - | artifact: Artifact::Qcow2, | |
| 104 | - | trim: Trim::Keep, | |
| 105 | - | db: Db::Postgres16, | |
| 106 | - | hostname: "bench".to_string(), | |
| 107 | - | pubkey: "/home/max/.ssh/id_ed25519.pub".to_string(), | |
| 108 | - | }; | |
| 109 | - | ||
| 110 | - | let parsed = Choices::from_toml(&choices.to_toml()).expect("round trip"); | |
| 111 | - | assert_eq!(parsed.profile, Profile::Server); | |
| 112 | - | assert_eq!(parsed.browser, Browser::None); | |
| 113 | - | assert_eq!(parsed.langs, BTreeSet::from([Lang::Go])); | |
| 114 | - | assert_eq!(parsed.artifact, Artifact::Qcow2); | |
| 115 | - | assert_eq!(parsed.trim, Trim::Keep); | |
| 116 | - | assert_eq!(parsed.db, Db::Postgres16); | |
| 117 | - | assert_eq!(parsed.hostname, "bench"); | |
| 118 | - | assert_eq!(parsed.pubkey, "/home/max/.ssh/id_ed25519.pub"); | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | /// A record from a different version of Alloy fails loudly. Falling back | |
| 122 | - | /// to the default would build something the user did not ask for and | |
| 123 | - | /// could not explain. | |
| 124 | - | #[test] | |
| 125 | - | fn an_unknown_value_is_an_error_rather_than_a_default() { | |
| 126 | - | let err = Choices::from_toml("browser = \"netscape\"").unwrap_err(); | |
| 127 | - | assert!(format!("{err}").contains("netscape"), "{err}"); | |
| 128 | - | ||
| 129 | - | let err = Choices::from_toml("langs = [\"cobol\"]").unwrap_err(); | |
| 130 | - | assert!(format!("{err}").contains("cobol"), "{err}"); | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | #[test] | |
| 134 | - | fn an_empty_record_is_the_default() { | |
| 135 | - | let parsed = Choices::from_toml("").expect("empty is valid"); | |
| 136 | - | assert_eq!(parsed.profile, Profile::Client); | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | #[test] | |
| 140 | - | fn hostnames_are_checked_the_way_dns_checks_them() { | |
| 141 | - | assert!(valid_hostname("bench")); | |
| 142 | - | assert!(valid_hostname("build-host-2")); | |
| 143 | - | assert!(!valid_hostname("")); | |
| 144 | - | assert!(!valid_hostname("-leading")); | |
| 145 | - | assert!(!valid_hostname("trailing-")); | |
| 146 | - | assert!(!valid_hostname("under_score")); | |
| 147 | - | assert!(!valid_hostname("has space")); | |
| 148 | - | assert!(!valid_hostname(&"a".repeat(64))); | |
| 149 | - | assert!(valid_hostname(&"a".repeat(63))); | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | /// The one check that matters here: a private key must never be mistaken | |
| 153 | - | /// for a public one, because the artifact is meant to be copyable without | |
| 154 | - | /// care and baking a private key in would silently make that false. | |
| 155 | - | #[test] | |
| 156 | - | fn a_private_key_is_not_mistaken_for_a_public_one() { | |
| 157 | - | let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n"; | |
| 158 | - | assert!(!looks_like_pubkey(private)); | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | #[test] | |
| 162 | - | fn a_public_key_is_recognized() { | |
| 163 | - | assert!(looks_like_pubkey( | |
| 164 | - | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n" | |
| 165 | - | )); | |
| 166 | - | assert!(looks_like_pubkey( | |
| 167 | - | "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY= max@fw13" | |
| 168 | - | )); | |
| 169 | - | assert!(!looks_like_pubkey("")); | |
| 170 | - | assert!(!looks_like_pubkey("hello world")); | |
| 171 | - | // A key type with no body is not a key. | |
| 172 | - | assert!(!looks_like_pubkey("ssh-ed25519")); | |
| 173 | - | assert!(!looks_like_pubkey("ssh-ed25519 short")); | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | /// A server has no session, so it cannot carry a browser. The blocker | |
| 177 | - | /// exists for a record loaded off disk; the key handler prevents reaching | |
| 178 | - | /// the state interactively. | |
| 179 | - | #[test] | |
| 180 | - | fn a_server_carrying_a_browser_is_blocked() { | |
| 181 | - | let choices = Choices { | |
| 182 | - | profile: Profile::Server, | |
| 183 | - | browser: Browser::Firefox, | |
| 184 | - | ..Choices::default() | |
| 185 | - | }; | |
| 186 | - | let blockers = choices.blockers(Some(Path::new("/nonexistent"))); | |
| 187 | - | assert!( | |
| 188 | - | blockers.iter().any(|b| b.contains("no graphical session")), | |
| 189 | - | "{blockers:?}" | |
| 190 | - | ); | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | #[test] | |
| 194 | - | fn no_checkout_is_the_first_thing_reported() { | |
| 195 | - | let blockers = Choices::default().blockers(None); | |
| 196 | - | assert!(blockers[0].contains("no Alloy checkout"), "{blockers:?}"); | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | /// The write goes through the script's own path, with `--write-only` so it | |
| 200 | - | /// writes the artifact that already exists rather than rebuilding it. | |
| 201 | - | /// Re-deriving this is the disk-eating bug the design note warns about. | |
| 202 | - | #[test] | |
| 203 | - | fn the_write_delegates_to_the_script_that_owns_the_guards() { | |
| 204 | - | let shown = ImageView { | |
| 205 | - | choices: Choices::default(), | |
| 206 | - | cursor: Cursor::new(), | |
| 207 | - | repo: None, | |
| 208 | - | candidates: Vec::new(), | |
| 209 | - | editing: None, | |
| 210 | - | sequence: None, | |
| 211 | - | pending_write: None, | |
| 212 | - | device: None, | |
| 213 | - | error: None, | |
| 214 | - | saved: false, | |
| 215 | - | } | |
| 216 | - | .write_command("/dev/sdX") | |
| 217 | - | .display(); | |
| 218 | - | ||
| 219 | - | assert!(shown.starts_with("build/build-iso.sh")); | |
| 220 | - | assert!(shown.contains("--write-only")); | |
| 221 | - | assert!(shown.contains("--write /dev/sdX")); | |
| 222 | - | // Nothing resembling a dd, anywhere. | |
| 223 | - | assert!(!shown.contains("dd ")); | |
| 224 | - | assert!(!shown.contains("of=")); | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | /// The build container cannot see a path on the building machine — the | |
| 228 | - | /// file is not in the build context — so the key travels as its bytes. | |
| 229 | - | /// Passing the path would make the Containerfile's own validation reject | |
| 230 | - | /// it, which is a confusing way to learn a file is missing. | |
| 231 | - | #[test] | |
| 232 | - | fn the_key_travels_as_bytes_and_the_record_keeps_the_path() { | |
| 233 | - | let dir = std::env::temp_dir().join(format!("alloy-image-key-{}", std::process::id())); | |
| 234 | - | std::fs::create_dir_all(&dir).expect("scratch dir"); | |
| 235 | - | let path = dir.join("id_ed25519.pub"); | |
| 236 | - | std::fs::write( | |
| 237 | - | &path, | |
| 238 | - | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n", | |
| 239 | - | ) | |
| 240 | - | .expect("write key"); | |
| 241 | - | ||
| 242 | - | let choices = Choices { | |
| 243 | - | pubkey: path.display().to_string(), | |
| 244 | - | ..Choices::default() | |
| 245 | - | }; | |
| 246 | - | ||
| 247 | - | let key = choices | |
| 248 | - | .build_args() | |
| 249 | - | .into_iter() | |
| 250 | - | .find(|(name, _)| name == "ALLOY_SSH_KEY") | |
| 251 | - | .expect("the key is passed") | |
| 252 | - | .1; | |
| 253 | - | assert!(key.starts_with("ssh-ed25519 AAAA"), "{key}"); | |
| 254 | - | // Trimmed: a trailing newline inside a --build-arg value would land in | |
| 255 | - | // the authorized_keys file and in the validation `case`. | |
| 256 | - | assert!(!key.ends_with('\n')); | |
| 257 | - | ||
| 258 | - | // The record keeps the path, so a saved build.toml is not a different | |
| 259 | - | // file on every machine. | |
| 260 | - | assert!(choices.to_toml().contains("id_ed25519.pub")); | |
| 261 | - | ||
| 262 | - | let _ = std::fs::remove_dir_all(&dir); | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | /// An unreadable key is omitted rather than guessed at, because `blockers` | |
| 266 | - | /// is the gate and has already refused to start the build. | |
| 267 | - | #[test] | |
| 268 | - | fn an_unreadable_key_is_omitted_and_blocked() { | |
| 269 | - | let choices = Choices { | |
| 270 | - | pubkey: "/nonexistent/id_ed25519.pub".to_string(), | |
| 271 | - | ..Choices::default() | |
| 272 | - | }; | |
| 273 | - | assert!( | |
| 274 | - | !choices | |
| 275 | - | .build_args() | |
| 276 | - | .iter() | |
| 277 | - | .any(|(name, _)| name == "ALLOY_SSH_KEY") | |
| 278 | - | ); | |
| 279 | - | assert!( | |
| 280 | - | choices | |
| 281 | - | .blockers(None) | |
| 282 | - | .iter() | |
| 283 | - | .any(|blocker| blocker.contains("no public key at")), | |
| 284 | - | ); | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | /// What a built image records, parsed by the same code that reads it back. | |
| 288 | - | /// The image writes `artifact` nowhere (it does not know which one it was | |
| 289 | - | /// packed into) and `pubkey` empty (the path meant something on another | |
| 290 | - | /// machine), so both have to land on their defaults rather than erroring. | |
| 291 | - | #[test] | |
| 292 | - | fn the_record_an_image_carries_reads_back() { | |
| 293 | - | let from_image = "\ | |
| 294 | - | profile = \"server\" | |
| 295 | - | browser = \"none\" | |
| 296 | - | langs = [\"rust\", \"go\"] | |
| 297 | - | hostname = \"bench\" | |
| 298 | - | pubkey = \"\" | |
| 299 | - | "; | |
| 300 | - | let parsed = Choices::from_toml(from_image).expect("an image record parses"); | |
| 301 | - | assert_eq!(parsed.profile, Profile::Server); | |
| 302 | - | assert_eq!(parsed.browser, Browser::None); | |
| 303 | - | assert_eq!(parsed.langs, BTreeSet::from([Lang::Rust, Lang::Go])); | |
| 304 | - | assert_eq!(parsed.hostname, "bench"); | |
| 305 | - | assert!(parsed.pubkey.is_empty()); | |
| 306 | - | // Absent, so the default. Not an error, and not a guess. | |
| 307 | - | assert_eq!(parsed.artifact, Artifact::Iso); | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | /// A view with a known candidate list, so the cycling can be exercised | |
| 311 | - | /// without a `~/.ssh` to stand in front of it. | |
| 312 | - | fn view_with(candidates: &[&str]) -> ImageView { | |
| 313 | - | ImageView { | |
| 314 | - | choices: Choices::default(), | |
| 315 | - | cursor: Cursor::new(), | |
| 316 | - | repo: None, | |
| 317 | - | candidates: candidates.iter().map(|key| (*key).to_string()).collect(), | |
| 318 | - | editing: None, | |
| 319 | - | sequence: None, | |
| 320 | - | pending_write: None, | |
| 321 | - | device: None, | |
| 322 | - | error: None, | |
| 323 | - | saved: false, | |
| 324 | - | } | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | /// Empty is a position on the ring, not a state to escape. An image with no | |
| 328 | - | /// baked key is the ordinary desktop install, so it has to stay reachable | |
| 329 | - | /// once a key has been cycled onto the row. | |
| 330 | - | #[test] | |
| 331 | - | fn cycling_the_pubkey_row_passes_back_through_none() { | |
| 332 | - | let mut view = view_with(&["/home/max/.ssh/a.pub", "/home/max/.ssh/b.pub"]); | |
| 333 | - | assert_eq!(view.choices.pubkey, ""); | |
| 334 | - | ||
| 335 | - | view.cycle_pubkey(true); | |
| 336 | - | assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub"); | |
| 337 | - | view.cycle_pubkey(true); | |
| 338 | - | assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub"); | |
| 339 | - | view.cycle_pubkey(true); | |
| 340 | - | assert_eq!(view.choices.pubkey, "", "the ring returns to no key"); | |
| 341 | - | ||
| 342 | - | // And backwards, off none onto the last one. | |
| 343 | - | view.cycle_pubkey(false); | |
| 344 | - | assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub"); | |
| 345 | - | } | |
| 346 | - | ||
| 347 | - | /// A path typed by hand is not one of the candidates, so it reads as the | |
| 348 | - | /// empty position rather than panicking on a lookup that finds nothing. | |
| 349 | - | #[test] | |
| 350 | - | fn a_typed_path_is_not_lost_to_an_index_it_never_had() { | |
| 351 | - | let mut view = view_with(&["/home/max/.ssh/a.pub"]); | |
| 352 | - | view.choices.pubkey = "/elsewhere/key.pub".to_string(); | |
| 353 | - | view.cycle_pubkey(true); | |
| 354 | - | assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub"); | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | /// Nothing to cycle says so, rather than silently doing nothing. An inert | |
| 358 | - | /// key reads as the form being broken. | |
| 359 | - | #[test] | |
| 360 | - | fn no_candidates_explains_itself() { | |
| 361 | - | let mut view = view_with(&[]); | |
| 362 | - | view.cycle_pubkey(true); | |
| 363 | - | assert_eq!(view.choices.pubkey, ""); | |
| 364 | - | assert!( | |
| 365 | - | view.error.as_deref().is_some_and(|e| e.contains("~/.ssh")), | |
| 366 | - | "{:?}", | |
| 367 | - | view.error | |
| 368 | - | ); | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | /// Discovery is filtered by shape, not by suffix. `~/.ssh` collects other | |
| 372 | - | /// files, and a misnamed private half offered as a candidate is how a | |
| 373 | - | /// secret reaches an artifact that promises to hold none. | |
| 374 | - | #[test] | |
| 375 | - | fn discovery_refuses_anything_that_is_not_a_public_key() { | |
| 376 | - | let dir = std::env::temp_dir().join(format!("alloy-image-scan-{}", std::process::id())); | |
| 377 | - | let ssh = dir.join(".ssh"); | |
| 378 | - | std::fs::create_dir_all(&ssh).expect("scratch dir"); | |
| 379 | - | ||
| 380 | - | std::fs::write( | |
| 381 | - | ssh.join("id_ed25519.pub"), | |
| 382 | - | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n", | |
| 383 | - | ) | |
| 384 | - | .expect("write key"); | |
| 385 | - | // A private key that someone named `.pub`. The suffix is not the check. | |
| 386 | - | std::fs::write( | |
| 387 | - | ssh.join("oops.pub"), | |
| 388 | - | "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n", | |
| 389 | - | ) | |
| 390 | - | .expect("write private"); | |
| 391 | - | // And the ordinary neighbours, which have no `.pub` at all. | |
| 392 | - | std::fs::write(ssh.join("known_hosts"), "github.com ssh-ed25519 AAAA\n") | |
| 393 | - | .expect("write known_hosts"); | |
| 394 | - | ||
| 395 | - | // SAFETY: single-threaded within this test's own scratch HOME. The | |
| 396 | - | // discovery reads HOME rather than taking a directory because that is | |
| 397 | - | // what it does in the program, and testing a different function would | |
| 398 | - | // test nothing. | |
| 399 | - | let restore = std::env::var_os("HOME"); | |
| 400 | - | unsafe { std::env::set_var("HOME", &dir) }; | |
| 401 | - | let found = discover_pubkeys(); | |
| 402 | - | match restore { | |
| 403 | - | Some(home) => unsafe { std::env::set_var("HOME", home) }, | |
| 404 | - | None => unsafe { std::env::remove_var("HOME") }, | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | assert_eq!(found.len(), 1, "{found:?}"); | |
| 408 | - | assert!(found[0].ends_with("id_ed25519.pub"), "{found:?}"); | |
| 409 | - | ||
| 410 | - | let _ = std::fs::remove_dir_all(&dir); | |
| 411 | - | } | |
| 412 | - | ||
| 413 | - | #[test] | |
| 414 | - | fn the_record_says_it_is_not_a_lockfile() { | |
| 415 | - | let toml = Choices::default().to_toml(); | |
| 416 | - | assert!( | |
| 417 | - | toml.contains("not a lockfile"), | |
| 418 | - | "the record must not read as one: it pins choices, not resolutions", | |
| 419 | - | ); | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | /// Against the file the image actually ships, not a fixture, so an | |
| 423 | - | /// os-release edit that drops or renames `VERSION_ID` fails here rather | |
| 424 | - | /// than by silently removing the image line from `alloy --version`. | |
| 425 | - | /// | |
| 426 | - | /// The committed file carries no `IMAGE_VERSION`: the build stamps it, and | |
| 427 | - | /// a placeholder here would be a lie on any machine where the stamping | |
| 428 | - | /// step stopped working. So this is the unstamped shape on purpose, and | |
| 429 | - | /// the product version is asserted rather than the whole line. | |
| 430 | - | #[test] | |
| 431 | - | fn the_shipped_os_release_states_an_image_version() { | |
| 432 | - | let shipped = concat!(env!("CARGO_MANIFEST_DIR"), "/../../usr/lib/os-release"); | |
| 433 | - | let text = std::fs::read_to_string(shipped).expect("the repo ships usr/lib/os-release"); | |
| 434 | - | let version = version_from(&text).expect("the shipped os-release names an image version"); | |
| 435 | - | assert!(version.starts_with("0."), "{version}"); | |
| 436 | - | assert!( | |
| 437 | - | !version.contains("build"), | |
| 438 | - | "the stamp is not committed: {version}" | |
| 439 | - | ); | |
| 440 | - | assert!(version.contains("Fedora"), "{version}"); | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | /// The line a support conversation reads back: product, build and base, | |
| 444 | - | /// which move on three different clocks and are three fields for that | |
| 445 | - | /// reason. | |
| 446 | - | #[test] | |
| 447 | - | fn a_stamped_image_composes_the_whole_triple() { | |
| 448 | - | let stamped = "NAME=\"Alloy\"\nVERSION_ID=\"0.1\"\nIMAGE_VERSION=\"20260816.143012\"\n\ | |
| 449 | - | ALLOY_BASE=\"43\"\nID=alloy\nID_LIKE=fedora\n"; | |
| 450 | - | assert_eq!( | |
| 451 | - | version_from(stamped).as_deref(), | |
| 452 | - | Some("0.1 (build 20260816.143012, Fedora 43)") | |
| 453 | - | ); | |
| 454 | - | } | |
| 455 | - | ||
| 456 | - | /// An unstamped build is a real state rather than a broken one: a bare | |
| 457 | - | /// `podman build` past the wrapper scripts produces one. It reports less | |
| 458 | - | /// and nothing false, which is what a filled-in "unknown" would not do. | |
| 459 | - | #[test] | |
| 460 | - | fn an_unstamped_image_says_less_rather_than_something_false() { | |
| 461 | - | let unstamped = "VERSION_ID=\"0.1\"\nALLOY_BASE=\"43\"\nID=alloy\n"; | |
| 462 | - | assert_eq!(version_from(unstamped).as_deref(), Some("0.1 (Fedora 43)")); | |
| 463 | - | ||
| 464 | - | let bare = "VERSION_ID=\"0.1\"\nID=alloy\n"; | |
| 465 | - | assert_eq!(version_from(bare).as_deref(), Some("0.1")); | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | /// The reason `ID` is checked. Every Linux host has an os-release, so a | |
| 469 | - | /// dev box would otherwise report its own distro's version as the image's. | |
| 470 | - | #[test] | |
| 471 | - | fn a_foreign_os_release_has_no_image_version() { | |
| 472 | - | let fedora = "NAME=\"Fedora Linux\"\nID=fedora\nVERSION_ID=43\n"; | |
| 473 | - | assert_eq!(version_from(fedora), None); | |
| 474 | - | } | |
| 475 | - | ||
| 476 | - | /// `ID` is a prefix of `ID_LIKE`, and matching the wrong one would read | |
| 477 | - | /// every Fedora derivative as Alloy. | |
| 478 | - | #[test] | |
| 479 | - | fn id_like_is_not_mistaken_for_id() { | |
| 480 | - | let derivative = "ID=notalloy\nID_LIKE=alloy\nVERSION_ID=9\n"; | |
| 481 | - | assert_eq!(version_from(derivative), None); | |
| 482 | - | } |
| @@ -1,0 +1,138 @@ | |||
| 1 | + | //! The running machine's Alloy version. | |
| 2 | + | //! | |
| 3 | + | //! Answers a different question from the rest of this verb: everything else | |
| 4 | + | //! here builds an image, and this reads the one already booted. It lives under | |
| 5 | + | //! `image` because `image::version()` is the name a reader guesses. | |
| 6 | + | ||
| 7 | + | /// Where the running system states which Alloy it is. | |
| 8 | + | /// | |
| 9 | + | /// `/usr/lib` and not `/etc`: the Containerfile ships it there and symlinks | |
| 10 | + | /// `/etc/os-release` at it, because bootc reads the deployment's copy when it | |
| 11 | + | /// writes a boot menu entry. Reading the source rather than the link. | |
| 12 | + | const OS_RELEASE: &str = "/usr/lib/os-release"; | |
| 13 | + | ||
| 14 | + | /// The image's version: the product, the build it came from, and the Fedora | |
| 15 | + | /// base it was built on, composed into one line. | |
| 16 | + | /// | |
| 17 | + | /// Three fields because they move on three clocks. `VERSION_ID` is the product | |
| 18 | + | /// and moves on a release; `IMAGE_VERSION` is stamped by every build; | |
| 19 | + | /// `ALLOY_BASE` follows the Containerfile's `FROM`. Two machines on the same | |
| 20 | + | /// product version can be different images, and that is exactly the pair a | |
| 21 | + | /// support conversation has to tell apart. | |
| 22 | + | /// | |
| 23 | + | /// Separate from the console's `CARGO_PKG_VERSION`, and the two diverge on | |
| 24 | + | /// purpose: the hotfix channel exists to put a newer console on an older | |
| 25 | + | /// image, so a support conversation that cannot see both cannot tell a layered | |
| 26 | + | /// machine from a rebuilt one, so `alloy --version` reports both. | |
| 27 | + | /// | |
| 28 | + | /// `None` off an Alloy machine, which is why `ID` is checked rather than | |
| 29 | + | /// assumed: every Linux host has an os-release, and reading a dev box's Fedora | |
| 30 | + | /// or Pop!_OS `VERSION_ID` would report a confident wrong answer. There is no | |
| 31 | + | /// image there to have a version, so the line is omitted rather than filled in | |
| 32 | + | /// with "unknown". | |
| 33 | + | pub(crate) fn version() -> Option<String> { | |
| 34 | + | version_from(&std::fs::read_to_string(OS_RELEASE).ok()?) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// The parse, split from the read so it can be tested against the os-release | |
| 38 | + | /// this repo actually ships rather than against the host's. | |
| 39 | + | /// | |
| 40 | + | /// Composed here and not in `notice_text`, which stays a formatter with no | |
| 41 | + | /// os-release knowledge. | |
| 42 | + | /// | |
| 43 | + | /// Each of the two trailing fields is dropped rather than filled in when it is | |
| 44 | + | /// missing. An unstamped image is a real state — a bare `podman build` past the | |
| 45 | + | /// wrapper scripts — and `0.1 (Fedora 43)` says less than the full line while | |
| 46 | + | /// saying nothing false, which is what `unknown` would do. | |
| 47 | + | fn version_from(text: &str) -> Option<String> { | |
| 48 | + | // `strip_prefix` on the key and then on `=`, in that order, so a key that | |
| 49 | + | // is a prefix of another does not match it: `ID` against `ID_LIKE=fedora` | |
| 50 | + | // leaves `_LIKE=fedora`, which has no leading `=` and is skipped. | |
| 51 | + | let field = |key: &str| { | |
| 52 | + | text.lines() | |
| 53 | + | .find_map(|line| line.strip_prefix(key)?.strip_prefix('=')) | |
| 54 | + | .map(|value| value.trim_matches('"').to_string()) | |
| 55 | + | }; | |
| 56 | + | if field("ID").as_deref() != Some("alloy") { | |
| 57 | + | return None; | |
| 58 | + | } | |
| 59 | + | let product = field("VERSION_ID")?; | |
| 60 | + | let detail: Vec<String> = [ | |
| 61 | + | field("IMAGE_VERSION").map(|build| format!("build {build}")), | |
| 62 | + | field("ALLOY_BASE").map(|base| format!("Fedora {base}")), | |
| 63 | + | ] | |
| 64 | + | .into_iter() | |
| 65 | + | .flatten() | |
| 66 | + | .collect(); | |
| 67 | + | if detail.is_empty() { | |
| 68 | + | return Some(product); | |
| 69 | + | } | |
| 70 | + | Some(format!("{product} ({})", detail.join(", "))) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | #[cfg(test)] | |
| 74 | + | mod tests { | |
| 75 | + | use super::*; | |
| 76 | + | ||
| 77 | + | /// Against the file the image actually ships, not a fixture, so an | |
| 78 | + | /// os-release edit that drops or renames `VERSION_ID` fails here rather | |
| 79 | + | /// than by silently removing the image line from `alloy --version`. | |
| 80 | + | /// | |
| 81 | + | /// The committed file carries no `IMAGE_VERSION`: the build stamps it, and | |
| 82 | + | /// a placeholder here would be a lie on any machine where the stamping | |
| 83 | + | /// step stopped working. So this is the unstamped shape on purpose, and | |
| 84 | + | /// the product version is asserted rather than the whole line. | |
| 85 | + | #[test] | |
| 86 | + | fn the_shipped_os_release_states_an_image_version() { | |
| 87 | + | let shipped = concat!(env!("CARGO_MANIFEST_DIR"), "/../../usr/lib/os-release"); | |
| 88 | + | let text = std::fs::read_to_string(shipped).expect("the repo ships usr/lib/os-release"); | |
| 89 | + | let version = version_from(&text).expect("the shipped os-release names an image version"); | |
| 90 | + | assert!(version.starts_with("0."), "{version}"); | |
| 91 | + | assert!( | |
| 92 | + | !version.contains("build"), | |
| 93 | + | "the stamp is not committed: {version}" | |
| 94 | + | ); | |
| 95 | + | assert!(version.contains("Fedora"), "{version}"); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// The line a support conversation reads back: product, build and base, | |
| 99 | + | /// which move on three different clocks and are three fields for that | |
| 100 | + | /// reason. | |
| 101 | + | #[test] | |
| 102 | + | fn a_stamped_image_composes_the_whole_triple() { | |
| 103 | + | let stamped = "NAME=\"Alloy\"\nVERSION_ID=\"0.1\"\nIMAGE_VERSION=\"20260816.143012\"\n\ | |
| 104 | + | ALLOY_BASE=\"43\"\nID=alloy\nID_LIKE=fedora\n"; | |
| 105 | + | assert_eq!( | |
| 106 | + | version_from(stamped).as_deref(), | |
| 107 | + | Some("0.1 (build 20260816.143012, Fedora 43)") | |
| 108 | + | ); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// An unstamped build is a real state rather than a broken one: a bare | |
| 112 | + | /// `podman build` past the wrapper scripts produces one. It reports less | |
| 113 | + | /// and nothing false, which is what a filled-in "unknown" would not do. | |
| 114 | + | #[test] | |
| 115 | + | fn an_unstamped_image_says_less_rather_than_something_false() { | |
| 116 | + | let unstamped = "VERSION_ID=\"0.1\"\nALLOY_BASE=\"43\"\nID=alloy\n"; | |
| 117 | + | assert_eq!(version_from(unstamped).as_deref(), Some("0.1 (Fedora 43)")); | |
| 118 | + | ||
| 119 | + | let bare = "VERSION_ID=\"0.1\"\nID=alloy\n"; | |
| 120 | + | assert_eq!(version_from(bare).as_deref(), Some("0.1")); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// The reason `ID` is checked. Every Linux host has an os-release, so a | |
| 124 | + | /// dev box would otherwise report its own distro's version as the image's. | |
| 125 | + | #[test] | |
| 126 | + | fn a_foreign_os_release_has_no_image_version() { | |
| 127 | + | let fedora = "NAME=\"Fedora Linux\"\nID=fedora\nVERSION_ID=43\n"; | |
| 128 | + | assert_eq!(version_from(fedora), None); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// `ID` is a prefix of `ID_LIKE`, and matching the wrong one would read | |
| 132 | + | /// every Fedora derivative as Alloy. | |
| 133 | + | #[test] | |
| 134 | + | fn id_like_is_not_mistaken_for_id() { | |
| 135 | + | let derivative = "ID=notalloy\nID_LIKE=alloy\nVERSION_ID=9\n"; | |
| 136 | + | assert_eq!(version_from(derivative), None); | |
| 137 | + | } | |
| 138 | + | } |
| @@ -1,0 +1,651 @@ | |||
| 1 | + | //! The `alloy image` screen: the form, its blockers, and the build it drives. | |
| 2 | + | ||
| 3 | + | use std::path::PathBuf; | |
| 4 | + | ||
| 5 | + | use alloy_tui::keys::{Action, classify}; | |
| 6 | + | use alloy_tui::{ | |
| 7 | + | AlloyBlock, AlloyForm, AlloyList, Cursor, FieldKind, FormRow, Hint, Severity, TextField, Theme, | |
| 8 | + | hint, text, | |
| 9 | + | }; | |
| 10 | + | use ratatui::Frame; | |
| 11 | + | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 12 | + | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 13 | + | use ratatui::text::Line; | |
| 14 | + | ||
| 15 | + | use super::RECORD_LOCAL; | |
| 16 | + | use super::choices::{Artifact, Browser, Choices, Db, Lang, Profile, Trim}; | |
| 17 | + | use super::discover::{discover_pubkeys, find_repo}; | |
| 18 | + | use crate::cli::{CommandLog, Invocation}; | |
| 19 | + | use crate::run::{Sequence, Stage}; | |
| 20 | + | use crate::shell::{Confirm, Flow, View, block_title}; | |
| 21 | + | ||
| 22 | + | /// Which row the form is on. | |
| 23 | + | /// | |
| 24 | + | /// An enum rather than an index into a `Vec` so that adding a row cannot | |
| 25 | + | /// silently renumber what a keypress edits. | |
| 26 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 27 | + | enum Row { | |
| 28 | + | Profile, | |
| 29 | + | Browser, | |
| 30 | + | /// One row per language rather than one row holding a set. A single | |
| 31 | + | /// "languages" row would need a key meaning "toggle the next one", which | |
| 32 | + | /// is a model nobody can predict from looking at it; four toggles are four | |
| 33 | + | /// things you can see the state of. | |
| 34 | + | Lang(Lang), | |
| 35 | + | Trim, | |
| 36 | + | Db, | |
| 37 | + | Artifact, | |
| 38 | + | Hostname, | |
| 39 | + | Pubkey, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl Row { | |
| 43 | + | /// The rows, in screen order. A function rather than a constant because | |
| 44 | + | /// the language rows are derived from [`Lang::ALL`], so adding a language | |
| 45 | + | /// cannot leave the form and the model disagreeing. | |
| 46 | + | fn all() -> Vec<Row> { | |
| 47 | + | let mut rows = vec![Row::Profile, Row::Browser]; | |
| 48 | + | rows.extend(Lang::ALL.map(Row::Lang)); | |
| 49 | + | rows.extend([ | |
| 50 | + | Row::Trim, | |
| 51 | + | Row::Db, | |
| 52 | + | Row::Artifact, | |
| 53 | + | Row::Hostname, | |
| 54 | + | Row::Pubkey, | |
| 55 | + | ]); | |
| 56 | + | rows | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | fn label(self) -> String { | |
| 60 | + | match self { | |
| 61 | + | Row::Profile => "profile".to_string(), | |
| 62 | + | Row::Browser => "browser".to_string(), | |
| 63 | + | Row::Lang(lang) => lang.value().to_string(), | |
| 64 | + | Row::Trim => "base trim".to_string(), | |
| 65 | + | Row::Db => "database".to_string(), | |
| 66 | + | Row::Artifact => "artifact".to_string(), | |
| 67 | + | Row::Hostname => "hostname".to_string(), | |
| 68 | + | Row::Pubkey => "ssh pubkey".to_string(), | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | fn help(self) -> &'static str { | |
| 73 | + | match self { | |
| 74 | + | Row::Profile => { | |
| 75 | + | "client is the desktop; server drops the compositor, greeter and session" | |
| 76 | + | } | |
| 77 | + | Row::Browser => "the one stack pick Alloy declines to make for you", | |
| 78 | + | Row::Lang(lang) => lang.label(), | |
| 79 | + | Row::Trim => "base packages nothing in Alloy reaches. Never firmware", | |
| 80 | + | Row::Db => "the binaries, not a cluster. 16 is what production runs", | |
| 81 | + | Row::Artifact => "ISO boots into the installer; raw and qcow2 are installed systems", | |
| 82 | + | Row::Hostname => "baked in, so a headless box is found at <name>.local", | |
| 83 | + | Row::Pubkey => "a PUBLIC key from ~/.ssh, or a path. The installer's only credential", | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// The `alloy image` screen. | |
| 89 | + | pub(crate) struct ImageView { | |
| 90 | + | choices: Choices, | |
| 91 | + | cursor: Cursor, | |
| 92 | + | /// The checkout to drive, found once at construction. `None` is a state | |
| 93 | + | /// the screen renders rather than an error it fails with: someone may open | |
| 94 | + | /// the builder to read what it would do before cloning anything. | |
| 95 | + | repo: Option<PathBuf>, | |
| 96 | + | /// The public keys found in `~/.ssh` at startup, for the pubkey row to | |
| 97 | + | /// cycle. Kept even when one of them was adopted as the default, so a host | |
| 98 | + | /// with several keys stays choosable without typing a path. | |
| 99 | + | candidates: Vec<String>, | |
| 100 | + | /// The open text editor, if the focused row takes one. | |
| 101 | + | editing: Option<TextField>, | |
| 102 | + | /// The running build. `None` before the first one and after a finished one | |
| 103 | + | /// is dismissed. | |
| 104 | + | sequence: Option<Sequence>, | |
| 105 | + | /// The device a write has been aimed at but not yet confirmed. | |
| 106 | + | pending_write: Option<String>, | |
| 107 | + | /// A device path being typed. | |
| 108 | + | device: Option<TextField>, | |
| 109 | + | error: Option<String>, | |
| 110 | + | saved: bool, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | impl ImageView { | |
| 114 | + | pub(crate) fn new(log: &mut CommandLog) -> Self { | |
| 115 | + | let loaded = Choices::load(); | |
| 116 | + | if let Some(from) = &loaded.from { | |
| 117 | + | log.record(format!("# choices from {from}"), Severity::Healthy); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | let mut choices = loaded.choices; | |
| 121 | + | let candidates = discover_pubkeys(); | |
| 122 | + | ||
| 123 | + | // Only when the record did not already answer. A saved `pubkey` is a | |
| 124 | + | // statement about which key this machine is minted with, and quietly | |
| 125 | + | // replacing it with whatever sorts first in `~/.ssh` would be the | |
| 126 | + | // silent-forgetting failure `load` exists to avoid. | |
| 127 | + | // | |
| 128 | + | // ONE key is adopted; SEVERAL are offered and none is chosen. There is | |
| 129 | + | // no rule that picks correctly between a personal key and a key for the | |
| 130 | + | // machine being built, and guessing wrong bakes the wrong credential | |
| 131 | + | // into an artifact nobody re-reads before writing it to a stick. | |
| 132 | + | if choices.pubkey.is_empty() { | |
| 133 | + | match candidates.as_slice() { | |
| 134 | + | [only] => { | |
| 135 | + | choices.pubkey.clone_from(only); | |
| 136 | + | log.record( | |
| 137 | + | format!("# one public key in ~/.ssh: {only}"), | |
| 138 | + | Severity::Info, | |
| 139 | + | ); | |
| 140 | + | } | |
| 141 | + | [_, _, ..] => log.record( | |
| 142 | + | format!( | |
| 143 | + | "# {} public keys in ~/.ssh, so none was chosen: h/l on the ssh pubkey row", | |
| 144 | + | candidates.len() | |
| 145 | + | ), | |
| 146 | + | Severity::Info, | |
| 147 | + | ), | |
| 148 | + | [] => {} | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | Self { | |
| 153 | + | choices, | |
| 154 | + | cursor: Cursor::new(), | |
| 155 | + | repo: find_repo(), | |
| 156 | + | candidates, | |
| 157 | + | editing: None, | |
| 158 | + | sequence: None, | |
| 159 | + | pending_write: None, | |
| 160 | + | device: None, | |
| 161 | + | // A broken record is the first thing the screen says, ahead of any | |
| 162 | + | // blocker: everything below it is defaults masquerading as this | |
| 163 | + | // machine's configuration, and that is worth knowing before | |
| 164 | + | // reading a single row. | |
| 165 | + | error: loaded.problem, | |
| 166 | + | saved: false, | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | fn row(&self) -> Row { | |
| 171 | + | let rows = Row::all(); | |
| 172 | + | rows[self.cursor.selected().unwrap_or(0).min(rows.len() - 1)] | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// Cycle the focused enum row forward. | |
| 176 | + | /// | |
| 177 | + | /// One key rather than a picker modal: four of the six rows are two- or | |
| 178 | + | /// three-valued, and a modal to choose between two things costs more | |
| 179 | + | /// keystrokes than it saves. | |
| 180 | + | fn cycle(&mut self, forward: bool) { | |
| 181 | + | fn step<T: Copy + PartialEq>(all: &[T], current: T, forward: bool) -> T { | |
| 182 | + | let index = all.iter().position(|c| *c == current).unwrap_or(0); | |
| 183 | + | let len = all.len(); | |
| 184 | + | let next = if forward { | |
| 185 | + | (index + 1) % len | |
| 186 | + | } else { | |
| 187 | + | (index + len - 1) % len | |
| 188 | + | }; | |
| 189 | + | all[next] | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | match self.row() { | |
| 193 | + | Row::Profile => { | |
| 194 | + | self.choices.profile = step(&Profile::ALL, self.choices.profile, forward); | |
| 195 | + | // A server has no session to run a browser in. Following the | |
| 196 | + | // profile rather than blocking on it: the blocker exists for a | |
| 197 | + | // record loaded from disk, but a user who just pressed a key | |
| 198 | + | // should see the consequence, not a complaint. | |
| 199 | + | if self.choices.profile == Profile::Server { | |
| 200 | + | self.choices.browser = Browser::None; | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | Row::Browser => { | |
| 204 | + | if self.choices.profile == Profile::Server { | |
| 205 | + | self.error = Some( | |
| 206 | + | "the server profile ships no graphical session, so it carries no browser" | |
| 207 | + | .to_string(), | |
| 208 | + | ); | |
| 209 | + | } else { | |
| 210 | + | self.choices.browser = step(&Browser::ALL, self.choices.browser, forward); | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | Row::Trim => { | |
| 214 | + | self.choices.trim = step(&Trim::ALL, self.choices.trim, forward); | |
| 215 | + | } | |
| 216 | + | Row::Db => { | |
| 217 | + | self.choices.db = step(&Db::ALL, self.choices.db, forward); | |
| 218 | + | } | |
| 219 | + | Row::Artifact => { | |
| 220 | + | self.choices.artifact = step(&Artifact::ALL, self.choices.artifact, forward); | |
| 221 | + | } | |
| 222 | + | // A toggle has no direction, so h/l does what space does on these | |
| 223 | + | // rows rather than nothing. A key that is inert on one row of a | |
| 224 | + | // form reads as the form being broken. | |
| 225 | + | Row::Lang(_) => self.toggle(), | |
| 226 | + | // Cycles the keys found in `~/.ssh`, which is the whole of the | |
| 227 | + | // choice on a host that has more than one. Typing a path is still | |
| 228 | + | // there on enter, for a key that lives somewhere else. | |
| 229 | + | Row::Pubkey => self.cycle_pubkey(forward), | |
| 230 | + | Row::Hostname => {} | |
| 231 | + | } | |
| 232 | + | self.saved = false; | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// Step through the discovered keys. | |
| 236 | + | /// | |
| 237 | + | /// Empty is one of the positions rather than something to escape from: an | |
| 238 | + | /// image without a baked key is the ordinary desktop install, so a user who | |
| 239 | + | /// cycles past the last candidate should land back on "none" instead of | |
| 240 | + | /// wrapping straight onto a key they were trying to get away from. | |
| 241 | + | /// | |
| 242 | + | /// A path typed by hand is not in the list, so it reads as position zero | |
| 243 | + | /// and the first press moves to the first candidate. That loses the typed | |
| 244 | + | /// value, which is why this is `h/l` and the typed path is committed with | |
| 245 | + | /// enter: the two are different gestures. | |
| 246 | + | fn cycle_pubkey(&mut self, forward: bool) { | |
| 247 | + | if self.candidates.is_empty() { | |
| 248 | + | self.error = Some("no public keys in ~/.ssh: press enter to type a path".to_string()); | |
| 249 | + | return; | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // Position 0 is "none", so the ring is one longer than the candidates. | |
| 253 | + | let len = self.candidates.len() + 1; | |
| 254 | + | let current = self | |
| 255 | + | .candidates | |
| 256 | + | .iter() | |
| 257 | + | .position(|key| *key == self.choices.pubkey) | |
| 258 | + | .map_or(0, |index| index + 1); | |
| 259 | + | let next = if forward { | |
| 260 | + | (current + 1) % len | |
| 261 | + | } else { | |
| 262 | + | (current + len - 1) % len | |
| 263 | + | }; | |
| 264 | + | ||
| 265 | + | self.choices.pubkey = match next { | |
| 266 | + | 0 => String::new(), | |
| 267 | + | index => self.candidates[index - 1].clone(), | |
| 268 | + | }; | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | /// Toggle the language on the focused row. | |
| 272 | + | fn toggle(&mut self) { | |
| 273 | + | let Row::Lang(lang) = self.row() else { | |
| 274 | + | return; | |
| 275 | + | }; | |
| 276 | + | if !self.choices.langs.remove(&lang) { | |
| 277 | + | self.choices.langs.insert(lang); | |
| 278 | + | } | |
| 279 | + | self.saved = false; | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | fn begin_edit(&mut self) { | |
| 283 | + | let current = match self.row() { | |
| 284 | + | Row::Hostname => self.choices.hostname.clone(), | |
| 285 | + | Row::Pubkey => self.choices.pubkey.clone(), | |
| 286 | + | _ => return, | |
| 287 | + | }; | |
| 288 | + | let mut field = TextField::new(); | |
| 289 | + | field.set(current); | |
| 290 | + | field.end(); | |
| 291 | + | self.editing = Some(field); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | fn commit_edit(&mut self) { | |
| 295 | + | let Some(field) = self.editing.take() else { | |
| 296 | + | return; | |
| 297 | + | }; | |
| 298 | + | let value = field.value().trim().to_string(); | |
| 299 | + | match self.row() { | |
| 300 | + | Row::Hostname => self.choices.hostname = value, | |
| 301 | + | Row::Pubkey => self.choices.pubkey = value, | |
| 302 | + | _ => {} | |
| 303 | + | } | |
| 304 | + | self.saved = false; | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | fn save(&mut self, log: &mut CommandLog) { | |
| 308 | + | let Some(repo) = &self.repo else { | |
| 309 | + | self.error = Some("no checkout to save into".to_string()); | |
| 310 | + | return; | |
| 311 | + | }; | |
| 312 | + | let path = repo.join(RECORD_LOCAL); | |
| 313 | + | match std::fs::write(&path, self.choices.to_toml()) { | |
| 314 | + | Ok(()) => { | |
| 315 | + | log.record(format!("# wrote {}", path.display()), Severity::Healthy); | |
| 316 | + | self.saved = true; | |
| 317 | + | } | |
| 318 | + | Err(err) => self.error = Some(format!("cannot write {}: {err}", path.display())), | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | fn start_build(&mut self, log: &mut CommandLog) { | |
| 323 | + | let blockers = self.choices.blockers(self.repo.as_deref()); | |
| 324 | + | if let Some(first) = blockers.first() { | |
| 325 | + | self.error = Some(first.clone()); | |
| 326 | + | return; | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // Saving before building rather than after, so a build that fails | |
| 330 | + | // half way still leaves the choices that produced it on disk. The | |
| 331 | + | // alternative loses the configuration at exactly the moment someone | |
| 332 | + | // wants to retry it. | |
| 333 | + | self.save(log); | |
| 334 | + | ||
| 335 | + | self.sequence = Some(Sequence::new(vec![Stage::Run(self.choices.invocation())])); | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | /// The write, handed to the script rather than performed here. | |
| 339 | + | /// | |
| 340 | + | /// See the module docs. This returns the command for the shell to run with | |
| 341 | + | /// the terminal attached; the script asks its own confirmation there. | |
| 342 | + | fn write_command(&self, device: &str) -> Invocation { | |
| 343 | + | Invocation::new(self.choices.artifact.script()).args(["--write-only", "--write", device]) | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | impl View for ImageView { | |
| 348 | + | fn title(&self) -> String { | |
| 349 | + | match &self.repo { | |
| 350 | + | Some(repo) => format!( | |
| 351 | + | "image builder ({})", | |
| 352 | + | repo.file_name().map_or_else( | |
| 353 | + | || repo.display().to_string(), | |
| 354 | + | |n| n.to_string_lossy().into() | |
| 355 | + | ) | |
| 356 | + | ), | |
| 357 | + | None => "image builder (no checkout)".to_string(), | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | fn hints(&self) -> Vec<Hint> { | |
| 362 | + | if self.editing.is_some() { | |
| 363 | + | return vec![hint("enter", "commit"), hint("esc", "cancel")]; | |
| 364 | + | } | |
| 365 | + | if self.device.is_some() { | |
| 366 | + | return vec![hint("enter", "confirm device"), hint("esc", "cancel")]; | |
| 367 | + | } | |
| 368 | + | if self.sequence.as_ref().is_some_and(|s| !s.is_done()) { | |
| 369 | + | return vec![hint("esc", "leave it running")]; | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | let mut hints = vec![hint("j/k", "row")]; | |
| 373 | + | match self.row() { | |
| 374 | + | Row::Lang(_) => hints.push(hint("space", "toggle")), | |
| 375 | + | Row::Hostname => hints.push(hint("enter", "edit")), | |
| 376 | + | // Both, on this row, and they do different things: one walks the | |
| 377 | + | // keys already on this disk, the other takes a path to one that is | |
| 378 | + | // not. Naming only the editor would hide the common case. | |
| 379 | + | Row::Pubkey => { | |
| 380 | + | if !self.candidates.is_empty() { | |
| 381 | + | hints.push(hint("h/l", "found keys")); | |
| 382 | + | } | |
| 383 | + | hints.push(hint("enter", "path")); | |
| 384 | + | } | |
| 385 | + | _ => hints.push(hint("h/l", "change")), | |
| 386 | + | } | |
| 387 | + | hints.push(hint("b", "build")); | |
| 388 | + | hints.push(hint("w", "write")); | |
| 389 | + | hints.push(hint("s", "save")); | |
| 390 | + | hints | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | fn unanswered(&self) -> &'static [Action] { | |
| 394 | + | &[Action::NextTab, Action::PrevTab] | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | fn text_entry(&self) -> bool { | |
| 398 | + | self.editing.is_some() || self.device.is_some() | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | fn status(&self) -> Option<(Severity, String)> { | |
| 402 | + | if let Some(error) = &self.error { | |
| 403 | + | return Some((Severity::Error, error.clone())); | |
| 404 | + | } | |
| 405 | + | if let Some(sequence) = &self.sequence { | |
| 406 | + | return match sequence.outcome() { | |
| 407 | + | None => Some((Severity::Warn, "building".to_string())), | |
| 408 | + | Some(Ok(())) => Some((Severity::Healthy, "built".to_string())), | |
| 409 | + | Some(Err(message)) => Some((Severity::Error, message.clone())), | |
| 410 | + | }; | |
| 411 | + | } | |
| 412 | + | let blockers = self.choices.blockers(self.repo.as_deref()); | |
| 413 | + | if let Some(blocker) = blockers.first() { | |
| 414 | + | return Some((Severity::Warn, blocker.clone())); | |
| 415 | + | } | |
| 416 | + | // Nothing wrong, so the footer answers the remaining question: are | |
| 417 | + | // these choices on disk. `b` saves before it builds, so an unsaved | |
| 418 | + | // form is only ever a form nobody has acted on yet — but "did that | |
| 419 | + | // take" is worth being able to see rather than infer. | |
| 420 | + | Some(if self.saved { | |
| 421 | + | (Severity::Healthy, format!("saved to {RECORD_LOCAL}")) | |
| 422 | + | } else { | |
| 423 | + | (Severity::Info, format!("s saves to {RECORD_LOCAL}")) | |
| 424 | + | }) | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 428 | + | let block = AlloyBlock::new(theme) | |
| 429 | + | .focused(true) | |
| 430 | + | .build() | |
| 431 | + | .title(block_title(&self.title())); | |
| 432 | + | let inner = block.inner(area); | |
| 433 | + | frame.render_widget(block, area); | |
| 434 | + | ||
| 435 | + | // A running or finished build takes the lower half. Before the first | |
| 436 | + | // build there is nothing to show there, so the form gets the screen. | |
| 437 | + | let [form_area, output_area] = if self.sequence.is_some() { | |
| 438 | + | Layout::vertical([Constraint::Length(10), Constraint::Min(0)]).areas(inner) | |
| 439 | + | } else { | |
| 440 | + | Layout::vertical([Constraint::Min(0), Constraint::Length(0)]).areas(inner) | |
| 441 | + | }; | |
| 442 | + | ||
| 443 | + | // Labels are owned so the borrow does not outlive the loop; the widget | |
| 444 | + | // takes `&'a str`, and building them inline would drop each one at the | |
| 445 | + | // end of its own iteration. | |
| 446 | + | let labels: Vec<String> = Row::all().iter().map(|row| row.label()).collect(); | |
| 447 | + | ||
| 448 | + | let rows: Vec<FormRow> = Row::all() | |
| 449 | + | .into_iter() | |
| 450 | + | .zip(&labels) | |
| 451 | + | .map(|(row, label)| { | |
| 452 | + | let field = match row { | |
| 453 | + | Row::Profile => alloy_tui::AlloyField::new( | |
| 454 | + | theme, | |
| 455 | + | label, | |
| 456 | + | FieldKind::Enum { | |
| 457 | + | label: self.choices.profile.label(), | |
| 458 | + | }, | |
| 459 | + | ), | |
| 460 | + | Row::Browser => alloy_tui::AlloyField::new( | |
| 461 | + | theme, | |
| 462 | + | label, | |
| 463 | + | FieldKind::Enum { | |
| 464 | + | label: self.choices.browser.label(), | |
| 465 | + | }, | |
| 466 | + | ) | |
| 467 | + | // Dimmed rather than hidden on a server: a row that | |
| 468 | + | // vanishes reads as a bug, and the point is that the | |
| 469 | + | // choice exists and this profile has no use for it. | |
| 470 | + | .unset(self.choices.profile == Profile::Server), | |
| 471 | + | Row::Lang(lang) => alloy_tui::AlloyField::new( | |
| 472 | + | theme, | |
| 473 | + | label, | |
| 474 | + | FieldKind::Toggle(self.choices.langs.contains(&lang)), | |
| 475 | + | ) | |
| 476 | + | .indent(true), | |
| 477 | + | Row::Trim => alloy_tui::AlloyField::new( | |
| 478 | + | theme, | |
| 479 | + | label, | |
| 480 | + | FieldKind::Enum { | |
| 481 | + | label: self.choices.trim.label(), | |
| 482 | + | }, | |
| 483 | + | ), | |
| 484 | + | Row::Db => alloy_tui::AlloyField::new( | |
| 485 | + | theme, | |
| 486 | + | label, | |
| 487 | + | FieldKind::Enum { | |
| 488 | + | label: self.choices.db.label(), | |
| 489 | + | }, | |
| 490 | + | ), | |
| 491 | + | Row::Artifact => alloy_tui::AlloyField::new( | |
| 492 | + | theme, | |
| 493 | + | label, | |
| 494 | + | FieldKind::Enum { | |
| 495 | + | label: self.choices.artifact.label(), | |
| 496 | + | }, | |
| 497 | + | ), | |
| 498 | + | Row::Hostname => alloy_tui::AlloyField::new( | |
| 499 | + | theme, | |
| 500 | + | label, |
Lines truncated
| @@ -1,0 +1,92 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | /// The write goes through the script's own path, with `--write-only` so it | |
| 6 | + | /// writes the artifact that already exists rather than rebuilding it. | |
| 7 | + | /// Re-deriving this is the disk-eating bug the design note warns about. | |
| 8 | + | #[test] | |
| 9 | + | fn the_write_delegates_to_the_script_that_owns_the_guards() { | |
| 10 | + | let shown = ImageView { | |
| 11 | + | choices: Choices::default(), | |
| 12 | + | cursor: Cursor::new(), | |
| 13 | + | repo: None, | |
| 14 | + | candidates: Vec::new(), | |
| 15 | + | editing: None, | |
| 16 | + | sequence: None, | |
| 17 | + | pending_write: None, | |
| 18 | + | device: None, | |
| 19 | + | error: None, | |
| 20 | + | saved: false, | |
| 21 | + | } | |
| 22 | + | .write_command("/dev/sdX") | |
| 23 | + | .display(); | |
| 24 | + | ||
| 25 | + | assert!(shown.starts_with("build/build-iso.sh")); | |
| 26 | + | assert!(shown.contains("--write-only")); | |
| 27 | + | assert!(shown.contains("--write /dev/sdX")); | |
| 28 | + | // Nothing resembling a dd, anywhere. | |
| 29 | + | assert!(!shown.contains("dd ")); | |
| 30 | + | assert!(!shown.contains("of=")); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// A view with a known candidate list, so the cycling can be exercised | |
| 34 | + | /// without a `~/.ssh` to stand in front of it. | |
| 35 | + | fn view_with(candidates: &[&str]) -> ImageView { | |
| 36 | + | ImageView { | |
| 37 | + | choices: Choices::default(), | |
| 38 | + | cursor: Cursor::new(), | |
| 39 | + | repo: None, | |
| 40 | + | candidates: candidates.iter().map(|key| (*key).to_string()).collect(), | |
| 41 | + | editing: None, | |
| 42 | + | sequence: None, | |
| 43 | + | pending_write: None, | |
| 44 | + | device: None, | |
| 45 | + | error: None, | |
| 46 | + | saved: false, | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Empty is a position on the ring, not a state to escape. An image with no | |
| 51 | + | /// baked key is the ordinary desktop install, so it has to stay reachable | |
| 52 | + | /// once a key has been cycled onto the row. | |
| 53 | + | #[test] | |
| 54 | + | fn cycling_the_pubkey_row_passes_back_through_none() { | |
| 55 | + | let mut view = view_with(&["/home/max/.ssh/a.pub", "/home/max/.ssh/b.pub"]); | |
| 56 | + | assert_eq!(view.choices.pubkey, ""); | |
| 57 | + | ||
| 58 | + | view.cycle_pubkey(true); | |
| 59 | + | assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub"); | |
| 60 | + | view.cycle_pubkey(true); | |
| 61 | + | assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub"); | |
| 62 | + | view.cycle_pubkey(true); | |
| 63 | + | assert_eq!(view.choices.pubkey, "", "the ring returns to no key"); | |
| 64 | + | ||
| 65 | + | // And backwards, off none onto the last one. | |
| 66 | + | view.cycle_pubkey(false); | |
| 67 | + | assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub"); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// A path typed by hand is not one of the candidates, so it reads as the | |
| 71 | + | /// empty position rather than panicking on a lookup that finds nothing. | |
| 72 | + | #[test] | |
| 73 | + | fn a_typed_path_is_not_lost_to_an_index_it_never_had() { | |
| 74 | + | let mut view = view_with(&["/home/max/.ssh/a.pub"]); | |
| 75 | + | view.choices.pubkey = "/elsewhere/key.pub".to_string(); | |
| 76 | + | view.cycle_pubkey(true); | |
| 77 | + | assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub"); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Nothing to cycle says so, rather than silently doing nothing. An inert | |
| 81 | + | /// key reads as the form being broken. | |
| 82 | + | #[test] | |
| 83 | + | fn no_candidates_explains_itself() { | |
| 84 | + | let mut view = view_with(&[]); | |
| 85 | + | view.cycle_pubkey(true); | |
| 86 | + | assert_eq!(view.choices.pubkey, ""); | |
| 87 | + | assert!( | |
| 88 | + | view.error.as_deref().is_some_and(|e| e.contains("~/.ssh")), | |
| 89 | + | "{:?}", | |
| 90 | + | view.error | |
| 91 | + | ); | |
| 92 | + | } |