Skip to main content

bootc_lib/install/
baseline.rs

1//! # The baseline installer
2//!
3//! This module handles creation of simple root filesystem setups.  At the current time
4//! it's very simple - just a direct filesystem (e.g. xfs, ext4, btrfs etc.).  It is
5//! intended to add opinionated handling of TPM2-bound LUKS too.  But that's about it;
6//! other more complex flows should set things up externally and use `bootc install to-filesystem`.
7
8use std::borrow::Cow;
9use std::fmt::Display;
10use std::fmt::Write as _;
11use std::io::Write;
12use std::process::Command;
13use std::process::Stdio;
14
15use anyhow::Ok;
16use anyhow::{Context, Result};
17use bootc_utils::CommandRunExt;
18use camino::Utf8Path;
19use camino::Utf8PathBuf;
20use cap_std::fs::Dir;
21use cap_std_ext::cap_std;
22use clap::ValueEnum;
23use fn_error_context::context;
24use serde::{Deserialize, Serialize};
25
26use super::MountSpec;
27use super::RUN_BOOTC;
28use super::RW_KARG;
29use super::RootSetup;
30use super::State;
31use super::config::Filesystem;
32use crate::task::Task;
33use bootc_kernel_cmdline::utf8::Cmdline;
34#[cfg(feature = "install-to-disk")]
35use bootc_mount::is_mounted_in_pid1_mountns;
36
37/// Check whether DPS auto-discovery is enabled.  When `true`,
38/// `root=UUID=` is omitted and `systemd-gpt-auto-generator` discovers
39/// the root partition via its DPS type GUID instead.
40///
41/// Defaults to `true` for systemd-boot (which always implements BLI).
42/// For GRUB the default is `false` because we cannot know at install
43/// time whether the GRUB build includes the `bli` module — the module
44/// is baked into the signed EFI binary with no external manifest.
45/// Distros shipping a BLI-capable GRUB should set
46/// `discoverable-partitions = true` in their install config.
47#[cfg(feature = "install-to-disk")]
48fn use_discoverable_partitions(state: &State) -> bool {
49    // Explicit config takes priority
50    if let Some(ref config) = state.install_config {
51        if let Some(v) = config.discoverable_partitions {
52            return v;
53        }
54    }
55    // systemd-boot always supports BLI
56    matches!(
57        state.config_opts.bootloader,
58        Some(crate::spec::Bootloader::Systemd)
59    )
60}
61
62// This ensures we end up under 512 to be small-sized.
63pub(crate) const BOOTPN_SIZE_MB: u32 = 510;
64pub(crate) const EFIPN_SIZE_MB: u32 = 512;
65/// EFI Partition size for composefs installations
66/// We need more space than ostree as we have UKIs and UKI addons
67/// We might also need to store UKIs for pinned deployments
68pub(crate) const CFS_EFIPN_SIZE_MB: u32 = 1024;
69#[cfg(feature = "install-to-disk")]
70pub(crate) const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B";
71#[cfg(feature = "install-to-disk")]
72pub(crate) const PREPBOOT_LABEL: &str = "PowerPC-PReP-boot";
73
74#[derive(clap::ValueEnum, Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "kebab-case")]
76pub(crate) enum BlockSetup {
77    #[default]
78    Direct,
79    Tpm2Luks,
80}
81
82impl Display for BlockSetup {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        self.to_possible_value().unwrap().get_name().fmt(f)
85    }
86}
87
88/// Options for installing to a block device
89#[derive(Debug, Clone, clap::Args, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "kebab-case")]
91pub(crate) struct InstallBlockDeviceOpts {
92    /// Target block device for installation.  The entire device will be wiped.
93    pub(crate) device: Utf8PathBuf,
94
95    /// Automatically wipe all existing data on device
96    #[clap(long)]
97    #[serde(default)]
98    pub(crate) wipe: bool,
99
100    /// Target root block device setup.
101    ///
102    /// direct: Filesystem written directly to block device
103    /// tpm2-luks: Bind unlock of filesystem to presence of the default tpm2 device.
104    #[clap(long, value_enum)]
105    pub(crate) block_setup: Option<BlockSetup>,
106
107    /// Target root filesystem type.
108    #[clap(long, value_enum)]
109    pub(crate) filesystem: Option<Filesystem>,
110
111    /// Size of the root partition (default specifier: M).  Allowed specifiers: M (mebibytes), G (gibibytes), T (tebibytes).
112    ///
113    /// By default, all remaining space on the disk will be used.
114    #[clap(long)]
115    pub(crate) root_size: Option<String>,
116}
117
118impl BlockSetup {
119    /// Returns true if the block setup requires a separate /boot aka XBOOTLDR partition.
120    pub(crate) fn requires_bootpart(&self) -> bool {
121        match self {
122            BlockSetup::Direct => false,
123            BlockSetup::Tpm2Luks => true,
124        }
125    }
126}
127
128#[cfg(feature = "install-to-disk")]
129fn mkfs<'a>(
130    dev: &str,
131    fs: Filesystem,
132    label: &str,
133    wipe: bool,
134    opts: impl IntoIterator<Item = &'a str>,
135) -> Result<uuid::Uuid> {
136    let devinfo = bootc_blockdev::list_dev(dev.into())?;
137    let size = ostree_ext::glib::format_size(devinfo.size);
138
139    // Generate a random UUID for the filesystem
140    let u = uuid::Uuid::new_v4();
141
142    let mut t = Task::new(
143        &format!("Creating {label} filesystem ({fs}) on device {dev} (size={size})"),
144        format!("mkfs.{fs}"),
145    );
146    match fs {
147        Filesystem::Xfs => {
148            if wipe {
149                t.cmd.arg("-f");
150            }
151            t.cmd.arg("-m");
152            t.cmd.arg(format!("uuid={u}"));
153        }
154        Filesystem::Btrfs | Filesystem::Ext4 => {
155            t.cmd.arg("-U");
156            t.cmd.arg(u.to_string());
157        }
158    };
159    // Today all the above mkfs commands take -L
160    t.cmd.args(["-L", label]);
161    t.cmd.args(opts);
162    t.cmd.arg(dev);
163    // All the mkfs commands are unnecessarily noisy by default
164    t.cmd.stdout(Stdio::null());
165    // But this one is notable so let's print the whole thing with verbose()
166    t.verbose().run()?;
167    Ok(u)
168}
169
170pub(crate) fn wipefs(dev: &Utf8Path) -> Result<()> {
171    println!("Wiping device {dev}");
172    Command::new("wipefs")
173        .args(["-a", dev.as_str()])
174        .run_inherited_with_cmd_context()
175}
176
177pub(crate) fn udev_settle() -> Result<()> {
178    // There's a potential window after rereading the partition table where
179    // udevd hasn't yet received updates from the kernel, settle will return
180    // immediately, and lsblk won't pick up partition labels.  Try to sleep
181    // our way out of this.
182    std::thread::sleep(std::time::Duration::from_millis(200));
183
184    let st = super::run_in_host_mountns("udevadm")?
185        .arg("settle")
186        .status()?;
187    if !st.success() {
188        anyhow::bail!("Failed to run udevadm settle: {st:?}");
189    }
190    Ok(())
191}
192
193#[context("Creating rootfs")]
194#[cfg(feature = "install-to-disk")]
195pub(crate) fn install_create_rootfs(
196    state: &State,
197    opts: InstallBlockDeviceOpts,
198) -> Result<RootSetup> {
199    let install_config = state.install_config.as_ref();
200    let luks_name = "root";
201    // Ensure we have a root filesystem upfront
202    let root_filesystem = opts
203        .filesystem
204        .or(install_config
205            .and_then(|c| c.filesystem_root())
206            .and_then(|r| r.fstype))
207        .ok_or_else(|| anyhow::anyhow!("No root filesystem specified"))?;
208    // Verify that the target is empty (if not already wiped in particular, but it's
209    // also good to verify that the wipe worked)
210    let mut device = bootc_blockdev::list_dev(&opts.device)?;
211
212    // Always disallow writing to mounted device
213    if is_mounted_in_pid1_mountns(&device.path())? {
214        anyhow::bail!("Device {} is mounted", device.path())
215    }
216
217    // Handle wiping any existing data
218    if opts.wipe {
219        let dev = &opts.device;
220        for child in device.children.iter().flatten() {
221            let child = child.path();
222            println!("Wiping {child}");
223            wipefs(Utf8Path::new(&child))?;
224        }
225        println!("Wiping {dev}");
226        wipefs(dev)?;
227    } else if device.has_children() {
228        anyhow::bail!(
229            "Detected existing partitions on {}; use e.g. `wipefs` or --wipe if you intend to overwrite",
230            opts.device
231        );
232    }
233
234    let run_bootc = Utf8Path::new(RUN_BOOTC);
235    let mntdir = run_bootc.join("mounts");
236    if mntdir.exists() {
237        std::fs::remove_dir_all(&mntdir)?;
238    }
239
240    // Use the install configuration to find the block setup, if we have one
241    let block_setup = if let Some(config) = install_config {
242        config.get_block_setup(opts.block_setup.as_ref().copied())?
243    } else if opts.filesystem.is_some() {
244        // Otherwise, if a filesystem is specified then we default to whatever was
245        // specified via --block-setup, or the default
246        opts.block_setup.unwrap_or_default()
247    } else {
248        // If there was no default filesystem, then there's no default block setup,
249        // and we need to error out.
250        anyhow::bail!("No install configuration found, and no filesystem specified")
251    };
252    let serial = device.serial.as_deref().unwrap_or("<unknown>");
253    let model = device.model.as_deref().unwrap_or("<unknown>");
254    let discoverable = use_discoverable_partitions(state);
255    println!("Block setup: {block_setup}");
256    println!("       Size: {}", device.size);
257    println!("     Serial: {serial}");
258    println!("      Model: {model}");
259    println!(
260        " Partitions: {}",
261        if discoverable { "Discoverable" } else { "UUID" }
262    );
263
264    let root_size = opts
265        .root_size
266        .as_deref()
267        .map(bootc_blockdev::parse_size_mib)
268        .transpose()
269        .context("Parsing root size")?;
270
271    // Load the policy from the container root, which also must be our install root
272    let sepolicy = state.load_policy()?;
273    let sepolicy = sepolicy.as_ref();
274
275    // Create a temporary directory to use for mount points.  Note that we're
276    // in a mount namespace, so these should not be visible on the host.
277    let physical_root_path = mntdir.join("rootfs");
278    std::fs::create_dir_all(&physical_root_path)?;
279    let bootfs = mntdir.join("boot");
280    std::fs::create_dir_all(bootfs)?;
281
282    // Generate partitioning spec as input to sfdisk
283    let mut partno = 0;
284    let mut partitioning_buf = String::new();
285    writeln!(partitioning_buf, "label: gpt")?;
286    let random_label = uuid::Uuid::new_v4();
287    writeln!(&mut partitioning_buf, "label-id: {random_label}")?;
288    if cfg!(target_arch = "x86_64") {
289        partno += 1;
290        writeln!(
291            &mut partitioning_buf,
292            r#"size=1MiB, bootable, type=21686148-6449-6E6F-744E-656564454649, name="BIOS-BOOT""#
293        )?;
294    } else if cfg!(target_arch = "powerpc64") {
295        // PowerPC-PReP-boot
296        partno += 1;
297        let label = PREPBOOT_LABEL;
298        let uuid = PREPBOOT_GUID;
299        writeln!(
300            &mut partitioning_buf,
301            r#"size=4MiB, bootable, type={uuid}, name="{label}""#
302        )?;
303    } else if cfg!(any(target_arch = "aarch64", target_arch = "s390x")) {
304        // No bootloader partition is necessary
305    } else {
306        anyhow::bail!("Unsupported architecture: {}", std::env::consts::ARCH);
307    }
308
309    let esp_partno = if super::ARCH_USES_EFI {
310        let esp_guid = crate::discoverable_partition_specification::ESP;
311        partno += 1;
312
313        let esp_size = if state.composefs_options.composefs_backend {
314            CFS_EFIPN_SIZE_MB
315        } else {
316            EFIPN_SIZE_MB
317        };
318
319        writeln!(
320            &mut partitioning_buf,
321            r#"size={esp_size}MiB, type={esp_guid}, name="EFI-SYSTEM""#
322        )?;
323        Some(partno)
324    } else {
325        None
326    };
327
328    // Initialize the /boot filesystem.  Note that in the future, we may match
329    // what systemd/uapi-group encourages and make /boot be FAT32 as well, as
330    // it would aid systemd-boot.
331    let boot_partno = if block_setup.requires_bootpart() {
332        partno += 1;
333        writeln!(
334            &mut partitioning_buf,
335            r#"size={BOOTPN_SIZE_MB}MiB, name="boot""#
336        )?;
337        Some(partno)
338    } else {
339        None
340    };
341    let rootpn = partno + 1;
342    let root_size = root_size
343        .map(|v| Cow::Owned(format!("size={v}MiB, ")))
344        .unwrap_or_else(|| Cow::Borrowed(""));
345    let rootpart_uuid =
346        uuid::Uuid::parse_str(crate::discoverable_partition_specification::this_arch_root())?;
347    writeln!(
348        &mut partitioning_buf,
349        r#"{root_size}type={rootpart_uuid}, name="root""#
350    )?;
351    tracing::debug!("Partitioning: {partitioning_buf}");
352    Task::new("Initializing partitions", "sfdisk")
353        .arg("--wipe=always")
354        .arg(device.path())
355        .quiet()
356        .run_with_stdin_buf(Some(partitioning_buf.as_bytes()))
357        .context("Failed to run sfdisk")?;
358    tracing::debug!("Created partition table");
359
360    // Full udev sync; it'd obviously be better to await just the devices
361    // we're targeting, but this is a simple coarse hammer.
362    udev_settle()?;
363
364    // Re-read partition table to get updated children
365    device.refresh()?;
366
367    let root_device = device.find_device_by_partno(rootpn)?;
368    // Verify the partition type matches the DPS root partition type for this architecture
369    let expected_parttype = crate::discoverable_partition_specification::this_arch_root();
370    if !root_device
371        .parttype
372        .as_ref()
373        .is_some_and(|pt| pt.eq_ignore_ascii_case(expected_parttype))
374    {
375        anyhow::bail!(
376            "root partition {rootpn} has type {}; expected {expected_parttype}",
377            root_device.parttype.as_deref().unwrap_or("<none>")
378        );
379    }
380    let (rootdev_path, root_blockdev_kargs) = match block_setup {
381        BlockSetup::Direct => (root_device.path(), None),
382        BlockSetup::Tpm2Luks => {
383            let uuid = uuid::Uuid::new_v4().to_string();
384            // This will be replaced via --wipe-slot=all when binding to tpm below
385            let dummy_passphrase = uuid::Uuid::new_v4().to_string();
386            let mut tmp_keyfile = tempfile::NamedTempFile::new()?;
387            tmp_keyfile.write_all(dummy_passphrase.as_bytes())?;
388            tmp_keyfile.flush()?;
389            let tmp_keyfile = tmp_keyfile.path();
390            let dummy_passphrase_input = Some(dummy_passphrase.as_bytes());
391
392            let root_devpath = root_device.path();
393
394            Task::new("Initializing LUKS for root", "cryptsetup")
395                .args(["luksFormat", "--uuid", uuid.as_str(), "--key-file"])
396                .args([tmp_keyfile])
397                .arg(&root_devpath)
398                .run()?;
399            // The --wipe-slot=all removes our temporary passphrase, and binds to the local TPM device.
400            // We also use .verbose() here as the details are important/notable.
401            Task::new("Enrolling root device with TPM", "systemd-cryptenroll")
402                .args(["--wipe-slot=all", "--tpm2-device=auto", "--unlock-key-file"])
403                .args([tmp_keyfile])
404                .arg(&root_devpath)
405                .verbose()
406                .run_with_stdin_buf(dummy_passphrase_input)?;
407            Task::new("Opening root LUKS device", "cryptsetup")
408                .args(["luksOpen", &root_devpath, luks_name])
409                .run()?;
410            let rootdev = format!("/dev/mapper/{luks_name}");
411            let kargs = vec![
412                format!("luks.uuid={uuid}"),
413                format!("luks.options=tpm2-device=auto,headless=true"),
414            ];
415            (rootdev, Some(kargs))
416        }
417    };
418
419    // Initialize the /boot filesystem
420    let bootdev = if let Some(bootpn) = boot_partno {
421        Some(device.find_device_by_partno(bootpn)?)
422    } else {
423        None
424    };
425    let boot_uuid = if let Some(bootdev) = bootdev {
426        Some(
427            mkfs(&bootdev.path(), root_filesystem, "boot", opts.wipe, [])
428                .context("Initializing /boot")?,
429        )
430    } else {
431        None
432    };
433
434    // Unconditionally enable fsverity for ext4
435    let mkfs_options = match root_filesystem {
436        Filesystem::Ext4 => ["-O", "verity"].as_slice(),
437        _ => [].as_slice(),
438    };
439
440    // Initialize rootfs
441    let root_uuid = mkfs(
442        &rootdev_path,
443        root_filesystem,
444        "root",
445        opts.wipe,
446        mkfs_options.iter().copied(),
447    )?;
448    let bootsrc = boot_uuid.as_ref().map(|uuid| format!("UUID={uuid}"));
449    let bootarg = bootsrc.as_deref().map(|bootsrc| format!("boot={bootsrc}"));
450    let boot = bootsrc.map(|bootsrc| MountSpec {
451        source: bootsrc,
452        target: "/boot".into(),
453        fstype: MountSpec::AUTO.into(),
454        options: Some("ro".into()),
455    });
456
457    let mut kargs = Cmdline::new();
458
459    // Add root blockdev kargs (e.g., LUKS parameters)
460    if let Some(root_blockdev_kargs) = root_blockdev_kargs {
461        for karg in root_blockdev_kargs {
462            kargs.extend(&Cmdline::from(karg.as_str()));
463        }
464    }
465
466    // When discoverable-partitions is enabled, omit root= so that
467    // systemd-gpt-auto-generator discovers root by its DPS type GUID.
468    if discoverable {
469        kargs.extend(&Cmdline::from(RW_KARG));
470    } else {
471        let rootarg = format!("root=UUID={root_uuid}");
472        kargs.extend(&Cmdline::from(format!("{rootarg} {RW_KARG}")));
473    }
474
475    // Add boot= argument if present
476    if let Some(bootarg) = bootarg {
477        kargs.extend(&Cmdline::from(bootarg.as_str()));
478    }
479
480    // Add CLI kargs
481    if let Some(cli_kargs) = state.config_opts.karg.as_ref() {
482        for karg in cli_kargs {
483            kargs.extend(karg);
484        }
485    }
486
487    let fstype = &root_filesystem.to_string();
488    bootc_mount::mount_typed(&rootdev_path, fstype, &physical_root_path)?;
489    let target_rootfs = Dir::open_ambient_dir(&physical_root_path, cap_std::ambient_authority())?;
490    crate::lsm::ensure_dir_labeled(&target_rootfs, "", Some("/".into()), 0o755.into(), sepolicy)?;
491    let physical_root = Dir::open_ambient_dir(&physical_root_path, cap_std::ambient_authority())?;
492    let bootfs = physical_root_path.join("boot");
493    // Create the underlying mount point directory, which should be labeled
494    crate::lsm::ensure_dir_labeled(&target_rootfs, "boot", None, 0o755.into(), sepolicy)?;
495    if let Some(bootdev) = bootdev {
496        bootc_mount::mount_typed(&bootdev.path(), fstype, &bootfs)?;
497    }
498    // And we want to label the root mount of /boot
499    crate::lsm::ensure_dir_labeled(&target_rootfs, "boot", None, 0o755.into(), sepolicy)?;
500
501    // Create the EFI system partition, if applicable
502    if let Some(esp_partno) = esp_partno {
503        let espdev = device.find_device_by_partno(esp_partno)?;
504        Task::new("Creating ESP filesystem", "mkfs.fat")
505            .args([&espdev.path(), "-n", "EFI-SYSTEM"])
506            .verbose()
507            .quiet_output()
508            .run()?;
509        let efifs_path = bootfs.join(crate::bootloader::EFI_DIR);
510        std::fs::create_dir(&efifs_path).context("Creating efi dir")?;
511    }
512
513    let luks_device = match block_setup {
514        BlockSetup::Direct => None,
515        BlockSetup::Tpm2Luks => Some(luks_name.to_string()),
516    };
517    Ok(RootSetup {
518        luks_device,
519        device_info: device,
520        physical_root_path,
521        physical_root,
522        target_root_path: None,
523        rootfs_uuid: Some(root_uuid.to_string()),
524        boot,
525        kargs,
526        skip_finalize: false,
527    })
528}