| 11 |
11 |
|
//! The install runs through [`Sequence`], so the frame keeps drawing for the
|
| 12 |
12 |
|
//! minutes `bootc` takes.
|
| 13 |
13 |
|
//!
|
| 14 |
|
- |
//! **One thing is known-wrong rather than merely unverified**, and it is worth
|
| 15 |
|
- |
//! reading before trusting this file. The configuration commands are pointed at
|
| 16 |
|
- |
//! [`TARGET_MOUNT`], but an ostree system's `/etc` is not at `<mount>/etc` — it
|
| 17 |
|
- |
//! is inside the deployment, at `<mount>/ostree/deploy/<stateroot>/deploy/
|
| 18 |
|
- |
//! <checksum>/`, and that checksum is not knowable in advance. As written,
|
| 19 |
|
- |
//! `systemd-firstboot` would write a hostname where nothing reads it and the
|
| 20 |
|
- |
//! install would boot with none of the answers applied and no error to say so.
|
|
14 |
+ |
//! Two of the install's arguments cannot be written in advance, so the plan is
|
|
15 |
+ |
//! [`Stage`]s rather than a flat list. bootc decides which partition holds the
|
|
16 |
+ |
//! new root while it partitions, and names the ostree deployment after a
|
|
17 |
+ |
//! checksum that does not exist until the deploy finishes. Both are discovered
|
|
18 |
+ |
//! by running a command and reading its output, and the second discovery is
|
|
19 |
+ |
//! only possible after the first one's result has been mounted.
|
| 21 |
20 |
|
//!
|
| 22 |
|
- |
//! [`deployment_dir`] is the documented way out and is implemented, but not
|
| 23 |
|
- |
//! wired in: using it makes the plan dynamic, since the arguments to one
|
| 24 |
|
- |
//! command come from the output of another, and [`Sequence`] runs a fixed list.
|
| 25 |
|
- |
//! Closing that is the remaining work.
|
|
21 |
+ |
//! The deployment directory is the part worth understanding. An ostree system's
|
|
22 |
+ |
//! `/etc` is not at `<mount>/etc`; it is inside the deployment, at
|
|
23 |
+ |
//! `<mount>/ostree/deploy/<stateroot>/deploy/<checksum>/`. Configuring the
|
|
24 |
+ |
//! mountpoint instead would write a hostname where nothing reads it and boot a
|
|
25 |
+ |
//! system with none of the answers applied and no error to say why.
|
|
26 |
+ |
//!
|
|
27 |
+ |
//! **Nothing here has run against real bootc.** The shape follows upstream's
|
|
28 |
+ |
//! documentation, and the pieces that are guesses rather than quotes are the
|
|
29 |
+ |
//! arguments to `bootc install finalize`, which that page does not document.
|
| 26 |
30 |
|
//!
|
| 27 |
31 |
|
//! <!-- wiki: alloy-console -->
|
| 28 |
32 |
|
|
| 40 |
44 |
|
|
| 41 |
45 |
|
use crate::cli::{CommandLog, Invocation, Secret};
|
| 42 |
46 |
|
use crate::field::TextField;
|
| 43 |
|
- |
use crate::run::Sequence;
|
|
47 |
+ |
use crate::run::{Sequence, Stage};
|
| 44 |
48 |
|
use crate::shell::{Confirm, Flow, View, block_title};
|
| 45 |
49 |
|
use crate::wizard::Steps;
|
| 46 |
50 |
|
|
| 231 |
235 |
|
/// deployment directory." That makes the configuration target a value
|
| 232 |
236 |
|
/// discovered at install time rather than a constant, which is the structural
|
| 233 |
237 |
|
/// change this function represents.
|
| 234 |
|
- |
///
|
| 235 |
|
- |
/// Not yet wired into [`install_plan`], which still passes [`TARGET_MOUNT`]
|
| 236 |
|
- |
/// straight to `--root`. Doing it properly means the plan can no longer be a
|
| 237 |
|
- |
/// flat list built up front: the arguments to `systemd-firstboot` depend on the
|
| 238 |
|
- |
/// output of a command that has not run yet, so [`Sequence`] needs to carry a
|
| 239 |
|
- |
/// step whose result parameterizes the steps behind it. That is the one piece
|
| 240 |
|
- |
/// of the installer still outstanding, and it is a known wrong answer rather
|
| 241 |
|
- |
/// than an unknown one — see the module header.
|
| 242 |
|
- |
#[allow(dead_code)]
|
| 243 |
238 |
|
fn deployment_dir(mount: &str) -> Invocation {
|
| 244 |
239 |
|
Invocation::new("ostree")
|
| 245 |
240 |
|
.arg("admin")
|
| 258 |
253 |
|
///
|
| 259 |
254 |
|
/// `chpasswd` takes the password on stdin rather than as an argument. See
|
| 260 |
255 |
|
/// [`Secret`] for why argv is not an option.
|
| 261 |
|
- |
fn install_plan(
|
| 262 |
|
- |
disk: &str,
|
| 263 |
|
- |
hostname: &str,
|
| 264 |
|
- |
username: &str,
|
| 265 |
|
- |
password: &str,
|
| 266 |
|
- |
root: &str,
|
| 267 |
|
- |
) -> Vec<Invocation> {
|
|
256 |
+ |
/// DPS partition type GUID for the root filesystem on this architecture.
|
|
257 |
+ |
///
|
|
258 |
+ |
/// From bootc 1.11 the default layout follows the Discoverable Partitions
|
|
259 |
+ |
/// Specification, setting "the appropriate DPS partition type GUID based on the
|
|
260 |
+ |
/// target architecture" rather than a filesystem label. So the root partition is
|
|
261 |
+ |
/// identified by type GUID, which `lsblk` reports as `PARTTYPE`.
|
|
262 |
+ |
///
|
|
263 |
+ |
/// Per-architecture because the specification assigns a different GUID to each,
|
|
264 |
+ |
/// and Alloy builds natively for both of its targets. `cfg!` rather than a
|
|
265 |
+ |
/// runtime check: the installer runs on the machine it is installing.
|
|
266 |
+ |
const ROOT_PARTITION_TYPE: &str = if cfg!(target_arch = "aarch64") {
|
|
267 |
+ |
"b921b045-1df0-41c3-af44-4c6f280d3fae"
|
|
268 |
+ |
} else {
|
|
269 |
+ |
"4f68bce3-e8cd-4db1-96e7-fbcaf984b709"
|
|
270 |
+ |
};
|
|
271 |
+ |
|
|
272 |
+ |
/// Ask lsblk for the partitions of `disk` and their type GUIDs.
|
|
273 |
+ |
fn partition_types(disk: &str) -> Invocation {
|
|
274 |
+ |
Invocation::new("lsblk")
|
|
275 |
+ |
.args(["-J", "-o", "PATH,PARTTYPE"])
|
|
276 |
+ |
.arg(disk)
|
|
277 |
+ |
}
|
|
278 |
+ |
|
|
279 |
+ |
/// Pick the root partition out of an `lsblk -o PATH,PARTTYPE` listing.
|
|
280 |
+ |
///
|
|
281 |
+ |
/// Matching on the DPS type GUID rather than on position or size: "the third
|
|
282 |
+ |
/// one" and "the biggest one" are both true of a default bootc layout today and
|
|
283 |
+ |
/// neither is a promise. The GUID is the thing bootc actually sets.
|
|
284 |
+ |
fn root_partition(listing: &str) -> Result<String, String> {
|
|
285 |
+ |
#[derive(Deserialize)]
|
|
286 |
+ |
struct Listing {
|
|
287 |
+ |
blockdevices: Vec<Node>,
|
|
288 |
+ |
}
|
|
289 |
+ |
#[derive(Deserialize)]
|
|
290 |
+ |
struct Node {
|
|
291 |
+ |
path: String,
|
|
292 |
+ |
#[serde(default)]
|
|
293 |
+ |
parttype: Option<String>,
|
|
294 |
+ |
#[serde(default)]
|
|
295 |
+ |
children: Vec<Node>,
|
|
296 |
+ |
}
|
|
297 |
+ |
|
|
298 |
+ |
fn find(nodes: &[Node]) -> Option<String> {
|
|
299 |
+ |
for node in nodes {
|
|
300 |
+ |
if node
|
|
301 |
+ |
.parttype
|
|
302 |
+ |
.as_deref()
|
|
303 |
+ |
.is_some_and(|t| t.eq_ignore_ascii_case(ROOT_PARTITION_TYPE))
|
|
304 |
+ |
{
|
|
305 |
+ |
return Some(node.path.clone());
|
|
306 |
+ |
}
|
|
307 |
+ |
if let Some(found) = find(&node.children) {
|
|
308 |
+ |
return Some(found);
|
|
309 |
+ |
}
|
|
310 |
+ |
}
|
|
311 |
+ |
None
|
|
312 |
+ |
}
|
|
313 |
+ |
|
|
314 |
+ |
let parsed: Listing = serde_json::from_str(listing)
|
|
315 |
+ |
.map_err(|err| format!("lsblk emitted invalid JSON: {err}"))?;
|
|
316 |
+ |
|
|
317 |
+ |
find(&parsed.blockdevices).ok_or_else(|| {
|
|
318 |
+ |
format!("no root partition ({ROOT_PARTITION_TYPE}) found after install; disk not deployed")
|
|
319 |
+ |
})
|
|
320 |
+ |
}
|
|
321 |
+ |
|
|
322 |
+ |
/// The commands that configure an already-deployed system.
|
|
323 |
+ |
///
|
|
324 |
+ |
/// `root` is the ostree deployment directory, not the mountpoint. See
|
|
325 |
+ |
/// [`deployment_dir`] for why that distinction is the whole ballgame.
|
|
326 |
+ |
fn configure_plan(hostname: &str, username: &str, password: &str, root: &str) -> Vec<Invocation> {
|
| 268 |
327 |
|
vec![
|
| 269 |
|
- |
// --wipe is explicit rather than implied by the confirm the user just
|
| 270 |
|
- |
// answered: the flag that destroys the disk should be visible on the
|
| 271 |
|
- |
// line the summary displays, not hidden in a default.
|
| 272 |
|
- |
Invocation::new("bootc")
|
| 273 |
|
- |
.args(["install", "to-disk", "--wipe"])
|
| 274 |
|
- |
.arg(disk),
|
| 275 |
328 |
|
Invocation::new("systemd-firstboot")
|
| 276 |
329 |
|
.arg(format!("--root={root}"))
|
| 277 |
330 |
|
.arg(format!("--hostname={hostname}"))
|
| 293 |
346 |
|
Invocation::new("chpasswd")
|
| 294 |
347 |
|
.args(["--root", root])
|
| 295 |
348 |
|
.stdin(Secret::new(format!("{username}:{password}\n"))),
|
| 296 |
|
- |
// Upstream: tools that make changes should run this "as the penultimate
|
| 297 |
|
- |
// step before unmounting the target filesystem". Skipping it is the
|
| 298 |
|
- |
// kind of omission that produces a system which boots and is subtly
|
| 299 |
|
- |
// wrong, so it is in the plan rather than in a comment about the plan.
|
|
349 |
+ |
// Upstream: "optional, but recommended to run as the penultimate step
|
|
350 |
+ |
// before unmounting the target filesystem. This command will perform
|
|
351 |
+ |
// some basic sanity checks and may also perform fixups on the target
|
|
352 |
+ |
// root." Its arguments are not documented on that page; the mountpoint
|
|
353 |
+ |
// is passed positionally, which is the one thing it plausibly wants and
|
|
354 |
+ |
// is flagged in the module header as unverified.
|
| 300 |
355 |
|
Invocation::new("bootc")
|
| 301 |
356 |
|
.args(["install", "finalize"])
|
| 302 |
|
- |
.arg(format!("--root={TARGET_MOUNT}")),
|
|
357 |
+ |
.arg(TARGET_MOUNT),
|
|
358 |
+ |
// Leaving the target mounted would strand the filesystem dirty across
|
|
359 |
+ |
// the reboot the user is about to perform.
|
|
360 |
+ |
Invocation::new("umount").arg(TARGET_MOUNT),
|
|
361 |
+ |
]
|
|
362 |
+ |
}
|
|
363 |
+ |
|
|
364 |
+ |
/// The whole install, as stages.
|
|
365 |
+ |
///
|
|
366 |
+ |
/// Two discoveries, nested. bootc decides which partition holds the new root
|
|
367 |
+ |
/// while it partitions, and names the ostree deployment after a checksum that
|
|
368 |
+ |
/// does not exist until the deploy finishes. Neither can be an argument written
|
|
369 |
+ |
/// in advance, which is why this returns [`Stage`]s rather than a flat list.
|
|
370 |
+ |
fn install_plan(disk: &str, hostname: &str, username: &str, password: &str) -> Vec<Stage> {
|
|
371 |
+ |
let hostname = hostname.to_string();
|
|
372 |
+ |
let username = username.to_string();
|
|
373 |
+ |
let password = password.to_string();
|
|
374 |
+ |
|
|
375 |
+ |
vec![
|
|
376 |
+ |
// --wipe is explicit rather than implied by the confirm the user just
|
|
377 |
+ |
// answered: the flag that destroys the disk should be visible on the
|
|
378 |
+ |
// line the summary displays, not hidden in a default.
|
|
379 |
+ |
Stage::Run(
|
|
380 |
+ |
Invocation::new("bootc")
|
|
381 |
+ |
.args(["install", "to-disk", "--wipe"])
|
|
382 |
+ |
.arg(disk),
|
|
383 |
+ |
),
|
|
384 |
+ |
Stage::Run(Invocation::new("mkdir").args(["-p", TARGET_MOUNT])),
|
|
385 |
+ |
// First discovery: which partition bootc made the root.
|
|
386 |
+ |
Stage::Resolve {
|
|
387 |
+ |
invocation: partition_types(disk),
|
|
388 |
+ |
then: Box::new(move |listing| {
|
|
389 |
+ |
let partition = root_partition(listing)?;
|
|
390 |
+ |
Ok(vec![
|
|
391 |
+ |
Stage::Run(Invocation::new("mount").arg(partition).arg(TARGET_MOUNT)),
|
|
392 |
+ |
// Second discovery, only possible once mounted: where the
|
|
393 |
+ |
// deployment actually is inside the sysroot.
|
|
394 |
+ |
Stage::Resolve {
|
|
395 |
+ |
invocation: deployment_dir(TARGET_MOUNT),
|
|
396 |
+ |
then: Box::new(move |printed| {
|
|
397 |
+ |
let deployment = printed.trim();
|
|
398 |
+ |
if deployment.is_empty() {
|
|
399 |
+ |
return Err("ostree reported no current deployment".into());
|
|
400 |
+ |
}
|
|
401 |
+ |
Ok(configure_plan(&hostname, &username, &password, deployment)
|
|
402 |
+ |
.into_iter()
|
|
403 |
+ |
.map(Stage::Run)
|
|
404 |
+ |
.collect())
|
|
405 |
+ |
}),
|
|
406 |
+ |
},
|
|
407 |
+ |
])
|
|
408 |
+ |
}),
|
|
409 |
+ |
},
|
| 303 |
410 |
|
]
|
| 304 |
411 |
|
}
|
| 305 |
412 |
|
|
| 961 |
1068 |
|
/// Empty if an answer is missing, which cannot happen from the summary step
|
| 962 |
1069 |
|
/// — every earlier step gates on its own validation — but returning nothing
|
| 963 |
1070 |
|
/// beats rendering a command line with a hole in it.
|
| 964 |
|
- |
fn plan(&self) -> Vec<Invocation> {
|
|
1071 |
+ |
fn plan(&self) -> Vec<Stage> {
|
| 965 |
1072 |
|
let (Some(disk), Some(hostname), Some(username)) = (
|
| 966 |
1073 |
|
self.answers.disk.as_deref(),
|
| 967 |
1074 |
|
self.answers.hostname.as_deref(),
|
| 970 |
1077 |
|
return Vec::new();
|
| 971 |
1078 |
|
};
|
| 972 |
1079 |
|
|
| 973 |
|
- |
install_plan(
|
| 974 |
|
- |
disk,
|
| 975 |
|
- |
hostname,
|
| 976 |
|
- |
username,
|
| 977 |
|
- |
self.password.value(),
|
| 978 |
|
- |
TARGET_MOUNT,
|
| 979 |
|
- |
)
|
|
1080 |
+ |
install_plan(disk, hostname, username, self.password.value())
|
| 980 |
1081 |
|
}
|
| 981 |
1082 |
|
|
| 982 |
1083 |
|
/// The hostname pane: a prompt, the field with its caret, and what the
|
| 1741 |
1842 |
|
// ---- the plan ----
|
| 1742 |
1843 |
|
|
| 1743 |
1844 |
|
#[test]
|
| 1744 |
|
- |
fn the_plan_runs_bootc_first_then_configures_the_deployed_root() {
|
|
1845 |
+ |
fn the_plan_starts_with_the_deploy_then_discovers() {
|
| 1745 |
1846 |
|
let (view, _log) = at_summary();
|
| 1746 |
|
- |
let shown: Vec<String> = view.plan().iter().map(Invocation::display).collect();
|
|
1847 |
+ |
let shown: Vec<String> = view.plan().iter().map(Stage::display).collect();
|
| 1747 |
1848 |
|
|
| 1748 |
|
- |
assert_eq!(shown.len(), 5, "{shown:#?}");
|
|
1849 |
+ |
assert_eq!(shown.len(), 3, "{shown:#?}");
|
| 1749 |
1850 |
|
assert_eq!(shown[0], "bootc install to-disk --wipe /dev/sda");
|
| 1750 |
|
- |
assert!(shown[1].starts_with("systemd-firstboot"), "{}", shown[1]);
|
| 1751 |
|
- |
assert!(shown[2].starts_with("useradd"), "{}", shown[2]);
|
| 1752 |
|
- |
assert!(shown[3].starts_with("chpasswd"), "{}", shown[3]);
|
|
1851 |
+ |
assert_eq!(shown[1], "mkdir -p /mnt/alloy-target");
|
|
1852 |
+ |
assert!(shown[2].starts_with("lsblk"), "{}", shown[2]);
|
| 1753 |
1853 |
|
}
|
| 1754 |
1854 |
|
|
| 1755 |
|
- |
// The whole reason Secret exists. The summary renders these lines and the
|
| 1756 |
|
- |
// log pane records them.
|
|
1855 |
+ |
// The summary can only show what is known before anything runs. Everything
|
|
1856 |
+ |
// past the first discovery depends on values that do not exist yet, and
|
|
1857 |
+ |
// inventing lines for them would be the summary claiming to know more than
|
|
1858 |
+ |
// it does.
|
|
1859 |
+ |
#[test]
|
|
1860 |
+ |
fn the_plan_shown_up_front_stops_at_the_first_discovery() {
|
|
1861 |
+ |
let (view, _log) = at_summary();
|
|
1862 |
+ |
let shown: Vec<String> = view.plan().iter().map(Stage::display).collect();
|
|
1863 |
+ |
|
|
1864 |
+ |
assert!(
|
|
1865 |
+ |
!shown.iter().any(|line| line.contains("firstboot")),
|
|
1866 |
+ |
"a command with undiscovered arguments was shown: {shown:#?}"
|
|
1867 |
+ |
);
|
|
1868 |
+ |
}
|
|
1869 |
+ |
|
|
1870 |
+ |
// The whole reason Secret exists: the summary renders these and the log
|
|
1871 |
+ |
// pane records them.
|
| 1757 |
1872 |
|
#[test]
|
| 1758 |
1873 |
|
fn no_line_of_the_plan_carries_the_password() {
|
| 1759 |
1874 |
|
let (view, _log) = at_summary();
|
| 1760 |
|
- |
for invocation in view.plan() {
|
|
1875 |
+ |
for stage in view.plan() {
|
|
1876 |
+ |
assert!(!stage.display().contains("hunter2"), "{}", stage.display());
|
|
1877 |
+ |
}
|
|
1878 |
+ |
for invocation in configure_plan("alloy", "max", "hunter2", "/deploy") {
|
| 1761 |
1879 |
|
let shown = invocation.display();
|
| 1762 |
1880 |
|
assert!(!shown.contains("hunter2"), "password on screen: {shown}");
|
| 1763 |
1881 |
|
}
|
| 1764 |
1882 |
|
}
|
| 1765 |
1883 |
|
|
|
1884 |
+ |
/// The configure half, against a deployment directory as discovered.
|
|
1885 |
+ |
fn configured() -> Vec<String> {
|
|
1886 |
+ |
configure_plan(
|
|
1887 |
+ |
"workshop",
|
|
1888 |
+ |
"max",
|
|
1889 |
+ |
"hunter2",
|
|
1890 |
+ |
"/mnt/alloy-target/ostree/deploy/x/deploy/abc",
|
|
1891 |
+ |
)
|
|
1892 |
+ |
.iter()
|
|
1893 |
+ |
.map(Invocation::display)
|
|
1894 |
+ |
.collect()
|
|
1895 |
+ |
}
|
|
1896 |
+ |
|
| 1766 |
1897 |
|
// --force matters: etc/hostname ships with "alloy" already in it, and
|
| 1767 |
|
- |
// firstboot skips any setting that is already present. Without the flag the
|
| 1768 |
|
- |
// user's answer is silently discarded.
|
|
1898 |
+ |
// firstboot skips any setting already present. Without the flag the user's
|
|
1899 |
+ |
// answer is silently discarded.
|
| 1769 |
1900 |
|
#[test]
|
| 1770 |
1901 |
|
fn firstboot_forces_over_the_images_baked_in_hostname() {
|
| 1771 |
|
- |
let (view, _log) = at_summary();
|
| 1772 |
|
- |
let firstboot = view.plan().remove(1).display();
|
|
1902 |
+ |
let firstboot = configured().remove(0);
|
| 1773 |
1903 |
|
|
| 1774 |
|
- |
assert!(firstboot.contains("--hostname=alloy"), "{firstboot}");
|
|
1904 |
+ |
assert!(firstboot.contains("--hostname=workshop"), "{firstboot}");
|
| 1775 |
1905 |
|
assert!(firstboot.contains("--force"), "{firstboot}");
|
| 1776 |
|
- |
assert!(firstboot.contains(TARGET_MOUNT), "{firstboot}");
|
|
1906 |
+ |
}
|
|
1907 |
+ |
|
|
1908 |
+ |
// The correction that started all this: configuration is pointed at the
|
|
1909 |
+ |
// ostree deployment, never at the mountpoint. A hostname written to the
|
|
1910 |
+ |
// sysroot is read by nothing.
|
|
1911 |
+ |
#[test]
|
|
1912 |
+ |
fn configuration_targets_the_deployment_not_the_mountpoint() {
|
|
1913 |
+ |
for line in configured() {
|
|
1914 |
+ |
if line.starts_with("bootc") || line.starts_with("umount") {
|
|
1915 |
+ |
continue;
|
|
1916 |
+ |
}
|
|
1917 |
+ |
assert!(
|
|
1918 |
+ |
line.contains("/ostree/deploy/"),
|
|
1919 |
+ |
"configured the sysroot instead of the deployment: {line}"
|
|
1920 |
+ |
);
|
|
1921 |
+ |
}
|
| 1777 |
1922 |
|
}
|
| 1778 |
1923 |
|
|
| 1779 |
1924 |
|
// An account that cannot escalate leaves an install with no way to
|
| 1780 |
|
- |
// administer itself, and -m is what makes the home directory exist.
|
|
1925 |
+ |
// administer itself, and --create-home is what makes the home exist.
|
| 1781 |
1926 |
|
#[test]
|
| 1782 |
1927 |
|
fn the_account_gets_a_home_and_a_way_to_sudo() {
|
| 1783 |
|
- |
let (view, _log) = at_summary();
|
| 1784 |
|
- |
let useradd = view.plan().remove(2).display();
|
|
1928 |
+ |
let useradd = configured().remove(1);
|
| 1785 |
1929 |
|
|
| 1786 |
1930 |
|
assert!(useradd.contains("--create-home"), "{useradd}");
|
| 1787 |
1931 |
|
assert!(useradd.contains("wheel"), "{useradd}");
|
| 1788 |
1932 |
|
assert!(useradd.ends_with("max"), "{useradd}");
|
| 1789 |
1933 |
|
}
|
| 1790 |
1934 |
|
|
| 1791 |
|
- |
// Upstream tells installers that make changes to run this "as the
|
| 1792 |
|
- |
// penultimate step before unmounting the target filesystem". Omitting it
|
| 1793 |
|
- |
// yields a system that boots and is subtly wrong, which is the worst kind
|
| 1794 |
|
- |
// of missing step.
|
|
1935 |
+ |
// Upstream tells installers that make changes to run finalize before
|
|
1936 |
+ |
// unmounting, and leaving the target mounted would strand it dirty across
|
|
1937 |
+ |
// the reboot.
|
| 1795 |
1938 |
|
#[test]
|
| 1796 |
|
- |
fn the_plan_finalizes_before_the_target_is_unmounted() {
|
| 1797 |
|
- |
let (view, _log) = at_summary();
|
| 1798 |
|
- |
let last = view.plan().pop().expect("a plan").display();
|
| 1799 |
|
- |
assert!(last.starts_with("bootc install finalize"), "{last}");
|
|
1939 |
+ |
fn the_target_is_finalized_and_then_unmounted() {
|
|
1940 |
+ |
let lines = configured();
|
|
1941 |
+ |
let finalize = &lines[lines.len() - 2];
|
|
1942 |
+ |
let last = &lines[lines.len() - 1];
|
|
1943 |
+ |
|
|
1944 |
+ |
assert!(finalize.starts_with("bootc install finalize"), "{finalize}");
|
|
1945 |
+ |
assert!(last.starts_with("umount"), "{last}");
|
| 1800 |
1946 |
|
}
|
| 1801 |
1947 |
|
|
| 1802 |
|
- |
// The deployment directory is discovered, never assumed. An ostree /etc is
|
| 1803 |
|
- |
// at <mount>/ostree/deploy/<stateroot>/deploy/<checksum>/etc, and that
|
| 1804 |
|
- |
// checksum cannot be known in advance.
|
|
1948 |
+ |
// ---- discovering the root partition ----
|
|
1949 |
+ |
|
|
1950 |
+ |
// Matched on the DPS type GUID rather than position or size: "the third
|
|
1951 |
+ |
// one" and "the biggest one" are both true of a default bootc layout and
|
|
1952 |
+ |
// neither is a promise.
|
| 1805 |
1953 |
|
#[test]
|
| 1806 |
|
- |
fn the_deployment_directory_is_discovered_from_the_mounted_target() {
|
| 1807 |
|
- |
let shown = deployment_dir(TARGET_MOUNT).display();
|
| 1808 |
|
- |
assert_eq!(
|
| 1809 |
|
- |
shown,
|
| 1810 |
|
- |
format!("ostree admin --sysroot={TARGET_MOUNT} --print-current-dir")
|
|
1954 |
+ |
fn the_root_partition_is_found_by_its_dps_type_guid() {
|
|
1955 |
+ |
let listing = format!(
|
|
1956 |
+ |
r#"{{"blockdevices":[{{"path":"/dev/sda","parttype":null,"children":[
|
|
1957 |
+ |
{{"path":"/dev/sda1","parttype":"c12a7328-f81f-11d2-ba4b-00a0c93ec93b"}},
|
|
1958 |
+ |
{{"path":"/dev/sda2","parttype":"{ROOT_PARTITION_TYPE}"}}]}}]}}"#
|
| 1811 |
1959 |
|
);
|
|
1960 |
+ |
assert_eq!(root_partition(&listing).unwrap(), "/dev/sda2");
|
|
1961 |
+ |
}
|
|
1962 |
+ |
|
|
1963 |
+ |
// lsblk reports GUIDs lowercase, but the specification writes them upper.
|
|
1964 |
+ |
#[test]
|
|
1965 |
+ |
fn the_type_guid_match_ignores_case() {
|
|
1966 |
+ |
let listing = format!(
|
|
1967 |
+ |
r#"{{"blockdevices":[{{"path":"/dev/sda1","parttype":"{}"}}]}}"#,
|
|
1968 |
+ |
ROOT_PARTITION_TYPE.to_uppercase()
|
|
1969 |
+ |
);
|
|
1970 |
+ |
assert_eq!(root_partition(&listing).unwrap(), "/dev/sda1");
|
|
1971 |
+ |
}
|
|
1972 |
+ |
|
|
1973 |
+ |
// A deploy that did not happen must say so here, rather than letting mount
|
|
1974 |
+ |
// fail with something about a device that does not exist.
|
|
1975 |
+ |
#[test]
|
|
1976 |
+ |
fn no_root_partition_is_a_named_failure() {
|
|
1977 |
+ |
let listing = r#"{"blockdevices":[{"path":"/dev/sda1","parttype":null}]}"#;
|
|
1978 |
+ |
let error = root_partition(listing).unwrap_err();
|
|
1979 |
+ |
assert!(error.contains("no root partition"), "{error}");
|
|
1980 |
+ |
}
|
|
1981 |
+ |
|
|
1982 |
+ |
#[test]
|
|
1983 |
+ |
fn a_malformed_listing_is_a_named_failure() {
|
|
1984 |
+ |
assert!(root_partition("not json").unwrap_err().contains("JSON"));
|
| 1812 |
1985 |
|
}
|
| 1813 |
1986 |
|
|
| 1814 |
1987 |
|
// Missing answers cannot happen from the summary — every step gates on its
|
| 1833 |
2006 |
|
|
| 1834 |
2007 |
|
let sequence = view.running.as_ref().expect("nothing was queued");
|
| 1835 |
2008 |
|
assert!(!sequence.is_done());
|
| 1836 |
|
- |
assert_eq!(sequence.progress(), (0, 5));
|
|
2009 |
+ |
// Three up front; the rest appear as the discoveries resolve.
|
|
2010 |
+ |
assert_eq!(sequence.progress(), (0, 3));
|
| 1837 |
2011 |
|
assert!(sequence.output().is_empty(), "a command ran during confirm");
|
| 1838 |
2012 |
|
}
|
| 1839 |
2013 |
|
|