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 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] 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