Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions crates/blockdev/src/blockdev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ pub const ESP: &str = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b";
/// BIOS boot partition type GUID for GPT
pub const BIOS_BOOT: &str = "21686148-6449-6e6f-744e-656564454649";

/// Variable Data Partition type GUID (DPS), the same on all architectures
pub const VAR: &str = "4d21b016-b534-45c2-a9fb-5c16e091fd2d";

#[derive(Debug, Deserialize)]
struct DevicesOutput {
blockdevices: Vec<Device>,
Expand Down Expand Up @@ -230,6 +233,16 @@ impl Device {
Ok((!bios_boots.is_empty()).then_some(bios_boots))
}

/// Find the first Variable Data Partition (DPS) among all root devices
/// backing this device. Returns None if there is none.
pub fn find_colocated_var(&self) -> Result<Option<Device>> {
Ok(self
.find_all_roots()?
.iter()
.find_map(|root| root.find_partition_of_type(VAR))
.cloned())
}

/// Find a child partition by partition type (case-insensitive).
pub fn find_partition_of_type(&self, parttype: &str) -> Option<&Device> {
self.children.as_ref()?.iter().find(|child| {
Expand Down Expand Up @@ -870,6 +883,19 @@ mod test {
assert_eq!(bios.parttype.as_deref().unwrap(), BIOS_BOOT);
}

/// A Variable Data Partition is found by its type GUID, whatever the case.
#[test]
fn test_find_partition_of_var() {
let fixture = include_str!("../tests/fixtures/lsblk.json");
let devs: DevicesOutput = serde_json::from_str(fixture).unwrap();
let mut dev = devs.blockdevices.into_iter().next().unwrap();
assert!(dev.find_partition_of_type(VAR).is_none());
let last = dev.children.as_mut().unwrap().last_mut().unwrap();
last.parttype = Some(VAR.to_uppercase());
let var = dev.find_partition_of_type(VAR).unwrap();
assert_eq!(var.partn, Some(3));
}

/// Verify that without the udev database, partition type fields are null
/// and partition discovery fails. This simulates what happens when bootc
/// runs inside a sandbox (like osbuild's bwrap) without /run/udev.
Expand Down
27 changes: 15 additions & 12 deletions crates/lib/src/bootc_composefs/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -794,20 +794,23 @@ pub(crate) fn setup_composefs_bls_boot(
build_composefs_karg(id.clone(), format_version, allow_missing_fsverity);
cmdline_options.extend(&Cmdline::from(&composefs_cmdline));

// If there's a separate /boot partition, add a systemd.mount-extra
// If there's a separate /boot or /var, add a systemd.mount-extra
// karg so systemd mounts it after reboot. This avoids writing to
// /etc/fstab which conflicts with transient etc (see #1388).
if let Some(boot) = root_setup.boot_mount_spec() {
if !boot.source.is_empty() {
let mount_extra = format!(
"systemd.mount-extra={}:/boot:{}:{}",
boot.source,
boot.fstype,
boot.options.as_deref().unwrap_or("defaults"),
);
cmdline_options.extend(&Cmdline::from(mount_extra.as_str()));
tracing::debug!("Added /boot mount karg: {mount_extra}");
}
for mount in [root_setup.boot_mount_spec(), root_setup.var_mount_spec()]
.into_iter()
.flatten()
.filter(|m| !m.source.is_empty())
{
let mount_extra = format!(
"systemd.mount-extra={}:{}:{}:{}",
mount.source,
mount.target,
mount.fstype,
mount.options.as_deref().unwrap_or("defaults"),
);
cmdline_options.extend(&Cmdline::from(mount_extra.as_str()));
tracing::debug!("Added {} mount karg: {mount_extra}", mount.target);
}

// Locate ESP partition device by walking up to the root disk(s)
Expand Down
31 changes: 30 additions & 1 deletion crates/lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,22 @@ impl InternalsOpts {
const GENERATOR_BIN: &'static str = "bootc-systemd-generator";
}

/// The text of `bootc --version`: the version, then the optional features of
/// this binary that tools driving bootc can rely on being present.
fn long_version() -> &'static str {
static LONG_VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
LONG_VERSION
.get_or_init(|| format_long_version(clap::crate_version!(), crate::install::FEATURES))
}

fn format_long_version(version: &str, features: &[&str]) -> String {
let features = features
.iter()
.map(|f| format!("\n - {f}"))
.collect::<String>();
format!("{version}\nFeatures:{features}")
}

/// Deploy and transactionally in-place with bootable container images.
///
/// The `bootc` project currently uses ostree-containers as a backend
Expand All @@ -983,7 +999,7 @@ impl InternalsOpts {
#[derive(Debug, Parser, PartialEq, Eq)]
#[clap(name = "bootc")]
#[clap(rename_all = "kebab-case")]
#[clap(version,long_version=clap::crate_version!())]
#[clap(version, long_version = long_version())]
#[allow(clippy::large_enum_variant)]
pub(crate) enum Opt {
/// Download and queue an updated container image to apply.
Expand Down Expand Up @@ -3161,4 +3177,17 @@ mod tests {
}
}
}

#[test]
fn test_long_version() {
assert_eq!(
format_long_version("1.2.3", &["a", "b"]),
"1.2.3\nFeatures:\n - a\n - b"
);
// Every feature we have shows up in `bootc --version`
let long = Opt::command().render_long_version();
for f in crate::install::FEATURES {
assert!(long.contains(&format!("\n - {f}\n")), "{long}");
}
}
}
140 changes: 130 additions & 10 deletions crates/lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ pub(crate) mod completion;
pub(crate) mod config;
mod osbuild;
pub(crate) mod osconfig;
mod var_mounts;

use std::collections::HashMap;
use std::io::Write;
Expand Down Expand Up @@ -224,6 +225,8 @@ const ALONGSIDE_ROOT_MOUNT: &str = "/target";
pub(crate) const DESTRUCTIVE_CLEANUP: &str = "etc/bootc-destructive-cleanup";
/// This is an ext4 special directory we need to ignore.
const LOST_AND_FOUND: &str = "lost+found";
/// Optional behaviors of this binary, listed by `bootc --version`.
pub(crate) const FEATURES: &[&str] = &[var_mounts::FEATURE];
/// The mount path for selinux
const SELINUXFS: &str = "/sys/fs/selinux";
/// The mount path for uefi
Expand Down Expand Up @@ -487,6 +490,17 @@ pub(crate) struct InstallTargetFilesystemOpts {
#[clap(long)]
pub(crate) boot_mount_spec: Option<String>,

/// Source device specification to mount at /var, such as `UUID=...`,
/// `LABEL=...` or a device path.
///
/// bootc mounts it on the target, initializes it from the image's /var
/// and records it for mounting at boot. By default, a Variable Data
/// Partition (per the Discoverable Partitions Specification) on the disk
/// backing the root is used, if there is one. An empty value disables
/// that discovery and all /var handling.
#[clap(long, alias = "var")]
pub(crate) var_mount_spec: Option<String>,

/// Initialize the system in-place; at the moment, only one mode for this is implemented.
/// In the future, it may also be supported to set up an explicit "dual boot" system.
#[clap(long)]
Expand Down Expand Up @@ -653,6 +667,8 @@ pub(crate) struct State {
pub(crate) composefs_options: InstallComposefsOpts,
pub(crate) composefs_fsverity_supported: bool,
pub(crate) allow_missing_verity_explicit: bool,
/// The image has a UKI as its kernel
pub(crate) is_uki: bool,
}

// Shared read-only global state
Expand Down Expand Up @@ -1286,15 +1302,22 @@ async fn install_container(
}
}

// Write the entry for /boot to /etc/fstab. TODO: Encourage OSes to use the karg?
// Write the entries for /boot and /var to /etc/fstab. TODO: Encourage OSes to use the karg?
// Or better bind this with the grub data.
// We omit it if the boot mountspec argument was empty
if let Some(boot) = root_setup.boot.as_ref() {
if !boot.source.is_empty() {
crate::lsm::atomic_replace_labeled(&root, "etc/fstab", 0o644.into(), sepolicy, |w| {
writeln!(w, "{}", boot.to_fstab()).map_err(Into::into)
})?;
}
// We omit /boot if the boot mountspec argument was empty
let fstab = [root_setup.boot_mount_spec(), root_setup.var_mount_spec()]
.into_iter()
.flatten()
.filter(|m| !m.source.is_empty())
.map(|m| m.to_fstab())
.collect::<Vec<_>>();
if !fstab.is_empty() {
crate::lsm::atomic_replace_labeled(&root, "etc/fstab", 0o644.into(), sepolicy, |w| {
fstab
.iter()
.try_for_each(|l| writeln!(w, "{l}"))
.map_err(Into::into)
})?;
}

if let Some(contents) = state.root_ssh_authorized_keys.as_deref() {
Expand Down Expand Up @@ -1372,6 +1395,8 @@ pub(crate) struct RootSetup {
/// True if we should skip finalizing
skip_finalize: bool,
boot: Option<MountSpec>,
/// A /var filesystem that bootc mounted itself for the installation.
var: Option<var_mounts::Mounted>,
pub(crate) kargs: CmdlineOwned,
}

Expand All @@ -1392,6 +1417,11 @@ impl RootSetup {
self.boot.as_ref()
}

/// Get the mount spec of the /var filesystem that bootc mounted, if any.
pub(crate) fn var_mount_spec(&self) -> Option<&MountSpec> {
self.var.as_ref().map(|v| &v.spec)
}

// Drop any open file descriptors and return just the mount path and backing luks device, if any
#[cfg(feature = "install-to-disk")]
fn into_storage(self) -> (Utf8PathBuf, Option<String>) {
Expand Down Expand Up @@ -1895,6 +1925,7 @@ async fn prepare_install(
.map(|fs| fs.supports_fsverity())
.unwrap_or(true),
allow_missing_verity_explicit,
is_uki,
});

Ok(state)
Expand Down Expand Up @@ -2090,6 +2121,7 @@ async fn install_to_filesystem_impl(
state: &State,
rootfs: &mut RootSetup,
cleanup: Cleanup,
var_mounts: &[Utf8PathBuf],
) -> Result<()> {
if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) {
rootfs.kargs.extend(&Cmdline::from("selinux=0"));
Expand Down Expand Up @@ -2204,6 +2236,20 @@ async fn install_to_filesystem_impl(
}
}

// The deployment backend seeds its own state directory, not the caller's
// mounted /var tree. Populate fresh external filesystems while all nested
// mounts are still visible, before labeling and finalizing the installation.
let prepared_var_mounts = if !var_mounts.is_empty() {
let source = if state.composefs_options.composefs_backend {
Utf8PathBuf::from(crate::composefs_consts::SHARED_VAR_PATH)
} else {
Utf8PathBuf::from(format!("ostree/deploy/{}/var", state.stateroot()))
};
var_mounts::populate(&rootfs.physical_root, &source, var_mounts)?
} else {
Vec::new()
};

// As the very last step before filesystem finalization, do a full SELinux
// relabel of the physical root filesystem. Any files that are already
// labeled (e.g. ostree deployment contents, composefs objects) are skipped.
Expand All @@ -2212,10 +2258,36 @@ async fn install_to_filesystem_impl(
let mut path = Utf8PathBuf::from("");
crate::lsm::ensure_dir_labeled_recurse(&rootfs.physical_root, &mut path, &policy, None)
.context("Final SELinux relabeling of physical root")?;
// The physical-root walk skips mountpoints. Label each newly
// prepared filesystem separately, using its deployed /var path.
for mount in &prepared_var_mounts {
let mut path = var_mounts::target_path(mount);
// These are fresh filesystems; label their roots unconditionally.
// Without selinuxfs (e.g. in an osbuild buildroot), an inode without
// a label still reports the kernel's unlabeled context, which the
// walk below would take as labeled.
for p in [path.clone(), path.join(LOST_AND_FOUND)] {
if let Some(meta) = rootfs.physical_root.symlink_metadata_optional(&p)? {
crate::lsm::relabel(&rootfs.physical_root, &meta, &p, None, &policy)
.with_context(|| format!("SELinux labeling of /{p}"))?;
}
}
crate::lsm::ensure_dir_labeled_recurse(&rootfs.physical_root, &mut path, &policy, None)
.with_context(|| format!("SELinux labeling of /{path}"))?;
}
} else {
tracing::debug!("Skipping final SELinux relabel (SELinux is disabled)");
}

// Flush the /var filesystems we wrote, surfacing any writeback errors.
// Unlike root and boot they are not remounted read-only or frozen: they
// may be bind mounts or lack freeze support, and callers may add content.
var_mounts::sync(&rootfs.physical_root, &prepared_var_mounts)?;
// A /var that we mounted is for the booted system to mount, not ours to leave behind.
if let Some(var) = &rootfs.var {
var.unmount()?;
}

// Finalize mounted filesystems
if !rootfs.skip_finalize {
let bootfs = rootfs.boot.as_ref().map(|_| ("boot", BOOT));
Expand Down Expand Up @@ -2305,7 +2377,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> {
(rootfs, loopback_dev)
};

install_to_filesystem_impl(&state, &mut rootfs, Cleanup::Skip).await?;
install_to_filesystem_impl(&state, &mut rootfs, Cleanup::Skip, &[]).await?;

// Drop all data about the root except the bits we need to ensure any file descriptors etc. are closed.
let (root_path, luksdev) = rootfs.into_storage();
Expand Down Expand Up @@ -2829,11 +2901,58 @@ pub(crate) async fn install_to_filesystem(
target_root_path: Some(target_root_path.clone()),
rootfs_uuid: inspect.uuid.clone(),
boot,
var: None,
kargs,
skip_finalize,
};

install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?;
// Reinstalling alongside an existing OS must not touch its live state.
let var_plan = if targeting_host_root
|| is_already_ostree
|| matches!(fsopts.replace, Some(ReplaceMode::Alongside))
{
anyhow::ensure!(
fsopts.var_mount_spec.as_deref().is_none_or(str::is_empty),
"--var-mount-spec is not supported when installing to the host root, over an \
existing ostree system or with --replace=alongside"
);
if let Some(spec) = state
.install_config
.as_ref()
.and_then(|c| c.var_mount_spec.as_deref())
.filter(|s| !s.is_empty())
{
tracing::warn!(
"Ignoring var-mount-spec={spec} from the install config: it is not supported \
when installing to the host root, over an existing ostree system or with \
--replace=alongside"
);
}
var_mounts::Plan::Disabled
} else {
let config_var_mount_spec = state
.install_config
.as_ref()
.and_then(|c| c.var_mount_spec.as_deref());
let spec = fsopts.var_mount_spec.as_deref().or(config_var_mount_spec);
let caller_mounts = !var_mounts::discover(&rootfs.physical_root_path)?.is_empty();
let cannot_record = state.composefs_options.composefs_backend && state.is_uki;
var_mounts::plan(spec, caller_mounts, cannot_record, || {
rootfs.device_info.find_colocated_var()
})?
};
if let var_mounts::Plan::Mount(source) = &var_plan {
rootfs.var = Some(var_mounts::mount(
&rootfs.physical_root_path,
&rootfs.physical_root,
source,
)?);
}
let var_mounts = match var_plan {
var_mounts::Plan::Disabled => Vec::new(),
_ => var_mounts::discover(&rootfs.physical_root_path)?,
};
install_to_filesystem_impl(&state, &mut rootfs, cleanup, &var_mounts).await?;

// Drop all data about the root except the path to ensure any file descriptors etc. are closed.
drop(rootfs);
Expand Down Expand Up @@ -2878,6 +2997,7 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) ->
root_path: opts.root_path,
root_mount_spec: None,
boot_mount_spec: None,
var_mount_spec: None,
replace: opts.replace,
skip_finalize: true,
acknowledge_destructive: opts.acknowledge_destructive,
Expand Down
1 change: 1 addition & 0 deletions crates/lib/src/install/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,7 @@ pub(crate) fn install_create_rootfs(
target_root_path: None,
rootfs_uuid: Some(root_uuid.to_string()),
boot,
var: None,
kargs,
skip_finalize: false,
})
Expand Down
Loading