diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 4aa032e55..a5c269c4b 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -80,6 +80,13 @@ pub(crate) struct UpgradeOpts { #[clap(long, conflicts_with = "check")] pub(crate) apply: bool, + /// Configure soft reboot behavior. + /// + /// 'required' will fail if soft reboot is not available. + /// 'auto' will use soft reboot if available, otherwise fall back to regular reboot. + #[clap(long = "soft-reboot", conflicts_with = "check")] + pub(crate) soft_reboot: Option, + #[clap(flatten)] pub(crate) progress: ProgressOptions, } @@ -99,6 +106,13 @@ pub(crate) struct SwitchOpts { #[clap(long)] pub(crate) apply: bool, + /// Configure soft reboot behavior. + /// + /// 'required' will fail if soft reboot is not available. + /// 'auto' will use soft reboot if available, otherwise fall back to regular reboot. + #[clap(long = "soft-reboot")] + pub(crate) soft_reboot: Option, + /// The transport; e.g. oci, oci-archive, containers-storage. Defaults to `registry`. #[clap(long, default_value = "registry")] pub(crate) transport: String, @@ -142,6 +156,13 @@ pub(crate) struct RollbackOpts { /// a userspace-only restart. #[clap(long)] pub(crate) apply: bool, + + /// Configure soft reboot behavior. + /// + /// 'required' will fail if soft reboot is not available. + /// 'auto' will use soft reboot if available, otherwise fall back to regular reboot. + #[clap(long = "soft-reboot")] + pub(crate) soft_reboot: Option, } /// Perform an edit operation @@ -167,6 +188,15 @@ pub(crate) enum OutputFormat { Json, } +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +#[clap(rename_all = "lowercase")] +pub(crate) enum SoftRebootMode { + /// Require a soft reboot; fail if not possible + Required, + /// Automatically use soft reboot if possible, otherwise use regular reboot + Auto, +} + /// Perform an status operation #[derive(Debug, Parser, PartialEq, Eq)] pub(crate) struct StatusOpts { @@ -562,7 +592,7 @@ pub(crate) enum Opt { Note on Rollbacks and the `/etc` Directory: When you perform a rollback (e.g., with `bootc rollback`), any - changes made to files in the `/etc` directory won’t carry over + changes made to files in the `/etc` directory won't carry over to the rolled-back deployment. The `/etc` files will revert to their state from that previous deployment instead. @@ -741,6 +771,104 @@ pub(crate) fn require_root(is_container: bool) -> Result<()> { Ok(()) } +/// Check if a deployment has soft reboot capability +fn has_soft_reboot_capability(deployment: Option<&crate::spec::BootEntry>) -> bool { + deployment.map(|d| d.soft_reboot_capable).unwrap_or(false) +} + +/// Prepare a soft reboot for the given deployment +#[context("Preparing soft reboot")] +fn prepare_soft_reboot( + sysroot: &crate::store::Storage, + deployment: &ostree::Deployment, +) -> Result<()> { + let cancellable = ostree::gio::Cancellable::NONE; + sysroot + .sysroot + .deployment_set_soft_reboot(deployment, false, cancellable) + .context("Failed to prepare soft-reboot")?; + Ok(()) +} + +/// Handle soft reboot based on the configured mode +#[context("Handling soft reboot")] +fn handle_soft_reboot( + soft_reboot_mode: Option, + entry: Option<&crate::spec::BootEntry>, + deployment_type: &str, + execute_soft_reboot: F, +) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + let Some(mode) = soft_reboot_mode else { + return Ok(()); + }; + + let can_soft_reboot = has_soft_reboot_capability(entry); + match mode { + SoftRebootMode::Required => { + if can_soft_reboot { + execute_soft_reboot()?; + } else { + anyhow::bail!( + "Soft reboot was required but {} deployment is not soft-reboot capable", + deployment_type + ); + } + } + SoftRebootMode::Auto => { + if can_soft_reboot { + execute_soft_reboot()?; + } + } + } + Ok(()) +} + +/// Handle soft reboot for staged deployments (used by upgrade and switch) +#[context("Handling staged soft reboot")] +fn handle_staged_soft_reboot( + sysroot: &crate::store::Storage, + soft_reboot_mode: Option, + host: &crate::spec::Host, +) -> Result<()> { + handle_soft_reboot( + soft_reboot_mode, + host.status.staged.as_ref(), + "staged", + || soft_reboot_staged(sysroot), + ) +} + +/// Perform a soft reboot for a staged deployment +#[context("Soft reboot staged deployment")] +fn soft_reboot_staged(sysroot: &crate::store::Storage) -> Result<()> { + println!("Staged deployment is soft-reboot capable, preparing for soft-reboot..."); + + let deployments_list = sysroot.deployments(); + let staged_deployment = deployments_list + .iter() + .find(|d| d.is_staged()) + .ok_or_else(|| anyhow::anyhow!("Failed to find staged deployment"))?; + + prepare_soft_reboot(sysroot, staged_deployment)?; + Ok(()) +} + +/// Perform a soft reboot for a rollback deployment +#[context("Soft reboot rollback deployment")] +fn soft_reboot_rollback(sysroot: &crate::store::Storage) -> Result<()> { + println!("Rollback deployment is soft-reboot capable, preparing for soft-reboot..."); + + let deployments_list = sysroot.deployments(); + let target_deployment = deployments_list + .first() + .ok_or_else(|| anyhow::anyhow!("No rollback deployment found!"))?; + + prepare_soft_reboot(sysroot, target_deployment) +} + /// A few process changes that need to be made for writing. /// IMPORTANT: This may end up re-executing the current process, /// so anything that happens before this should be idempotent. @@ -813,6 +941,7 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { let booted_image = host .status .booted + .as_ref() .map(|b| b.query_image(repo)) .transpose()? .flatten(); @@ -859,7 +988,7 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { .unwrap_or_default(); if staged_unchanged { println!("Staged update present, not changed."); - + handle_staged_soft_reboot(sysroot, opts.soft_reboot, &host)?; if opts.apply { crate::reboot::reboot()?; } @@ -881,6 +1010,13 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { if changed { sysroot.update_mtime()?; + if opts.soft_reboot.is_some() { + // At this point we have new staged deployment and the host definition has changed. + // We need the updated host status before we check if we can prepare the soft-reboot. + let updated_host = crate::status::get_status(sysroot, Some(&booted_deployment))?.1; + handle_staged_soft_reboot(sysroot, opts.soft_reboot, &updated_host)?; + } + if opts.apply { crate::reboot::reboot()?; } @@ -956,6 +1092,13 @@ async fn switch(opts: SwitchOpts) -> Result<()> { sysroot.update_mtime()?; + if opts.soft_reboot.is_some() { + // At this point we have staged the deployment and the host definition has changed. + // We need the updated host status before we check if we can prepare the soft-reboot. + let updated_host = crate::status::get_status(sysroot, Some(&booted_deployment))?.1; + handle_staged_soft_reboot(sysroot, opts.soft_reboot, &updated_host)?; + } + if opts.apply { crate::reboot::reboot()?; } @@ -969,6 +1112,18 @@ async fn rollback(opts: RollbackOpts) -> Result<()> { let sysroot = &get_storage().await?; crate::deploy::rollback(sysroot).await?; + if opts.soft_reboot.is_some() { + // Get status of rollback deployment to check soft-reboot capability + let host = crate::status::get_status_require_booted(sysroot)?.2; + + handle_soft_reboot( + opts.soft_reboot, + host.status.rollback.as_ref(), + "rollback", + || soft_reboot_rollback(sysroot), + )?; + } + if opts.apply { crate::reboot::reboot()?; } diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 18d675402..2d8593fde 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -176,6 +176,9 @@ pub struct BootEntry { pub incompatible: bool, /// Whether this entry will be subject to garbage collection pub pinned: bool, + /// This is true if (relative to the booted system) this is a possible target for a soft reboot + #[serde(default)] + pub soft_reboot_capable: bool, /// The container storage backend #[serde(default)] pub store: Option, @@ -517,6 +520,7 @@ mod tests { image: None, cached_update: None, incompatible: false, + soft_reboot_capable: false, pinned: false, store: None, ostree: None, diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index d8cd0669c..8cb03db1f 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -90,6 +90,11 @@ impl From for OstreeImageReference { } } +/// Check if a deployment has soft reboot capability +fn has_soft_reboot_capability(sysroot: &Storage, deployment: &ostree::Deployment) -> bool { + ostree_ext::systemd_has_soft_reboot() && sysroot.deployment_can_soft_reboot(deployment) +} + /// Parse an ostree origin file (a keyfile) and extract the targeted /// container image reference. fn get_image_origin(origin: &glib::KeyFile) -> Result> { @@ -187,11 +192,13 @@ fn boot_entry_from_deployment( (CachedImageStatus::default(), false) }; + let soft_reboot_capable = has_soft_reboot_capability(sysroot, deployment); let store = Some(crate::spec::Store::OstreeContainer); let r = BootEntry { image, cached_update, incompatible, + soft_reboot_capable, store, pinned: deployment.is_pinned(), ostree: Some(crate::spec::BootEntryOstree { @@ -425,6 +432,27 @@ fn render_verbose_ostree_info( Ok(()) } +/// Helper function to render if soft-reboot capable +fn write_soft_reboot( + mut out: impl Write, + entry: &crate::spec::BootEntry, + prefix_len: usize, +) -> Result<()> { + // Show soft-reboot capability + write_row_name(&mut out, "Soft-reboot", prefix_len)?; + writeln!( + out, + "{}", + if entry.soft_reboot_capable { + "yes" + } else { + "no" + } + )?; + + Ok(()) +} + /// Write the data for a container image based status. fn human_render_slot( mut out: impl Write, @@ -507,6 +535,9 @@ fn human_render_slot( } } } + + // Show soft-reboot capability + write_soft_reboot(&mut out, entry, prefix_len)?; } tracing::debug!("pinned={}", entry.pinned); @@ -544,6 +575,9 @@ fn human_render_slot_ostree( if let Some(ostree) = &entry.ostree { render_verbose_ostree_info(&mut out, ostree, slot, prefix_len)?; } + + // Show soft-reboot capability + write_soft_reboot(&mut out, entry, prefix_len)?; } tracing::debug!("pinned={}", entry.pinned); @@ -765,5 +799,6 @@ mod tests { assert!(w.contains("Deploy serial:")); assert!(w.contains("Staged:")); assert!(w.contains("Commit:")); + assert!(w.contains("Soft-reboot:")); } } diff --git a/crates/ostree-ext/Cargo.toml b/crates/ostree-ext/Cargo.toml index 9c9503e63..bf40e2773 100644 --- a/crates/ostree-ext/Cargo.toml +++ b/crates/ostree-ext/Cargo.toml @@ -50,7 +50,7 @@ io-lifetimes = "3" libsystemd = "0.7.0" ocidir = "0.4.0" # We re-export this library too. -ostree = { features = ["v2025_2"], version = "0.20" } +ostree = { features = ["v2025_3"], version = "0.20.3" } pin-project = "1.0" tar = "0.4.43" tokio-stream = { features = ["sync"], version = "0.1.8" } diff --git a/crates/ostree-ext/src/lib.rs b/crates/ostree-ext/src/lib.rs index 7889a0220..40cd2d084 100644 --- a/crates/ostree-ext/src/lib.rs +++ b/crates/ostree-ext/src/lib.rs @@ -78,3 +78,12 @@ pub mod prelude { pub mod fixture; #[cfg(feature = "internal-testing-api")] pub mod integrationtest; + +/// Check if the system has the soft reboot target, which signals +/// systemd support for soft reboots. +pub fn systemd_has_soft_reboot() -> bool { + const UNIT: &str = "/usr/lib/systemd/system/soft-reboot.target"; + use std::sync::OnceLock; + static EXISTS: OnceLock = OnceLock::new(); + *EXISTS.get_or_init(|| std::path::Path::new(UNIT).exists()) +} diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 543d068be..a02912c79 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -62,3 +62,11 @@ execute: test: - /tmt/tests/bootc-install-provision - /tmt/tests/test-24-local-upgrade-reboot + +/test-25-soft-reboot: + summary: Soft reboot support + discover: + how: fmf + test: + - /tmt/tests/bootc-install-provision + - /tmt/tests/test-25-soft-reboot diff --git a/tmt/tests/booted/test-soft-reboot.nu b/tmt/tests/booted/test-soft-reboot.nu new file mode 100644 index 000000000..ee372149f --- /dev/null +++ b/tmt/tests/booted/test-soft-reboot.nu @@ -0,0 +1,85 @@ +# Verify that soft reboot works (on by default) +use std assert +use tap.nu + +let soft_reboot_capable = "/usr/lib/systemd/system/soft-reboot.target" | path exists +if not $soft_reboot_capable { + echo "Skipping, system is not soft reboot capable" + return +} + +# This code runs on *each* boot. +# Here we just capture information. +bootc status + +# Run on the first boot +def initial_build [] { + tap begin "local image push + pull + upgrade" + + let td = mktemp -d + cd $td + + bootc image copy-to-storage + + # A simple derived container that adds a file, but also injects some kargs + "FROM localhost/bootc +RUN echo test content > /usr/share/testfile-for-soft-reboot.txt +" | save Dockerfile + # Build it + podman build -t localhost/bootc-derived . + + assert (not ("/run/nextroot" | path exists)) + + bootc switch --soft-reboot=auto --transport containers-storage localhost/bootc-derived + let st = bootc status --json | from json + assert $st.status.staged.softRebootCapable + + assert ("/run/nextroot" | path exists) + + #Let's reset the soft-reboot as we still can't correctly soft-reboot with tmt + ostree admin prepare-soft-reboot --reset + # https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + tmt-reboot +} + +# The second boot; verify we're in the derived image +def second_boot [] { + assert ("/usr/share/testfile-for-soft-reboot.txt" | path exists) + #tmt-reboot seems not to be using systemd soft-reboot + # and tmt-reboot -c "systemctl soft-reboot" is not connecting back + # let's comment this check. + #assert equal (systemctl show -P SoftRebootsCount) "1" + + # A new derived with new kargs which should stop the soft reboot. + "FROM localhost/bootc +RUN echo test content > /usr/share/testfile-for-soft-reboot.txt +RUN echo 'kargs = ["foo1=bar2"]' | tee /usr/lib/bootc/kargs.d/00-foo1bar2.toml > /dev/null +" | save Dockerfile + # Build it + podman build -t localhost/bootc-derived . + + bootc upgrade --soft-reboot=auto + let st = bootc status --json | from json + # Should not be soft-reboot capable because of kargs diff + assert (not $st.status.staged.softRebootCapable) + + # And reboot into it + tmt-reboot +} + +# The third boot; verify we're in the derived image +def third_boot [] { + assert ("/usr/lib/bootc/kargs.d/00-foo1bar2.toml" | path exists) + + assert equal (systemctl show -P SoftRebootsCount) "0" +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + "2" => third_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/test-25-soft-reboot.fmf b/tmt/tests/test-25-soft-reboot.fmf new file mode 100644 index 000000000..1ed98d43f --- /dev/null +++ b/tmt/tests/test-25-soft-reboot.fmf @@ -0,0 +1,3 @@ +summary: Execute soft reboot test +test: nu booted/test-soft-reboot.nu +duration: 30m