diff --git a/lib/src/cli.rs b/lib/src/cli.rs index e4900a173..622c5c629 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -25,7 +25,7 @@ use ostree_ext::ostree; use schemars::schema_for; use serde::{Deserialize, Serialize}; -use crate::deploy::RequiredHostSpec; +use crate::deploy::{MergeState, RequiredHostSpec}; use crate::lints; use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::Host; @@ -218,6 +218,12 @@ pub(crate) enum InstallOpts { /// will be wiped, but the content of the existing root will otherwise be retained, and will /// need to be cleaned up if desired when rebooted into the new root. ToExistingRoot(crate::install::InstallToExistingRootOpts), + /// Nondestructively create a fresh installation state inside an existing bootc system. + /// + /// This is a nondestructive variant of `install to-existing-root` that works only inside + /// an existing bootc system. + #[clap(hide = true)] + Reset(crate::install::InstallResetOpts), /// Execute this as the penultimate step of an installation using `install to-filesystem`. /// Finalize { @@ -840,8 +846,9 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { } else if booted_unchanged { println!("No update available.") } else { - let osname = booted_deployment.osname(); - crate::deploy::stage(sysroot, &osname, &fetched, &spec, prog.clone()).await?; + let stateroot = booted_deployment.osname(); + let from = MergeState::from_stateroot(sysroot, &stateroot)?; + crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone()).await?; changed = true; if let Some(prev) = booted_image.as_ref() { if let Some(fetched_manifest) = fetched.get_manifest(repo)? { @@ -926,7 +933,8 @@ async fn switch(opts: SwitchOpts) -> Result<()> { } let stateroot = booted_deployment.osname(); - crate::deploy::stage(sysroot, &stateroot, &fetched, &new_spec, prog.clone()).await?; + let from = MergeState::from_stateroot(sysroot, &stateroot)?; + crate::deploy::stage(sysroot, from, &fetched, &new_spec, prog.clone()).await?; sysroot.update_mtime()?; @@ -989,7 +997,8 @@ async fn edit(opts: EditOpts) -> Result<()> { // TODO gc old layers here let stateroot = booted_deployment.osname(); - crate::deploy::stage(sysroot, &stateroot, &fetched, &new_spec, prog.clone()).await?; + let from = MergeState::from_stateroot(sysroot, &stateroot)?; + crate::deploy::stage(sysroot, from, &fetched, &new_spec, prog.clone()).await?; sysroot.update_mtime()?; @@ -1175,6 +1184,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { InstallOpts::ToExistingRoot(opts) => { crate::install::install_to_existing_root(opts).await } + InstallOpts::Reset(opts) => crate::install::install_reset(opts).await, InstallOpts::PrintConfiguration => crate::install::print_configuration(), InstallOpts::EnsureCompletion {} => { let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; diff --git a/lib/src/deploy.rs b/lib/src/deploy.rs index 39ab21f94..4e96a82f7 100644 --- a/lib/src/deploy.rs +++ b/lib/src/deploy.rs @@ -538,8 +538,7 @@ pub(crate) fn get_base_commit(repo: &ostree::Repo, commit: &str) -> Result, - stateroot: &str, + from: MergeState, image: &ImageState, origin: &glib::KeyFile, ) -> Result { @@ -547,16 +546,18 @@ async fn deploy( // a merge deployment. The kargs code also always looks at the booted root (which // is a distinct minor issue, but not super important as right now the install path // doesn't use this API). - let override_kargs = if let Some(deployment) = merge_deployment { - Some(crate::kargs::get_kargs(sysroot, &deployment, image)?) - } else { - None + let (stateroot, override_kargs) = match &from { + MergeState::MergeDeployment(deployment) => { + let kargs = crate::kargs::get_kargs(sysroot, &deployment, image)?; + (deployment.stateroot().into(), kargs) + } + MergeState::Reset { stateroot, kargs } => (stateroot.clone(), kargs.clone()), }; // Clone all the things to move to worker thread let sysroot_clone = sysroot.sysroot.clone(); // ostree::Deployment is incorrectly !Send 😢 so convert it to an integer + let merge_deployment = from.as_merge_deployment(); let merge_deployment = merge_deployment.map(|d| d.index() as usize); - let stateroot = stateroot.to_string(); let ostree_commit = image.ostree_commit.to_string(); // GKeyFile also isn't Send! So we serialize that as a string... let origin_data = origin.to_data(); @@ -570,11 +571,10 @@ async fn deploy( // Because the C API expects a Vec<&str>, we need to generate a new Vec<> // that borrows. let override_kargs = override_kargs - .as_deref() - .map(|v| v.iter().map(|s| s.as_str()).collect::>()); - if let Some(kargs) = override_kargs.as_deref() { - opts.override_kernel_argv = Some(&kargs); - } + .iter() + .map(|s| s.as_str()) + .collect::>(); + opts.override_kernel_argv = Some(&override_kargs); let deployments = sysroot.deployments(); let merge_deployment = merge_deployment.map(|m| &deployments[m]); let origin = glib::KeyFile::new(); @@ -609,11 +609,41 @@ fn origin_from_imageref(imgref: &ImageReference) -> Result { Ok(origin) } +/// The source of data for staging a new deployment +#[derive(Debug)] +pub(crate) enum MergeState { + /// Use the provided merge deployment + MergeDeployment(Deployment), + /// Don't use a merge deployment, but only this + /// provided initial state. + Reset { + stateroot: String, + kargs: Vec, + }, +} +impl MergeState { + /// Initialize using the default merge deployment for the given stateroot. + pub(crate) fn from_stateroot(sysroot: &Storage, stateroot: &str) -> Result { + let merge_deployment = sysroot.merge_deployment(Some(stateroot)).ok_or_else(|| { + anyhow::anyhow!("No merge deployment found for stateroot {stateroot}") + })?; + Ok(Self::MergeDeployment(merge_deployment)) + } + + /// Cast this to a merge deployment case. + pub(crate) fn as_merge_deployment(&self) -> Option<&Deployment> { + match self { + Self::MergeDeployment(d) => Some(d), + Self::Reset { .. } => None, + } + } +} + /// Stage (queue deployment of) a fetched container image. #[context("Staging")] pub(crate) async fn stage( sysroot: &Storage, - stateroot: &str, + from: MergeState, image: &ImageState, spec: &RequiredHostSpec<'_>, prog: ProgressWriter, @@ -639,7 +669,6 @@ pub(crate) async fn stage( .collect(), }) .await; - let merge_deployment = sysroot.merge_deployment(Some(stateroot)); subtask.completed = true; subtasks.push(subtask.clone()); @@ -662,14 +691,7 @@ pub(crate) async fn stage( }) .await; let origin = origin_from_imageref(spec.image)?; - let deployment = crate::deploy::deploy( - sysroot, - merge_deployment.as_ref(), - stateroot, - image, - &origin, - ) - .await?; + let deployment = crate::deploy::deploy(sysroot, from, image, &origin).await?; subtask.completed = true; subtasks.push(subtask.clone()); diff --git a/lib/src/install.rs b/lib/src/install.rs index 46753e429..d4ece9226 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -43,7 +43,7 @@ use ostree_ext::oci_spec; use ostree_ext::ostree; use ostree_ext::ostree_prepareroot::{ComposefsState, Tristate}; use ostree_ext::prelude::Cast; -use ostree_ext::sysroot::SysrootLock; +use ostree_ext::sysroot::{allocate_new_stateroot, list_stateroots, SysrootLock}; use ostree_ext::{container as ostree_container, ostree_prepareroot}; #[cfg(feature = "install-to-disk")] use rustix::fs::FileTypeExt; @@ -54,7 +54,9 @@ use serde::{Deserialize, Serialize}; use self::baseline::InstallBlockDeviceOpts; use crate::boundimage::{BoundImage, ResolvedBoundImage}; use crate::containerenv::ContainerExecutionInfo; -use crate::deploy::{prepare_for_pull, pull_from_prepared, PreparedImportMeta, PreparedPullResult}; +use crate::deploy::{ + prepare_for_pull, pull_from_prepared, MergeState, PreparedImportMeta, PreparedPullResult, +}; use crate::lsm; use crate::progress_jsonl::ProgressWriter; use crate::spec::ImageReference; @@ -350,6 +352,50 @@ pub(crate) struct InstallToExistingRootOpts { pub(crate) root_path: Utf8PathBuf, } +#[derive(Debug, clap::Parser, PartialEq, Eq)] +pub(crate) struct InstallResetOpts { + /// Acknowledge that this command is experimental. + #[clap(long)] + pub(crate) experimental: bool, + + #[clap(flatten)] + pub(crate) source_opts: InstallSourceOpts, + + #[clap(flatten)] + pub(crate) target_opts: InstallTargetOpts, + + /// Name of the target stateroot. If not provided, one will be automatically + /// generated of the form s- where starts at zero and + /// increments automatically. + #[clap(long)] + pub(crate) stateroot: Option, + + /// Don't display progress + #[clap(long)] + pub(crate) quiet: bool, + + #[clap(flatten)] + pub(crate) progress: crate::cli::ProgressOptions, + + /// Restart or reboot into the new target image. + /// + /// Currently, this option always reboots. In the future this command + /// will detect the case where no kernel changes are queued, and perform + /// a userspace-only restart. + #[clap(long)] + pub(crate) apply: bool, + + /// Skip inheriting any automatically discovered root file system kernel arguments. + #[clap(long)] + no_root_kargs: bool, + + /// Add a kernel argument. This option can be provided multiple times. + /// + /// Example: --karg=nosmt --karg=console=ttyS0,114800n8 + #[clap(long)] + karg: Option>, +} + /// Global state captured from the container. #[derive(Debug, Clone)] pub(crate) struct SourceInfo { @@ -383,6 +429,24 @@ pub(crate) struct State { pub(crate) tempdir: TempDir, } +impl InstallTargetOpts { + pub(crate) fn imageref(&self) -> Result> { + let Some(target_imgname) = self.target_imgref.as_deref() else { + return Ok(None); + }; + let target_transport = + ostree_container::Transport::try_from(self.target_transport.as_str())?; + let target_imgref = ostree_container::OstreeImageReference { + sigverify: ostree_container::SignatureSource::ContainerPolicyAllowInsecure, + imgref: ostree_container::ImageReference { + transport: target_transport, + name: target_imgname.to_string(), + }, + }; + Ok(Some(target_imgref)) + } +} + impl State { #[context("Loading SELinux policy")] pub(crate) fn load_policy(&self) -> Result> { @@ -1964,6 +2028,94 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> install_to_filesystem(opts, true, cleanup).await } +pub(crate) async fn install_reset(opts: InstallResetOpts) -> Result<()> { + let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; + if !opts.experimental { + anyhow::bail!("This command requires --experimental"); + } + + let prog: ProgressWriter = opts.progress.try_into()?; + + let sysroot = &crate::cli::get_storage().await?; + let repo = &sysroot.repo(); + let (booted_deployment, _deployments, host) = + crate::status::get_status_require_booted(sysroot)?; + + let stateroots = list_stateroots(sysroot)?; + dbg!(&stateroots); + let target_stateroot = if let Some(s) = opts.stateroot { + s + } else { + let now = chrono::Utc::now(); + let r = allocate_new_stateroot(&sysroot, &stateroots, now)?; + r.name + }; + + let booted_stateroot = booted_deployment.osname(); + assert!(booted_stateroot.as_str() != target_stateroot); + let (fetched, spec) = if let Some(target) = opts.target_opts.imageref()? { + let mut new_spec = host.spec; + new_spec.image = Some(target.into()); + let fetched = crate::deploy::pull( + repo, + &new_spec.image.as_ref().unwrap(), + None, + opts.quiet, + prog.clone(), + ) + .await?; + (fetched, new_spec) + } else { + let imgstate = host + .status + .booted + .map(|b| b.query_image(repo)) + .transpose()? + .flatten() + .ok_or_else(|| anyhow::anyhow!("No image source specified"))?; + (Box::new((*imgstate).into()), host.spec) + }; + let spec = crate::deploy::RequiredHostSpec::from_spec(&spec)?; + + // Compute the kernel arguments to inherit. By default, that's only those involved + // in the root filesystem. + let root_kargs = if opts.no_root_kargs { + Vec::new() + } else { + let bootcfg = booted_deployment + .bootconfig() + .ok_or_else(|| anyhow!("Missing bootcfg for booted deployment"))?; + if let Some(options) = bootcfg.get("options") { + let options = options.split_ascii_whitespace().collect::>(); + crate::kernel::root_args_from_cmdline(&options) + .into_iter() + .map(ToOwned::to_owned) + .collect::>() + } else { + Vec::new() + } + }; + + let kargs = crate::kargs::get_kargs_in_root(rootfs, std::env::consts::ARCH)? + .into_iter() + .chain(root_kargs.into_iter()) + .chain(opts.karg.unwrap_or_default()) + .collect::>(); + + let from = MergeState::Reset { + stateroot: target_stateroot, + kargs, + }; + crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone()).await?; + + sysroot.update_mtime()?; + + if opts.apply { + crate::reboot::reboot()?; + } + Ok(()) +} + /// Implementation of `bootc install finalize`. pub(crate) async fn install_finalize(target: &Utf8Path) -> Result<()> { crate::cli::require_root(false)?; diff --git a/lib/src/kernel.rs b/lib/src/kernel.rs index 72c34a07c..ebd0970d0 100644 --- a/lib/src/kernel.rs +++ b/lib/src/kernel.rs @@ -1,6 +1,8 @@ use anyhow::Result; use fn_error_context::context; +/// The default root filesystem mount specification. +pub(crate) const ROOT: &str = "root="; /// This is used by dracut. pub(crate) const INITRD_ARG_PREFIX: &str = "rd."; /// The kernel argument for configuring the rootfs flags. @@ -37,6 +39,19 @@ pub(crate) fn find_first_cmdline_arg<'a>( .next() } +/// Find the subset of kernel argumetns which describe how to mount the root filesystem. +pub(crate) fn root_args_from_cmdline<'a>(cmdline: &'a [&str]) -> Vec<&'a str> { + cmdline + .iter() + .filter(|arg| { + arg.starts_with(ROOT) + || arg.starts_with(ROOTFLAGS) + || arg.starts_with(INITRD_ARG_PREFIX) + }) + .copied() + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/ostree-ext/src/container/deploy.rs b/ostree-ext/src/container/deploy.rs index 90001581b..59f4b34b3 100644 --- a/ostree-ext/src/container/deploy.rs +++ b/ostree-ext/src/container/deploy.rs @@ -1,8 +1,6 @@ //! Perform initial setup for a container image based system root use std::collections::HashSet; -#[cfg(feature = "bootc")] -use std::os::fd::BorrowedFd; use anyhow::Result; use fn_error_context::context; @@ -53,13 +51,6 @@ pub struct DeployOpts<'a> { pub no_clean: bool, } -// Access the file descriptor for a sysroot -#[allow(unsafe_code)] -#[cfg(feature = "bootc")] -pub(crate) fn sysroot_fd(sysroot: &ostree::Sysroot) -> BorrowedFd { - unsafe { BorrowedFd::borrow_raw(sysroot.fd()) } -} - /// Write a container image to an OSTree deployment. /// /// This API is currently intended for only an initial deployment. @@ -148,7 +139,7 @@ pub async fn deploy( use cap_std_ext::cmdext::CapStdExtCommandExt; use ocidir::cap_std::fs::Dir; - let sysroot_dir = &Dir::reopen_dir(&sysroot_fd(sysroot))?; + let sysroot_dir = &Dir::reopen_dir(&crate::sysroot::sysroot_fd(sysroot))?; // Note that the sysroot is provided as `.` but we use cwd_dir to // make the process current working directory the sysroot. diff --git a/ostree-ext/src/sysroot.rs b/ostree-ext/src/sysroot.rs index a4f971101..17c390286 100644 --- a/ostree-ext/src/sysroot.rs +++ b/ostree-ext/src/sysroot.rs @@ -1,8 +1,14 @@ //! Helpers for interacting with sysroots. -use std::ops::Deref; +use std::{ops::Deref, os::fd::BorrowedFd, time::SystemTime}; use anyhow::Result; +use chrono::Datelike as _; +use ocidir::cap_std::fs_utf8::Dir; +use ostree::gio; + +/// We may automatically allocate stateroots, this string is the prefix. +const AUTO_STATEROOT_PREFIX: &str = "state-"; /// A locked system root. #[derive(Debug)] @@ -30,6 +36,116 @@ impl Deref for SysrootLock { } } +/// Access the file descriptor for a sysroot +#[allow(unsafe_code)] +pub fn sysroot_fd(sysroot: &ostree::Sysroot) -> BorrowedFd { + unsafe { BorrowedFd::borrow_raw(sysroot.fd()) } +} + +/// A stateroot can match our auto "state-" prefix, or be manual. +#[derive(Debug, PartialEq, Eq)] +pub enum StaterootKind { + /// This stateroot has an automatic name + Auto((u64, u64)), + /// This stateroot is manually named + Manual, +} + +/// Metadata about a stateroot. +#[derive(Debug, PartialEq, Eq)] +pub struct Stateroot { + /// The name + pub name: String, + /// Kind + pub kind: StaterootKind, + /// Creation timestamp (from the filesystem) + pub creation: SystemTime, +} + +impl StaterootKind { + fn new(name: &str) -> Self { + if let Some(v) = parse_auto_stateroot_name(name) { + return Self::Auto(v); + } + Self::Manual + } +} + +/// Load metadata for a stateroot +fn read_stateroot(sysroot_dir: &Dir, name: &str) -> Result { + let path = format!("ostree/deploy/{name}"); + let kind = StaterootKind::new(&name); + let creation = sysroot_dir.symlink_metadata(&path)?.created()?.into_std(); + let r = Stateroot { + name: name.to_owned(), + kind, + creation, + }; + Ok(r) +} + +/// Enumerate stateroots, which are basically the default place for `/var`. +pub fn list_stateroots(sysroot: &ostree::Sysroot) -> Result> { + let sysroot_dir = &Dir::reopen_dir(&sysroot_fd(sysroot))?; + let r = sysroot_dir + .read_dir("ostree/deploy")? + .try_fold(Vec::new(), |mut acc, v| { + let v = v?; + let name = v.file_name()?; + if sysroot_dir.try_exists(format!("ostree/deploy/{name}/deploy"))? { + acc.push(read_stateroot(sysroot_dir, &name)?); + } + anyhow::Ok(acc) + })?; + Ok(r) +} + +/// Given a string, if it matches the form of an automatic state root, parse it into its . pair. +fn parse_auto_stateroot_name(name: &str) -> Option<(u64, u64)> { + let Some(statename) = name.strip_prefix(AUTO_STATEROOT_PREFIX) else { + return None; + }; + let Some((year, serial)) = statename.split_once("-") else { + return None; + }; + let Ok(year) = year.parse::() else { + return None; + }; + let Ok(serial) = serial.parse::() else { + return None; + }; + Some((year, serial)) +} + +/// Given a set of stateroots, allocate a new one +pub fn allocate_new_stateroot( + sysroot: &ostree::Sysroot, + stateroots: &[Stateroot], + now: chrono::DateTime, +) -> Result { + let sysroot_dir = &Dir::reopen_dir(&sysroot_fd(sysroot))?; + + let current_year = now.year().try_into().unwrap_or_default(); + let (year, serial) = stateroots + .iter() + .filter_map(|v| { + if let StaterootKind::Auto(v) = v.kind { + Some(v) + } else { + None + } + }) + .max() + .map(|(year, serial)| (year, serial + 1)) + .unwrap_or((current_year, 0)); + + let name = format!("state-{year}-{serial}"); + + sysroot.init_osname(&name, gio::Cancellable::NONE)?; + + read_stateroot(sysroot_dir, &name) +} + impl SysrootLock { /// Asynchronously acquire a sysroot lock. If the lock cannot be acquired /// immediately, a status message will be printed to standard output. @@ -60,3 +176,121 @@ impl SysrootLock { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_auto_stateroot_name_valid() { + let test_cases = [ + // Basic valid cases + ("state-2024-0", Some((2024, 0))), + ("state-2024-1", Some((2024, 1))), + ("state-2023-123", Some((2023, 123))), + // Large numbers + ( + "state-18446744073709551615-18446744073709551615", + Some((18446744073709551615, 18446744073709551615)), + ), + // Zero values + ("state-0-0", Some((0, 0))), + ("state-0-123", Some((0, 123))), + // Leading zeros (should work - u64::parse handles them) + ("state-0002024-001", Some((2024, 1))), + ("state-000-000", Some((0, 0))), + ]; + + for (input, expected) in test_cases { + assert_eq!( + parse_auto_stateroot_name(input), + expected, + "Failed for input: {}", + input + ); + } + } + + #[test] + fn test_parse_auto_stateroot_name_invalid() { + let test_cases = [ + // Missing prefix + "2024-1", + // Wrong prefix + "stat-2024-1", + "states-2024-1", + "prefix-2024-1", + // Empty string + "", + // Only prefix + "state-", + // Missing separator + "state-20241", + // Wrong separator + "state-2024.1", + "state-2024_1", + "state-2024:1", + // Multiple separators + "state-2024-1-2", + // Missing year or serial + "state--1", + "state-2024-", + // Non-numeric year + "state-abc-1", + "state-2024a-1", + // Non-numeric serial + "state-2024-abc", + "state-2024-1a", + // Both non-numeric + "state-abc-def", + // Negative numbers (handled by parse::() failure) + "state--2024-1", + "state-2024--1", + // Floating point numbers + "state-2024.5-1", + "state-2024-1.5", + // Numbers with whitespace + "state- 2024-1", + "state-2024- 1", + "state-2024 -1", + "state-2024- 1 ", + // Case sensitivity (should fail - prefix is lowercase) + "State-2024-1", + "STATE-2024-1", + // Unicode characters + "state-2024-1🦀", + "state-2024🦀-1", + // Hex-like strings (should fail - not decimal) + "state-0x2024-1", + "state-2024-0x1", + ]; + + for input in test_cases { + assert_eq!( + parse_auto_stateroot_name(input), + None, + "Expected None for input: {}", + input + ); + } + } + + #[test] + fn test_stateroot_kind_new() { + let test_cases = [ + ("state-2024-1", StaterootKind::Auto((2024, 1))), + ("manual-name", StaterootKind::Manual), + ("state-invalid", StaterootKind::Manual), + ("", StaterootKind::Manual), + ]; + + for (input, expected) in test_cases { + assert_eq!( + StaterootKind::new(input), + expected, + "Failed for input: {}", + input + ); + } + } +}