From dee213c4a584dca635ceb5b35dec4f47568c1c8c Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Mon, 12 Jan 2026 10:51:59 +0530 Subject: [PATCH 1/6] composefs: Add option to reset soft reboot state Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/soft_reboot.rs | 47 ++++++++++++++++++- crates/lib/src/bootc_composefs/update.rs | 2 +- crates/lib/src/cli.rs | 24 ++++++++-- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 3e4509da1..3e0bcf8c2 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -10,25 +10,70 @@ use bootc_initramfs_setup::setup_root; use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::{PID1, bind_mount_from_pidns}; use camino::Utf8Path; +use cap_std_ext::cap_std::ambient_authority; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; use ostree_ext::systemd_has_soft_reboot; +use rustix::mount::{unmount, UnmountFlags}; use std::{fs::create_dir_all, os::unix::process::CommandExt, path::PathBuf, process::Command}; const NEXTROOT: &str = "/run/nextroot"; +#[context("Resetting soft reboot state")] +fn reset_soft_reboot() -> Result<()> { + let run = Utf8Path::new("/run"); + bind_mount_from_pidns(PID1, &run, &run, true).context("Bind mounting /run")?; + + let run_dir = Dir::open_ambient_dir("/run", ambient_authority()).context("Opening run")?; + + let nextroot = run_dir + .open_dir_optional("nextroot") + .context("Opening nextroot")?; + + let Some(nextroot) = nextroot else { + tracing::debug!("Nextroot is not a directory"); + println!("No deployment staged for soft rebooting"); + return Ok(()); + }; + + let nextroot_mounted = nextroot + .is_mountpoint(".")? + .ok_or_else(|| anyhow::anyhow!("Failed to get mount info"))?; + + if !nextroot_mounted { + tracing::debug!("Nextroot is not a mountpoint"); + println!("No deployment staged for soft rebooting"); + return Ok(()); + } + + unmount(NEXTROOT, UnmountFlags::DETACH).context("Unmounting nextroot")?; + + println!("Soft reboot state cleared successfully"); + + Ok(()) +} + /// Checks if the provided deployment is soft reboot capable, and soft reboots the system if /// argument `reboot` is true #[context("Soft rebooting")] pub(crate) async fn prepare_soft_reboot_composefs( storage: &Storage, booted_cfs: &BootedComposefs, - deployment_id: &String, + deployment_id: Option<&String>, reboot: bool, + reset: bool, ) -> Result<()> { if !systemd_has_soft_reboot() { anyhow::bail!("System does not support soft reboots") } + if reset { + return reset_soft_reboot(); + } + + let deployment_id = deployment_id.ok_or_else(|| anyhow::anyhow!("Expected deployment id"))?; + if *deployment_id == *booted_cfs.cmdline.digest { anyhow::bail!("Cannot soft-reboot to currently booted deployment"); } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index f6252a65f..206bb7335 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -267,7 +267,7 @@ pub(crate) async fn do_upgrade( } if opts.soft_reboot.is_some() { - prepare_soft_reboot_composefs(storage, booted_cfs, &id.to_hex(), true).await?; + prepare_soft_reboot_composefs(storage, booted_cfs, Some(&id.to_hex()), true, false).await?; } Ok(()) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index b4887f619..ae8bd3185 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -618,8 +618,12 @@ pub(crate) enum InternalsOpts { /// Dump CLI structure as JSON for documentation generation DumpCliJson, PrepSoftReboot { - deployment: String, - #[clap(long)] + #[clap(required_unless_present = "reset")] + deployment: Option, + #[clap(long, conflicts_with = "reset")] + reboot: bool, + #[clap(long, conflicts_with = "reboot")] + reset: bool, reboot: bool, }, } @@ -1834,7 +1838,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> { Ok(()) } - InternalsOpts::PrepSoftReboot { deployment, reboot } => { + InternalsOpts::PrepSoftReboot { + deployment, + reboot, + reset, + } => { let storage = &get_storage().await?; match storage.kind()? { @@ -1843,8 +1851,14 @@ async fn run_from_opt(opt: Opt) -> Result<()> { anyhow::bail!("soft-reboot only implemented for composefs") } BootedStorageKind::Composefs(booted_cfs) => { - prepare_soft_reboot_composefs(&storage, &booted_cfs, &deployment, reboot) - .await + prepare_soft_reboot_composefs( + &storage, + &booted_cfs, + deployment.as_ref(), + reboot, + reset, + ) + .await } } } From 1dbc38716f39bc7d4b0e54fe26c7b949b0d190d1 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 15 Jan 2026 11:36:49 +0530 Subject: [PATCH 2/6] composefs: Don't soft-reboot automatically Aligning with ostree API, now we only initiate soft-reboot if `--apply` is passed to `bootc update`, `bootc switch`, else we only prepare the soft reboot Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/soft_reboot.rs | 17 ++++++++++------- crates/lib/src/bootc_composefs/update.rs | 15 +++++++++++---- crates/lib/src/cli.rs | 10 +++++++--- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 3e0bcf8c2..308913f3b 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -2,6 +2,7 @@ use crate::{ bootc_composefs::{ service::start_finalize_stated_svc, status::composefs_deployment_status_from, }, + cli::SoftRebootMode, composefs_consts::COMPOSEFS_CMDLINE, store::{BootedComposefs, Storage}, }; @@ -21,7 +22,7 @@ use std::{fs::create_dir_all, os::unix::process::CommandExt, path::PathBuf, proc const NEXTROOT: &str = "/run/nextroot"; #[context("Resetting soft reboot state")] -fn reset_soft_reboot() -> Result<()> { +pub(crate) fn reset_soft_reboot() -> Result<()> { let run = Utf8Path::new("/run"); bind_mount_from_pidns(PID1, &run, &run, true).context("Bind mounting /run")?; @@ -61,17 +62,13 @@ pub(crate) async fn prepare_soft_reboot_composefs( storage: &Storage, booted_cfs: &BootedComposefs, deployment_id: Option<&String>, + soft_reboot_mode: SoftRebootMode, reboot: bool, - reset: bool, ) -> Result<()> { if !systemd_has_soft_reboot() { anyhow::bail!("System does not support soft reboots") } - if reset { - return reset_soft_reboot(); - } - let deployment_id = deployment_id.ok_or_else(|| anyhow::anyhow!("Expected deployment id"))?; if *deployment_id == *booted_cfs.cmdline.digest { @@ -89,7 +86,13 @@ pub(crate) async fn prepare_soft_reboot_composefs( .ok_or_else(|| anyhow::anyhow!("Deployment '{deployment_id}' not found"))?; if !requred_deployment.soft_reboot_capable { - anyhow::bail!("Cannot soft-reboot to deployment with a different kernel state"); + match soft_reboot_mode { + SoftRebootMode::Required => { + anyhow::bail!("Cannot soft-reboot to deployment with a different kernel state") + } + + SoftRebootMode::Auto => return Ok(()), + } } start_finalize_stated_svc()?; diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 206bb7335..667105cf5 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -262,14 +262,21 @@ pub(crate) async fn do_upgrade( ) .await?; + if let Some(soft_reboot_mode) = opts.soft_reboot { + return prepare_soft_reboot_composefs( + storage, + booted_cfs, + Some(&id.to_hex()), + soft_reboot_mode, + opts.apply, + ) + .await; + }; + if opts.apply { return crate::reboot::reboot(); } - if opts.soft_reboot.is_some() { - prepare_soft_reboot_composefs(storage, booted_cfs, Some(&id.to_hex()), true, false).await?; - } - Ok(()) } diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index ae8bd3185..5d26ef4a9 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -34,7 +34,7 @@ use schemars::schema_for; use serde::{Deserialize, Serialize}; use crate::bootc_composefs::delete::delete_composefs_deployment; -use crate::bootc_composefs::soft_reboot::prepare_soft_reboot_composefs; +use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::{ digest::{compute_composefs_digest, new_temp_composefs_repo}, finalize::{composefs_backend_finalize, get_etc_diff}, @@ -624,7 +624,6 @@ pub(crate) enum InternalsOpts { reboot: bool, #[clap(long, conflicts_with = "reboot")] reset: bool, - reboot: bool, }, } @@ -1850,13 +1849,18 @@ async fn run_from_opt(opt: Opt) -> Result<()> { // TODO: Call ostree implementation? anyhow::bail!("soft-reboot only implemented for composefs") } + BootedStorageKind::Composefs(booted_cfs) => { + if reset { + return reset_soft_reboot(); + } + prepare_soft_reboot_composefs( &storage, &booted_cfs, deployment.as_ref(), + SoftRebootMode::Required, reboot, - reset, ) .await } From d79a7c2523d9297cadae8a35e58c33782f480542 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 15 Jan 2026 12:44:38 +0530 Subject: [PATCH 3/6] wip Signed-off-by: Pragyan Poudyal --- .github/workflows/ci.yml | 25 +++++-- Justfile | 3 +- crates/lib/src/bootc_composefs/status.rs | 6 +- crates/lib/src/store/mod.rs | 2 +- crates/xtask/src/tmt.rs | 12 +++- hack/build-sealed | 2 +- podman-cmd | 65 +++++++++++++++++++ tmt/plans/integration.fmf | 2 +- .../booted/test-image-pushpull-upgrade.nu | 14 ++-- tmt/tests/booted/test-image-upgrade-reboot.nu | 6 ++ tmt/tests/booted/test-soft-reboot.nu | 12 +++- ...-policy.nu => test-soft-selinux-policy.nu} | 0 12 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 podman-cmd rename tmt/tests/booted/{test-soft-reboot-selinux-policy.nu => test-soft-selinux-policy.nu} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0081dfa11..cd695e328 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,14 +152,26 @@ jobs: strategy: fail-fast: false matrix: - # No fedora-44 due to https://bugzilla.redhat.com/show_bug.cgi?id=2429501 - test_os: [fedora-42, fedora-43, centos-9, centos-10] - variant: [ostree, composefs-sealeduki-sdboot] + test_os: [fedora-42] + variant: [ostree] + gating: [true] exclude: # centos-9 UKI is experimental/broken (https://github.com/bootc-dev/bootc/issues/1812) - test_os: centos-9 variant: composefs-sealeduki-sdboot - + - test_os: fedora-44 + gating: true + # include: + # # fedora-44 non-gating due to grub2 regression + # # https://bugzilla.redhat.com/show_bug.cgi?id=2429501 + # - test_os: fedora-44 + # gating: false + # variant: ostree + # - test_os: fedora-44 + # gating: false + # variant: composefs-sealeduki-sdboot + # Non-gating jobs are allowed to fail without blocking the PR + continue-on-error: ${{ !matrix.gating }} runs-on: ubuntu-24.04 steps: @@ -200,11 +212,12 @@ jobs: - name: Run TMT integration tests run: | - if [ "${{ matrix.variant }}" = "composefs-sealeduki-sdboot" ]; then - just test-composefs + if [ "${{ matrix.variant }}" = "composefs" ]; then + just test-tmt else just test-tmt integration fi + just clean-local-images - name: Archive TMT logs diff --git a/Justfile b/Justfile index 09e84c263..f362ce27b 100644 --- a/Justfile +++ b/Justfile @@ -124,7 +124,7 @@ package: # Build+test using the `composefs-sealeduki-sdboot` variant. test-composefs: - just variant=composefs-sealeduki-sdboot test-tmt readonly local-upgrade-reboot + just variant=composefs-sealeduki-sdboot test-tmt readonly soft-reboot # Only used by ci.yml right now build-install-test-image: build @@ -162,6 +162,7 @@ _build-upgrade-image: # Assume the localhost/bootc image is up to date, and just run tests. # Useful for iterating on tests quickly. test-tmt-nobuild *ARGS: + echo {{ARGS}} cargo xtask run-tmt --env=BOOTC_variant={{variant}} --upgrade-image={{upgrade_img}} {{base_img}} {{ARGS}} # Build test container image for testing on coreos with SKIP_CONFIGS=1, diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 386e2470a..a7eac0ffb 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -448,8 +448,10 @@ fn set_reboot_capable_type1_deployments( .chain(host.status.rollback.iter_mut()) .chain(host.status.other_deployments.iter_mut()) { - let entry = find_bls_entry(&depl.require_composefs()?.verity, &bls_entries)? - .ok_or_else(|| anyhow::anyhow!("Entry not found"))?; + let v = &depl.require_composefs()?.verity; + + let entry = find_bls_entry(v, &bls_entries)? + .ok_or_else(|| anyhow::anyhow!("Entry {v} not found"))?; let depl_cmdline = entry.get_cmdline()?; diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index b001e7abb..f38edd4bb 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -272,7 +272,7 @@ pub(crate) struct Storage { pub esp: Option, /// Our runtime state - run: Dir, + pub run: Dir, /// The OSTree storage ostree: OnceCell, diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index 0dd4634aa..a3ab3ef5c 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -29,6 +29,8 @@ const ENV_BOOTC_UPGRADE_IMAGE: &str = "BOOTC_upgrade_image"; // Distro identifiers const DISTRO_CENTOS_9: &str = "centos-9"; +const KARGS: [&str; 2] = ["--karg=enforcing=0", "--karg=console=ttyS0,115000n8"]; + // Import the argument types from xtask.rs use crate::{RunTmtArgs, TmtProvisionArgs}; @@ -280,6 +282,8 @@ fn parse_plan_metadata(plans_file: &Utf8Path) -> Result Result<()> { + println!("RunTmtArgs: {args:#?}"); + // Check dependencies first check_dependencies(sh)?; @@ -437,7 +441,8 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { let firmware_args_slice = firmware_args.as_slice(); let launch_result = cmd!( sh, - "bcvk libvirt run --name {vm_name} --detach {firmware_args_slice...} {COMMON_INST_ARGS...} {plan_bcvk_opts...} {image}" + "bcvk libvirt run --name {vm_name} --detach {firmware_args_slice...} + --filesystem ext4 --composefs-backend {KARGS...} {COMMON_INST_ARGS...} {plan_bcvk_opts...} {image}" ) .run() .context("Launching VM with bcvk"); @@ -686,12 +691,15 @@ pub(crate) fn tmt_provision(sh: &Shell, args: &TmtProvisionArgs) -> Result<()> { let firmware_args = build_firmware_args(sh, image)?; + println!("firmware_args: {firmware_args:#?}"); + // Launch VM with bcvk // Use ds=iid-datasource-none to disable cloud-init for faster boot let firmware_args_slice = firmware_args.as_slice(); + cmd!( sh, - "bcvk libvirt run --name {vm_name} --detach {firmware_args_slice...} {COMMON_INST_ARGS...} {image}" + "bcvk libvirt run --name {vm_name} --filesystem ext4 --composefs-backend {KARGS...} --detach {firmware_args_slice...} {COMMON_INST_ARGS...} {image}" ) .run() .context("Launching VM with bcvk")?; diff --git a/hack/build-sealed b/hack/build-sealed index 22b668312..2b467a631 100755 --- a/hack/build-sealed +++ b/hack/build-sealed @@ -19,7 +19,7 @@ runv() { } case $variant in - ostree) + ostree|composefs) # Nothing to do echo "Not building a sealed image; forwarding tag" runv podman tag $input_image $output_image diff --git a/podman-cmd b/podman-cmd new file mode 100644 index 000000000..277ee0587 --- /dev/null +++ b/podman-cmd @@ -0,0 +1,65 @@ +DEBUG Podman command: + + +podman run +--pull=never +--label=bcvk.ephemeral=1 +--mount=type=tmpfs,target=/run +--rm -t -d +--cap-add=all +--security-opt=label=disable +--security-opt=seccomp=unconfined +--security-opt=unmask=/proc/* +-v /sys:/sys:ro +-v /var/tmp:/var/tmp +--device=/dev/kvm +--device=/dev/vhost-vsock +-v /usr:/run/tmproot/usr:ro +-v /tmp/.tmpebjLzO/entrypoint:/var/lib/bcvk/entrypoint +-v /home/pragyan/.cargo/bin/bcvk:/run/selfexe:ro +--stop-signal=SIGKILL +--mount=type=image,source=localhost/bootc,target=/run/source-image,rw=true +-v /home/pragyan/.local/share/containers/storage:/run/host-mounts/hoststorage:ro +-v /home/pragyan/.local/share/libvirt/images/bootc-base-4b963f65acbfab31.WzUM7g.tmp.qcow2:/run/disk-files/output:rw +--dns 192.168.0.1 +-e BCK_CONFIG={ + "image": "localhost/bootc", + "common": { + "itype": null, + "memory": { + "memory": "4G" + }, + "vcpus": null, + "console": false, + "debug": false, + "virtio_serial_out": [], + "execute": [], + "ssh_keygen": true + }, + "podman": { + "tty": true, + "interactive": false, + "detach": true, + "rm": true, + "name": null, + "network": null, + "label": [], + "env": [] + }, + "debug_entrypoint": null, + "bind_mounts": [], + "ro_bind_mounts": [], + "systemd_units_dir": null, + "bind_storage_ro": true, + "add_swap": "21474836480", + "mount_disk_files": [ + "/home/pragyan/.local/share/libvirt/images/bootc-base-4b963f65acbfab31.WzUM7g.tmp.qcow2:output:qcow2" + ], + "kernel_args": [], + "host_dns_servers": [ + "192.168.0.1" + ] +} +-e BOOTC_DISK_FILES=/run/disk-files/output:output:qcow2 +localhost/bootc +/var/lib/bcvk/entrypoint diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 365e6618c..9c4c3ec4a 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -125,7 +125,7 @@ execute: test: - /tmt/tests/tests/test-28-factory-reset -/plan-29-soft-reboot-selinux-policy: +/plan-29-soft-selinux-policy: summary: Test soft reboot with SELinux policy changes discover: how: fmf diff --git a/tmt/tests/booted/test-image-pushpull-upgrade.nu b/tmt/tests/booted/test-image-pushpull-upgrade.nu index 39f757b56..c793b8eee 100644 --- a/tmt/tests/booted/test-image-pushpull-upgrade.nu +++ b/tmt/tests/booted/test-image-pushpull-upgrade.nu @@ -54,7 +54,11 @@ RUN echo test content > /usr/share/blah.txt let v = podman run --rm localhost/bootc-derived cat /usr/share/blah.txt | str trim assert equal $v "test content" - let orig_root_mtime = ls -Dl /ostree/bootc | get modified + let orig_root_mtime = null; + + if not $is_composefs { + $orig_root_mtime = ls -Dl /ostree/bootc | get modified + } # Now, fetch it back into the bootc storage! # We also test the progress API here @@ -79,9 +83,11 @@ RUN echo test content > /usr/share/blah.txt # Verify that we logged to the journal journalctl _MESSAGE_ID=3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7 - # The mtime should change on modification - let new_root_mtime = ls -Dl /ostree/bootc | get modified - assert ($new_root_mtime > $orig_root_mtime) + if not $is_composefs { + # The mtime should change on modification + let new_root_mtime = ls -Dl /ostree/bootc | get modified + assert ($new_root_mtime > $orig_root_mtime) + } # Test for https://github.com/ostreedev/ostree/issues/3544 # Add a quoted karg using rpm-ostree if available diff --git a/tmt/tests/booted/test-image-upgrade-reboot.nu b/tmt/tests/booted/test-image-upgrade-reboot.nu index 676605658..d20ee12df 100644 --- a/tmt/tests/booted/test-image-upgrade-reboot.nu +++ b/tmt/tests/booted/test-image-upgrade-reboot.nu @@ -38,10 +38,15 @@ def initial_build [] { tap begin "local image push + pull + upgrade" let imgsrc = imgsrc + + print $"USING IMAGE: ($imgsrc)" + # For the packit case, we build locally right now if ($imgsrc | str ends-with "-local") { bootc image copy-to-storage + print $"After copy-to-storage" + # A simple derived container that adds a file "FROM localhost/bootc RUN touch /usr/share/testing-bootc-upgrade-apply @@ -53,6 +58,7 @@ RUN touch /usr/share/testing-bootc-upgrade-apply # Now, switch into the new image print $"Applying ($imgsrc)" bootc switch --transport containers-storage ($imgsrc) + print $"Switch to ($imgsrc) COMPLETE" tmt-reboot } diff --git a/tmt/tests/booted/test-soft-reboot.nu b/tmt/tests/booted/test-soft-reboot.nu index dd3374e13..8a93988d3 100644 --- a/tmt/tests/booted/test-soft-reboot.nu +++ b/tmt/tests/booted/test-soft-reboot.nu @@ -17,6 +17,9 @@ if not $soft_reboot_capable { # Here we just capture information. bootc status +let st = bootc status --json | from json +let is_composefs = ($st.status.booted.composefs? != null) + # Run on the first boot def initial_build [] { tap begin "local image push + pull + upgrade" @@ -41,8 +44,13 @@ RUN echo test content > /usr/share/testfile-for-soft-reboot.txt assert ("/run/nextroot" | path exists) - # See ../bug-soft-reboot.md - TMT cannot handle systemd soft-reboots - ostree admin prepare-soft-reboot --reset + if not $is_composefs { + # See ../bug-soft-reboot.md - TMT cannot handle systemd soft-reboots + ostree admin prepare-soft-reboot --reset + } else { + bootc internals prep-soft-reboot --reset + } + # https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test tmt-reboot } diff --git a/tmt/tests/booted/test-soft-reboot-selinux-policy.nu b/tmt/tests/booted/test-soft-selinux-policy.nu similarity index 100% rename from tmt/tests/booted/test-soft-reboot-selinux-policy.nu rename to tmt/tests/booted/test-soft-selinux-policy.nu From 353bee21c4aecb3b032136c0241470ab6b5fe95f Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 15 Jan 2026 18:20:20 +0530 Subject: [PATCH 4/6] composefs/export: Update image digest query format After bootc/commit/49d753f996747a9b1f531abf35ba4e207cf4f020, composefs-rs saves config in the format `oci-config-sha256:`. Update to match the same Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/export.rs | 5 +++-- crates/lib/src/bootc_composefs/soft_reboot.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index b8abd4b4c..f86392421 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -50,7 +50,8 @@ pub async fn export_repo_to_image( let imginfo = get_imginfo(storage, &depl_verity, None).await?; - let config_digest = imginfo.manifest.config().digest().digest(); + // We want the digest in the form of "sha256:abc123" + let config_digest = format!("{}", imginfo.manifest.config().digest()); let var_tmp = Dir::open_ambient_dir("/var/tmp", ambient_authority()).context("Opening /var/tmp")?; @@ -60,7 +61,7 @@ pub async fn export_repo_to_image( // Use composefs_oci::open_config to get the config and layer map let (config, layer_map) = - open_config(&*booted_cfs.repo, config_digest, None).context("Opening config")?; + open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?; // We can't guarantee that we'll get the same tar stream as the container image // So we create new config and manifest diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index 308913f3b..9ec3d6157 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -16,7 +16,7 @@ use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; use ostree_ext::systemd_has_soft_reboot; -use rustix::mount::{unmount, UnmountFlags}; +use rustix::mount::{UnmountFlags, unmount}; use std::{fs::create_dir_all, os::unix::process::CommandExt, path::PathBuf, process::Command}; const NEXTROOT: &str = "/run/nextroot"; From 67e9b0668184b806f6260b22194957445f1348b0 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 16 Jan 2026 12:08:55 +0530 Subject: [PATCH 5/6] wip2 Signed-off-by: Pragyan Poudyal --- .../booted/test-image-pushpull-upgrade.nu | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/tmt/tests/booted/test-image-pushpull-upgrade.nu b/tmt/tests/booted/test-image-pushpull-upgrade.nu index c793b8eee..dd1a1c0f9 100644 --- a/tmt/tests/booted/test-image-pushpull-upgrade.nu +++ b/tmt/tests/booted/test-image-pushpull-upgrade.nu @@ -54,7 +54,7 @@ RUN echo test content > /usr/share/blah.txt let v = podman run --rm localhost/bootc-derived cat /usr/share/blah.txt | str trim assert equal $v "test content" - let orig_root_mtime = null; + mut orig_root_mtime = null; if not $is_composefs { $orig_root_mtime = ls -Dl /ostree/bootc | get modified @@ -72,26 +72,25 @@ RUN echo test content > /usr/share/blah.txt systemd-run -u test-cat-progress -- /bin/bash -c $"exec cat ($progress_fifo) > ($progress_json)" # nushell doesn't do fd passing right now either, so run via bash bash -c $"bootc switch --progress-fd 3 --transport containers-storage localhost/bootc-derived 3>($progress_fifo)" - # Now, let's do some checking of the progress json - let progress = open --raw $progress_json | from json -o - sanity_check_switch_progress_json $progress - # Check that /run/reboot-required exists and is not empty - let rr_meta = (ls /run/reboot-required | first) - assert ($rr_meta.size > 0b) + if not $is_composefs { + # Now, let's do some checking of the progress json + let progress = open --raw $progress_json | from json -o + sanity_check_switch_progress_json $progress - # Verify that we logged to the journal - journalctl _MESSAGE_ID=3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7 + # Check that /run/reboot-required exists and is not empty + let rr_meta = (ls /run/reboot-required | first) + assert ($rr_meta.size > 0b) + + # Verify that we logged to the journal + journalctl _MESSAGE_ID=3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7 - if not $is_composefs { # The mtime should change on modification let new_root_mtime = ls -Dl /ostree/bootc | get modified assert ($new_root_mtime > $orig_root_mtime) - } - # Test for https://github.com/ostreedev/ostree/issues/3544 - # Add a quoted karg using rpm-ostree if available - if not $is_composefs { + # Test for https://github.com/ostreedev/ostree/issues/3544 + # Add a quoted karg using rpm-ostree if available # Check rpm-ostree and rpm-ostreed service status before run rpm-ostree # And collect info for flaky error "error: System transaction in progress" rpm-ostree status From f0be096e4919885e8acbe0ac718b99cb7a290366 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 16 Jan 2026 16:01:46 +0530 Subject: [PATCH 6/6] composefs/update: Handle --download-only flag When `--download-only` is passed, only download the image into the composefs repository but don't finalize it. Conver the /run/composefs/staged-deployment to a JSON file and Add a finalization_locked field depending upon which the finalize service will either finalize the staged deployment or leave it as is for garbage collection (even though GC is not fully implemented right now). Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 2 +- crates/lib/src/bootc_composefs/finalize.rs | 5 + crates/lib/src/bootc_composefs/state.rs | 20 +++- crates/lib/src/bootc_composefs/status.rs | 17 ++- crates/lib/src/bootc_composefs/switch.rs | 1 + crates/lib/src/bootc_composefs/update.rs | 123 +++++++++++++++------ 6 files changed, 125 insertions(+), 43 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 1a295d071..801a019bd 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1277,7 +1277,7 @@ pub(crate) async fn setup_composefs_boot( &root_setup.physical_root_path, &id, &crate::spec::ImageReference::from(state.target_imgref.clone()), - false, + None, boot_type, boot_digest, &get_container_manifest_and_config(&get_imgref( diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 027ffb5ee..68c302033 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -52,6 +52,11 @@ pub(crate) async fn composefs_backend_finalize( return Ok(()); }; + if staged_depl.download_only { + tracing::debug!("Staged deployment is marked download only. Won't finalize"); + return Ok(()); + } + let staged_composefs = staged_depl.composefs.as_ref().ok_or(anyhow::anyhow!( "Staged deployment is not a composefs deployment" ))?; diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 0dc20f8be..517281be0 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -9,6 +9,7 @@ use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::tempmount::TempMount; use bootc_utils::CommandRunExt; use camino::Utf8PathBuf; +use canon_json::CanonJsonSerialize; use cap_std_ext::cap_std::ambient_authority; use cap_std_ext::cap_std::fs::{Dir, Permissions, PermissionsExt}; use cap_std_ext::dirext::CapStdExtDirExt; @@ -23,7 +24,9 @@ use rustix::{ use crate::bootc_composefs::boot::BootType; use crate::bootc_composefs::repo::get_imgref; -use crate::bootc_composefs::status::{ImgConfigManifest, get_sorted_type1_boot_entries}; +use crate::bootc_composefs::status::{ + ImgConfigManifest, StagedDeployment, get_sorted_type1_boot_entries, +}; use crate::parsers::bls_config::BLSConfigType; use crate::store::{BootedComposefs, Storage}; use crate::{ @@ -227,7 +230,7 @@ pub(crate) async fn write_composefs_state( root_path: &Utf8PathBuf, deployment_id: &Sha512HashValue, target_imgref: &ImageReference, - staged: bool, + staged: Option, boot_type: BootType, boot_digest: String, container_details: &ImgConfigManifest, @@ -248,7 +251,12 @@ pub(crate) async fn write_composefs_state( ) .context("Failed to create symlink for /var")?; - initialize_state(&root_path, &deployment_id.to_hex(), &state_path, !staged)?; + initialize_state( + &root_path, + &deployment_id.to_hex(), + &state_path, + staged.is_none(), + )?; let ImageReference { image: image_name, @@ -291,7 +299,7 @@ pub(crate) async fn write_composefs_state( ) .context("Failed to write to .origin file")?; - if staged { + if let Some(staged) = staged { std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; @@ -302,7 +310,9 @@ pub(crate) async fn write_composefs_state( staged_depl_dir .atomic_write( COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - deployment_id.to_hex().as_bytes(), + staged + .to_canon_json_vec() + .context("Failed to serialize staged deployment JSON")?, ) .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?; } diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index a7eac0ffb..50a277823 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -79,6 +79,12 @@ impl std::fmt::Display for ComposefsCmdline { } } +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct StagedDeployment { + pub(crate) depl_id: String, + pub(crate) finalization_locked: bool, +} + /// Detect if we have composefs= in /proc/cmdline pub(crate) fn composefs_booted() -> Result> { static CACHED_DIGEST_VALUE: OnceLock> = OnceLock::new(); @@ -556,7 +562,7 @@ pub(crate) async fn composefs_deployment_status_from( let mut host = Host::new(host_spec); - let staged_deployment_id = match std::fs::File::open(format!( + let staged_deployment = match std::fs::File::open(format!( "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}" )) { Ok(mut f) => { @@ -592,7 +598,7 @@ pub(crate) async fn composefs_deployment_status_from( let ini = tini::Ini::from_string(&config) .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; - let boot_entry = + let mut boot_entry = boot_entry_from_composefs_deployment(storage, ini, depl_file_name.to_string()).await?; // SAFETY: boot_entry.composefs will always be present @@ -616,8 +622,11 @@ pub(crate) async fn composefs_deployment_status_from( continue; } - if let Some(staged_deployment_id) = &staged_deployment_id { - if depl_file_name == staged_deployment_id.trim() { + if let Some(staged_deployment) = &staged_deployment { + let staged_depl = serde_json::from_str::(&staged_deployment)?; + + if depl_file_name == staged_depl.depl_id { + boot_entry.download_only = staged_depl.finalization_locked; host.status.staged = Some(boot_entry); continue; } diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 4f12b4790..944e166c3 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -45,6 +45,7 @@ pub(crate) async fn switch_composefs( let do_upgrade_opts = DoUpgradeOpts { soft_reboot: opts.soft_reboot, apply: opts.apply, + download_only: false, }; if let Some(cfg_verity) = image { diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 667105cf5..8b74f4c03 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -1,10 +1,14 @@ +use std::io::Write; + use anyhow::{Context, Result}; use camino::Utf8PathBuf; -use cap_std_ext::cap_std::fs::Dir; +use canon_json::CanonJsonSerialize; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::BootOps; use composefs_oci::image::create_filesystem; use fn_error_context::context; +use ocidir::cap_std::ambient_authority; use ostree_ext::container::ManifestDiff; use crate::{ @@ -15,12 +19,15 @@ use crate::{ soft_reboot::prepare_soft_reboot_composefs, state::write_composefs_state, status::{ - ImgConfigManifest, get_bootloader, get_composefs_status, + ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status, get_container_manifest_and_config, get_imginfo, }, }, cli::{SoftRebootMode, UpgradeOpts}, - composefs_consts::{STATE_DIR_RELATIVE, TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED}, + composefs_consts::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE, + TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED, + }, spec::{Bootloader, Host, ImageReference}, store::{BootedComposefs, ComposefsRepository, Storage}, }; @@ -206,6 +213,31 @@ pub(crate) fn validate_update( pub(crate) struct DoUpgradeOpts { pub(crate) apply: bool, pub(crate) soft_reboot: Option, + pub(crate) download_only: bool, +} + +async fn apply_upgrade( + storage: &Storage, + booted_cfs: &BootedComposefs, + depl_id: &String, + opts: &DoUpgradeOpts, +) -> Result<()> { + if let Some(soft_reboot_mode) = opts.soft_reboot { + return prepare_soft_reboot_composefs( + storage, + booted_cfs, + Some(depl_id), + soft_reboot_mode, + opts.apply, + ) + .await; + }; + + if opts.apply { + return crate::reboot::reboot(); + } + + Ok(()) } /// Performs the Update or Switch operation @@ -255,29 +287,17 @@ pub(crate) async fn do_upgrade( &Utf8PathBuf::from("/sysroot"), &id, imgref, - true, + Some(StagedDeployment { + depl_id: id.to_hex(), + finalization_locked: opts.download_only, + }), boot_type, boot_digest, img_manifest_config, ) .await?; - if let Some(soft_reboot_mode) = opts.soft_reboot { - return prepare_soft_reboot_composefs( - storage, - booted_cfs, - Some(&id.to_hex()), - soft_reboot_mode, - opts.apply, - ) - .await; - }; - - if opts.apply { - return crate::reboot::reboot(); - } - - Ok(()) + apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await } #[context("Upgrading composefs")] @@ -286,18 +306,60 @@ pub(crate) async fn upgrade_composefs( storage: &Storage, composefs: &BootedComposefs, ) -> Result<()> { - // Download-only mode is not yet supported for composefs backend - if opts.download_only { - anyhow::bail!("--download-only is not yet supported for composefs backend"); - } - if opts.from_downloaded { - anyhow::bail!("--from-downloaded is not yet supported for composefs backend"); - } - let host = get_composefs_status(storage, composefs) .await .context("Getting composefs deployment status")?; + let do_upgrade_opts = DoUpgradeOpts { + soft_reboot: opts.soft_reboot, + apply: opts.apply, + download_only: opts.download_only, + }; + + if opts.from_downloaded { + let staged = host + .status + .staged + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?; + + // Staged deployment exists, but it will be finalized + if !staged.download_only { + println!("Staged deployment is present and not in download only mode."); + println!("Use `bootc update --apply` to apply the update."); + return Ok(()); + } + + start_finalize_stated_svc()?; + + // Make the staged deployment not download_only + let new_staged = StagedDeployment { + depl_id: staged.require_composefs()?.verity.clone(), + finalization_locked: false, + }; + + let staged_depl_dir = + Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .context("Opening transient state directory")?; + + staged_depl_dir + .atomic_replace_with( + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + |f| -> std::io::Result<()> { + f.write_all(new_staged.to_canon_json_string()?.as_bytes()) + }, + ) + .context("Writing staged file")?; + + return apply_upgrade( + storage, + composefs, + &staged.require_composefs()?.verity, + &do_upgrade_opts, + ) + .await; + } + let mut booted_imgref = host .spec .image @@ -313,11 +375,6 @@ pub(crate) async fn upgrade_composefs( // Or if we have another staged deployment with a different image let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref()); - let do_upgrade_opts = DoUpgradeOpts { - soft_reboot: opts.soft_reboot, - apply: opts.apply, - }; - if let Some(staged_image) = staged_image { // We have a staged image and it has the same digest as the currently booted image's latest // digest