From cea83bc5b4cda2bfc27a78b823866e2e3c57af92 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 3 Sep 2026 19:33:24 -0400 Subject: [PATCH 1/3] utils: Capture command output with memfd In what turns out to be a GIANT trap, systemd generators on RHEL9 may run before a writable /tmp is available. Assisted-by: https://github.com/cgwalters/cgwalters#llms Signed-off-by: Colin Walters --- crates/utils/Cargo.toml | 2 +- crates/utils/src/command.rs | 43 ++++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index b80ce9caf5..776b26e169 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -17,7 +17,6 @@ rustix = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } shlex = { workspace = true } -tempfile = { workspace = true } tokio = { workspace = true, features = ["process", "rt", "macros"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -26,6 +25,7 @@ tracing-journald = { workspace = true } [dev-dependencies] similar-asserts = { workspace = true } static_assertions = { workspace = true } +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/utils/src/command.rs b/crates/utils/src/command.rs index 0a9d759c11..afd565deeb 100644 --- a/crates/utils/src/command.rs +++ b/crates/utils/src/command.rs @@ -9,6 +9,16 @@ use std::{ use anyhow::{Context, Result}; +/// Create a seekable, filesystem-independent file for command output. +fn command_output_file() -> Result { + // bootc's command helpers run from a systemd generator. Generators on + // systemd 252 and older may see a read-only /tmp (253+ provides a private + // writable /tmp), so output capture must not rely on filesystem temp files. + rustix::fs::memfd_create("bootc-command-output", rustix::fs::MemfdFlags::CLOEXEC) + .map(std::fs::File::from) + .context("create memfd for command output") +} + /// Helpers intended for [`std::process::Command`]. pub trait CommandRunExt { /// Log (at debug level) the full child commandline. @@ -139,7 +149,7 @@ impl CommandRunExt for Command { /// Synchronously execute the child, and return an error if the child exited unsuccessfully. fn run_capture_stderr(&mut self) -> Result<()> { - let stderr = tempfile::tempfile()?; + let stderr = command_output_file()?; self.stderr(stderr.try_clone()?); tracing::trace!("exec: {self:?}"); self.status()?.check_status_with_stderr(stderr) @@ -168,7 +178,7 @@ impl CommandRunExt for Command { } fn run_get_output(&mut self) -> Result> { - let mut stdout = tempfile::tempfile()?; + let mut stdout = command_output_file()?; self.stdout(stdout.try_clone()?); self.run_capture_stderr()?; stdout.seek(std::io::SeekFrom::Start(0)).context("seek")?; @@ -220,7 +230,7 @@ pub trait AsyncCommandRunExt { impl AsyncCommandRunExt for tokio::process::Command { async fn run(&mut self) -> Result<()> { - let stderr = tempfile::tempfile()?; + let stderr = command_output_file()?; self.stderr(stderr.try_clone()?); self.status().await?.check_status_with_stderr(stderr) } @@ -283,6 +293,22 @@ mod tests { ); } + #[test] + fn command_output_file_is_a_memfd() { + use std::os::fd::AsRawFd; + + let file = command_output_file().unwrap(); + // An unprivileged test cannot reliably make /tmp read-only, so verify + // directly that the capture backing file is a memfd instead. + let fd_path = format!("/proc/self/fd/{}", file.as_raw_fd()); + let target = std::fs::read_link(fd_path).unwrap(); + assert!( + target + .to_string_lossy() + .contains("memfd:bootc-command-output") + ); + } + #[test] fn exit_status_check_status() { use std::process::Command; @@ -307,14 +333,14 @@ mod tests { // Test successful exit status let mut success_status = Command::new("true").status().unwrap(); - let temp_stderr = tempfile::tempfile().unwrap(); + let temp_stderr = command_output_file().unwrap(); success_status .check_status_with_stderr(temp_stderr) .unwrap(); // Test failed exit status with stderr content let mut fail_status = Command::new("false").status().unwrap(); - let mut temp_stderr = tempfile::tempfile().unwrap(); + let mut temp_stderr = command_output_file().unwrap(); write!(temp_stderr, "test error message").unwrap(); let e = fail_status .check_status_with_stderr(temp_stderr) @@ -351,6 +377,13 @@ mod tests { let (success, fail) = tokio::join!(success.run(), fail.run(),); success.unwrap(); assert!(fail.is_err()); + + let error = AsyncCommand::new("/bin/sh") + .args(["-c", "echo expected-async-error 1>&2; exit 1"]) + .run() + .await + .unwrap_err(); + assert!(error.to_string().contains("expected-async-error")); } #[test] From 8bfe8fdf64a25c4b2857a4a700dde50292806466 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 11 Sep 2026 14:56:12 -0400 Subject: [PATCH 2/3] ci: Move CI jobs to Ubuntu 26.04 Use the native QEMU stack containing the virtiofs notifier-ordering fix, rather than mixing newer emulator packages into an older runner. Keep TMT on the Python 3.12 runtime used for validation. Requires Ubuntu setup to select the native QEMU package on 26.04. Generated-by: AI Signed-off-by: Colin Walters --- .github/actions/install-tmt/action.yml | 4 ++++ .github/workflows/ci.yml | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/actions/install-tmt/action.yml b/.github/actions/install-tmt/action.yml index 8fea40d585..d54c3ab242 100644 --- a/.github/actions/install-tmt/action.yml +++ b/.github/actions/install-tmt/action.yml @@ -3,6 +3,10 @@ description: 'Install a pinned version of tmt (Test Management Tool)' runs: using: 'composite' steps: + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12.14' - name: Install tmt shell: bash run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3bc28d68d..63f3c2f268 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,7 @@ jobs: # Run basic validation checks (linting, formatting, etc) validate: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 - name: Bootc Ubuntu Setup @@ -149,7 +149,7 @@ jobs: run: just validate # Check for security vulnerabilities and license compliance cargo-deny: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 - uses: EmbarkStudios/cargo-deny-action@v2 @@ -167,7 +167,7 @@ jobs: # Re-enable once the underlying hang is root-caused. if: false && needs.compute-ci-level.outputs.run_heavy == 'true' needs: compute-ci-level - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - name: Checkout repository uses: actions/checkout@v7 @@ -236,7 +236,7 @@ jobs: done # Test that we can build documentation docs: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 - name: Bootc Ubuntu Setup @@ -254,7 +254,7 @@ jobs: matrix: test_os: ${{ fromJson(needs.compute-ci-level.outputs.package_os_matrix) }} - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 # Rawhide is best-effort; don't let it block merges continue-on-error: ${{ matrix.test_os == 'fedora-46' }} @@ -344,7 +344,7 @@ jobs: # - bootloader: grub-cc # seal_state: sealed - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 @@ -443,7 +443,7 @@ jobs: - test_os: fedora-44 variant: composefs - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 @@ -505,7 +505,7 @@ jobs: # centos-9 ships an older dracut that lacks the auto-install of setup-root-conf.toml - test_os: centos-9 - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 @@ -571,7 +571,7 @@ jobs: # We need to change to use coreos-assembler. if: false needs: [compute-ci-level, package] - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 @@ -614,7 +614,7 @@ jobs: test-container-export: if: needs.compute-ci-level.outputs.run_heavy == 'true' needs: [compute-ci-level, package] - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v7 From 12f03a042b868f40ec850c2496babda1d66775d5 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 11 Sep 2026 14:56:12 -0400 Subject: [PATCH 3/3] tmt: Apply boot context when discovering plans FMF adjustments need the boot type and seal state during plan discovery, not only during execution. Honor disabled plans before provisioning a VM so unsupported test variants can be excluded through existing metadata. Keep an unspecified seal state distinct from an explicitly unsealed one. Generated-by: AI Signed-off-by: Colin Walters --- crates/xtask/src/tmt.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index 12111f46e5..b129b213f9 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -69,6 +69,16 @@ fn sanitize_plan_name(plan: &str) -> String { } } +fn boot_context(boot_type: &crate::BootType, seal_state: Option<&SealState>) -> [String; 2] { + [ + format!("--context=boot_type={boot_type}"), + format!( + "--context=seal_state={}", + seal_state.map_or("unspecified".to_string(), ToString::to_string) + ), + ] +} + /// Check that required dependencies are available #[context("Checking dependencies")] fn check_dependencies(sh: &Shell) -> Result<()> { @@ -378,6 +388,7 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { .chain(std::iter::once(format!( "--context=VARIANT_ID={variant_id}" ))) + .chain(boot_context(&args.boot_type, args.seal_state.as_ref())) .collect::>(); let preserve_vm = args.preserve_vm; @@ -425,9 +436,13 @@ pub(crate) fn run_tmt(sh: &Shell, args: &RunTmtArgs) -> Result<()> { // Get the list of plans println!("Discovering test plans..."); - let plans_output = cmd!(sh, "tmt plan ls") - .read() - .context("Getting list of test plans")?; + let discovery_context = context.clone(); + let plans_output = cmd!( + sh, + "tmt {discovery_context...} plan ls --filter enabled:true" + ) + .read() + .context("Getting list of test plans")?; let mut plans: Vec<&str> = plans_output .lines() @@ -1399,6 +1414,21 @@ fn generate_integration() -> Result<(String, String)> { mod tests { use super::*; + #[test] + fn test_boot_context_values() { + assert_eq!( + boot_context(&crate::BootType::Uki, Some(&SealState::Sealed)), + ["--context=boot_type=uki", "--context=seal_state=sealed"] + ); + assert_eq!( + boot_context(&crate::BootType::Bls, None), + [ + "--context=boot_type=bls", + "--context=seal_state=unspecified" + ] + ); + } + #[test] fn test_parse_tmt_metadata_basic() { let content = r#"# number: 1