ci: Revamp bootc integration test suite with bcvk VM support
authorColin Walters <walters@verbum.org>
Tue, 17 Mar 2026 21:00:34 +0000 (21:00 +0000)
committerColin Walters <walters@verbum.org>
Tue, 17 Mar 2026 23:13:57 +0000 (23:13 +0000)
 the old privtest CI job with a new tests/bootc-integration
Rust crate that runs inside a bcvk VM. The old tests/inst crate is
preserved for tests not yet ported.

Tests are split into two tiers based on what they need:

- booted_test!: needs a fully deployed ostree system. Dispatches via
  `bcvk libvirt run` which does `bootc install to-disk`.
- privileged_test!: just needs root. Dispatches via the faster
  `bcvk ephemeral run-ssh`.

The Justfile provides `integration-container` (full suite) and
`integration-ephemeral` (fast privileged-only path). JUnit XML output
is supported via the JUNIT_OUTPUT env var using quick-junit;
integration-container captures results to target/integration-results.xml.

Drop the vendored bootc-ubuntu-setup action in favor of the upstream
bootc-dev/actions/bootc-ubuntu-setup@main.

Assisted-by: OpenCode (Claude claude-opus-4-6)
Signed-off-by: Colin Walters <walters@verbum.org>
13 files changed:
.github/actions/bootc-ubuntu-setup/action.yml [deleted file]
.github/workflows/bootc.yaml
.github/workflows/tests.yml
Dockerfile
Justfile
tests/bootc-integration/.gitignore [new file with mode: 0644]
tests/bootc-integration/Cargo.lock [new file with mode: 0644]
tests/bootc-integration/Cargo.toml [new file with mode: 0644]
tests/bootc-integration/bootc-e2e.md [new file with mode: 0644]
tests/bootc-integration/src/lib.rs [new file with mode: 0644]
tests/bootc-integration/src/main.rs [new file with mode: 0644]
tests/bootc-integration/src/tests/mod.rs [new file with mode: 0644]
tests/bootc-integration/src/tests/privileged.rs [new file with mode: 0644]

diff --git a/.github/actions/bootc-ubuntu-setup/action.yml b/.github/actions/bootc-ubuntu-setup/action.yml
deleted file mode 100644 (file)
index 5bfcbb2..0000000
+++ /dev/null
@@ -1,91 +0,0 @@
-name: 'Bootc Ubuntu Setup'
-description: 'Default host setup'
-inputs:
-  libvirt:
-    description: 'Install libvirt and virtualization stack'
-    required: false
-    default: 'false'
-runs:
-  using: 'composite'
-  steps:
-    # The default runners have TONS of crud on them...
-    - name: Free up disk space on runner
-      shell: bash
-      run: |
-        set -xeuo pipefail
-        sudo df -h
-        unwanted_pkgs=('^aspnetcore-.*' '^dotnet-.*' '^llvm-.*' 'php.*' '^mongodb-.*' '^mysql-.*'
-                       azure-cli google-chrome-stable firefox mono-devel)
-        unwanted_dirs=(/usr/share/dotnet /opt/ghc /usr/local/lib/android /opt/hostedtoolcache/CodeQL)
-        # Start background removal operations as systemd units; if this causes
-        # races in the future around disk space we can look at waiting for cleanup
-        # before starting further jobs, but right now we spent a lot of time waiting
-        # on the network and scripts and such below, giving these plenty of time to run.
-        n=0
-        runcleanup() {
-          sudo systemd-run -r -u action-cleanup-${n} -- "$@"
-          n=$(($n + 1))
-        }
-        runcleanup docker image prune --all --force
-        for x in ${unwanted_dirs[@]}; do
-          runcleanup rm -rf "$x"
-        done
-        # Apt removals in foreground, as we can't parallelize these
-        for x in ${unwanted_pkgs[@]}; do
-          /bin/time -f '%E %C' sudo apt-get remove -y $x
-        done
-    # We really want support for heredocs
-    - name: Update podman and install just
-      shell: bash
-      run: |
-        set -eux
-        # Require the runner is ubuntu-24.04
-        IDV=$(. /usr/lib/os-release && echo ${ID}-${VERSION_ID})
-        test "${IDV}" = "ubuntu-24.04"
-        # plucky is the next release
-        echo 'deb http://azure.archive.ubuntu.com/ubuntu plucky universe main' | sudo tee /etc/apt/sources.list.d/plucky.list
-        /bin/time -f '%E %C' sudo apt update
-        # skopeo is currently older in plucky for some reason hence --allow-downgrades
-        /bin/time -f '%E %C' sudo apt install -y --allow-downgrades crun/plucky podman/plucky skopeo/plucky just
-    # This is the default on e.g. Fedora derivatives, but not Debian
-    - name: Enable unprivileged /dev/kvm access
-      shell: bash
-      run: |
-        set -xeuo pipefail
-        echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
-        sudo udevadm control --reload-rules
-        sudo udevadm trigger --name-match=kvm
-        ls -l /dev/kvm
-    # Used by a few workflows, but generally useful
-    - name: Set architecture variable
-      id: set_arch
-      shell: bash
-      run: echo "ARCH=$(arch)" >> $GITHUB_ENV
-    # Install libvirt stack if requested
-    - name: Install libvirt and virtualization stack
-      if: ${{ inputs.libvirt == 'true' }}
-      shell: bash
-      run: |
-        set -xeuo pipefail
-        export BCVK_VERSION=0.8.0
-        /bin/time -f '%E %C' sudo apt install -y libkrb5-dev pkg-config libvirt-dev genisoimage qemu-utils qemu-kvm virtiofsd libvirt-daemon-system
-        # Something in the stack is overriding this, but we want session right now for bcvk
-        echo LIBVIRT_DEFAULT_URI=qemu:///session >> $GITHUB_ENV
-        td=$(mktemp -d)
-        cd $td
-        # Install bcvk
-        target=bcvk-$(arch)-unknown-linux-gnu
-        /bin/time -f '%E %C' curl -LO https://github.com/bootc-dev/bcvk/releases/download/v${BCVK_VERSION}/${target}.tar.gz
-        tar xzf ${target}.tar.gz
-        sudo install -T ${target} /usr/bin/bcvk
-        cd -
-        rm -rf "$td"
-
-        # Also bump the default fd limit as a workaround for https://github.com/bootc-dev/bcvk/issues/65
-        sudo sed -i -e 's,^\* hard nofile 65536,* hard nofile 524288,' /etc/security/limits.conf
-    - name: Cleanup status
-      shell: bash
-      run: |
-        set -xeuo pipefail
-        systemctl list-units 'action-cleanup*'
-        df -h
index b3d6ef242de0332cbd36322d5a6434e84c11f7f7..d7142110b4fd05204108557d789c62f1b21077a3 100644 (file)
@@ -23,22 +23,18 @@ jobs:
         stream: [stream9, stream10]
     steps:
       - uses: actions/checkout@v4
-      - uses: ./.github/actions/bootc-ubuntu-setup
+      - uses: bootc-dev/actions/bootc-ubuntu-setup@main
+        with:
+          libvirt: true
       - name: build
-        run: sudo STREAM=${{ matrix.stream }} just build
+        run: STREAM=${{ matrix.stream }} just build
       - name: unitcontainer
-        run: sudo STREAM=${{ matrix.stream }} just unitcontainer
+        run: STREAM=${{ matrix.stream }} just unitcontainer
       - name: unittest
-        run: sudo STREAM=${{ matrix.stream }} just unittest
-      - name: bootc install
-        run: |
-          set -xeuo pipefail
-          sudo podman run --env BOOTC_SKIP_SELINUX_HOST_CHECK=1 --rm -ti --privileged -v /:/target --pid=host --security-opt label=disable \
-            -v /dev:/dev -v /var/lib/containers:/var/lib/containers \
-            localhost/ostree:latest bootc install to-filesystem --skip-fetch-check \
-             --replace=alongside /target
-          # Verify labeling for /etc
-          sudo ls -dZ /ostree/deploy/default/deploy/*.0/etc |grep :etc_t:
+        run: STREAM=${{ matrix.stream }} just unittest
+      - name: integration tests (bcvk)
+        timeout-minutes: 15
+        run: STREAM=${{ matrix.stream }} just integration-container
       - name: Upload test logs
         if: failure()
         uses: actions/upload-artifact@v4
index 5364429e221871f90fc4e26869813a5020ff546e..af83c01475fcf6624a02839ea561c741bddac1d3 100644 (file)
@@ -41,22 +41,6 @@ jobs:
       - run: sudo apt install clang-format just
       - name: Test style
         run: just clang-format-check
-  build-integration:
-    runs-on: ubuntu-latest
-    container: quay.io/coreos-assembler/fcos-buildroot:testing-devel
-    steps:
-      - uses: actions/checkout@v3
-      - name: Cache Dependencies
-        uses: Swatinem/rust-cache@ce325b60658c1b38465c06cc965b79baf32c1e72
-        with:
-          key: "integration"
-      - name: Build
-        run: cd tests/inst && cargo build --verbose --release
-      - name: Upload binary
-        uses: actions/upload-artifact@v4
-        with:
-          name: ostree-test
-          path: tests/inst/target/release/ostree-test
   minimal:
     name: "Build - FCOS minimal"
     runs-on: ubuntu-latest
@@ -95,33 +79,6 @@ jobs:
         with:
           name: inst.tar.zst
           path: inst.tar.zst
-  privtest:
-    name: "Privileged testing"
-    needs: [build-c, build-integration]
-    runs-on: ubuntu-latest
-    container:
-      image: quay.io/fedora/fedora-coreos:testing-devel
-      options: "--privileged --pid=host -v /run/systemd:/run/systemd -v /:/run/host"
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v3
-      - name: Download install tree
-        uses: actions/download-artifact@v4
-        with:
-          name: inst.tar.zst
-      - name: Install
-        run: tar -C / -xvf inst.tar.zst && rm -f inst.tar.zst
-      - name: Download test binary
-        uses: actions/download-artifact@v4
-        with:
-          name: ostree-test
-      - name: Install
-        run: install ostree-test /usr/bin
-      - name: Setup
-        # https://github.com/ostreedev/ostree-rs-ext/issues/417
-        run: mkdir -p /var/tmp
-      - name: Integration tests (unit)
-        run: ostree-test
   tests:
     # Distro configuration matrix
     #
index 412f21a49014c08c7dc2ae85d8adf862b773c7d7..3dd64c242058c45169fb18aee27458693c18c053 100644 (file)
@@ -39,6 +39,22 @@ RUN rpm -e --nodeps ostree{,-libs}
 COPY --from=build /out/ /
 COPY --from=src /src/tests-unit-container /tests
 
+# Build integration test binary
+FROM $base as integration-build
+RUN dnf -y install cargo rust
+COPY tests/bootc-integration/Cargo.toml tests/bootc-integration/Cargo.lock /build/tests/bootc-integration/
+WORKDIR /build/tests/bootc-integration
+RUN --mount=type=cache,target=/root/.cargo/registry \
+    --mount=type=cache,target=/root/.cargo/git \
+    cargo fetch
+COPY tests/bootc-integration /build/tests/bootc-integration
+RUN --network=none \
+    --mount=type=cache,target=/root/.cargo/registry \
+    --mount=type=cache,target=/root/.cargo/git \
+    --mount=type=cache,target=/build/tests/bootc-integration/target \
+    cargo build --release && \
+    cp target/release/ostree-bootc-integration-tests /usr/bin/ostree-bootc-integration-tests
+
 # Override userspace
 FROM $base as rootfs
 # Remove the default binaries to ensure we're getting our overrides
@@ -48,6 +64,7 @@ COPY --from=build /out/ /
 # The default final container, with also a regenerated
 # initramfs in case ostree-prepare-root changed.
 FROM rootfs
+COPY --from=integration-build /usr/bin/ostree-bootc-integration-tests /usr/bin/ostree-bootc-integration-tests
 # https://docs.fedoraproject.org/en-US/bootc/initramfs/#_regenerating_the_initrd
 # since we have ostree-prepare-root there
 RUN set -x; kver=$(cd /usr/lib/modules && echo *); dracut -vf /usr/lib/modules/$kver/initramfs.img $kver
index c1316990b929819335723ebee35b813991ad831b..b2f53c142e4640b8fff67a6b0c6f642528dc5cc3 100644 (file)
--- a/Justfile
+++ b/Justfile
@@ -56,6 +56,34 @@ build-host-inst: build-host
     make -C target/c install DESTDIR=$(pwd)/target/inst
     tar --sort=name --numeric-owner --owner=0 --group=0 -C target/inst -czf target/inst.tar.gz .
 
+# Run all integration tests inside a deployed libvirt VM.
+# This runs both "booted" tests (need full deployment) and "privileged"
+# tests (just need root). For faster iteration on privileged-only tests,
+# use `integration-ephemeral`.
+# JUnit XML results are written to target/integration-results.xml.
+integration-container *ARGS:
+    #!/bin/bash
+    set -euo pipefail
+    bcvk libvirt run --name ostree-integration-test --replace --detach --ssh-wait localhost/ostree:latest
+    rc=0
+    bcvk libvirt ssh ostree-integration-test -- env JUNIT_OUTPUT=/tmp/junit.xml \
+        ostree-bootc-integration-tests {{ARGS}} || rc=$?
+    mkdir -p target
+    bcvk libvirt ssh ostree-integration-test -- cat /tmp/junit.xml \
+        > target/integration-results.xml 2>/dev/null || true
+    bcvk libvirt rm --stop --force ostree-integration-test
+    exit $rc
+
+# Run only root-privileged tests via an ephemeral VM (faster — no disk install).
+# This skips tests that need a fully deployed/booted ostree system.
+# Not all tests will pass here; use `integration-container` for the full suite.
+integration-ephemeral *ARGS:
+    bcvk ephemeral run-ssh localhost/ostree:latest -- ostree-bootc-integration-tests {{ARGS}}
+
+# Remove any leftover integration test VMs
+integration-cleanup:
+    -bcvk libvirt rm --stop --force ostree-integration-test
+
 sourcefiles := "git ls-files '**.c' '**.cxx' '**.h' '**.hpp'"
 # Reformat source files
 clang-format:
diff --git a/tests/bootc-integration/.gitignore b/tests/bootc-integration/.gitignore
new file mode 100644 (file)
index 0000000..b83d222
--- /dev/null
@@ -0,0 +1 @@
+/target/
diff --git a/tests/bootc-integration/Cargo.lock b/tests/bootc-integration/Cargo.lock
new file mode 100644 (file)
index 0000000..3dcd204
--- /dev/null
@@ -0,0 +1,800 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "escape8259"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6"
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "js-sys"
+version = "0.3.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.182"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+
+[[package]]
+name = "libtest-mimic"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5297962ef19edda4ce33aaa484386e0a5b3d7f2f4e037cbeee00503ef6b29d33"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap",
+ "escape8259",
+]
+
+[[package]]
+name = "linkme"
+version = "0.3.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e3283ed2d0e50c06dd8602e0ab319bb048b6325d0bba739db64ed8205179898"
+dependencies = [
+ "linkme-impl",
+]
+
+[[package]]
+name = "linkme-impl"
+version = "0.3.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "newtype-uuid"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c012d14ef788ab066a347d19e3dda699916c92293b05b85ba2c76b8c82d2830"
+dependencies = [
+ "uuid",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "ostree-bootc-integration-tests"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "libtest-mimic",
+ "linkme",
+ "paste",
+ "quick-junit",
+ "rustix",
+ "tempfile",
+ "xshell",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-junit"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ee9342d671fae8d66b3ae9fd7a9714dfd089c04d2a8b1ec0436ef77aee15e5f"
+dependencies = [
+ "chrono",
+ "indexmap",
+ "newtype-uuid",
+ "quick-xml",
+ "strip-ansi-escapes",
+ "thiserror",
+ "uuid",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.38.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "strip-ansi-escapes"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
+dependencies = [
+ "vte",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vte"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "xshell"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e7290c623014758632efe00737145b6867b66292c42167f2ec381eb566a373d"
+dependencies = [
+ "xshell-macros",
+]
+
+[[package]]
+name = "xshell-macros"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/tests/bootc-integration/Cargo.toml b/tests/bootc-integration/Cargo.toml
new file mode 100644 (file)
index 0000000..af3b21e
--- /dev/null
@@ -0,0 +1,22 @@
+[package]
+name = "ostree-bootc-integration-tests"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+# Separate workspace from root; this is built inside the container
+[workspace]
+
+[[bin]]
+name = "ostree-bootc-integration-tests"
+path = "src/main.rs"
+
+[dependencies]
+anyhow = "1"
+libtest-mimic = "0.8"
+linkme = "0.3"
+paste = "1"
+quick-junit = "0.5"
+rustix = { version = "1", default-features = false, features = ["process"] }
+tempfile = "3"
+xshell = "0.2"
diff --git a/tests/bootc-integration/bootc-e2e.md b/tests/bootc-integration/bootc-e2e.md
new file mode 100644 (file)
index 0000000..9d309ac
--- /dev/null
@@ -0,0 +1,25 @@
+# Bootc integration tests
+
+Integration tests that run inside a [bcvk](https://github.com/bootc-dev/bcvk) VM
+against a deployed ostree system. The test binary is baked into the container
+image at build time and executed over SSH.
+
+Tests are split into two tiers: `booted_test!` tests need a fully deployed
+system (via `bcvk libvirt run`) while `privileged_test!` tests just need root
+and can run in a faster ephemeral VM. See the module docs in
+`src/tests/privileged.rs` for details.
+
+## Running
+
+```sh
+just build                    # build the container image
+just integration-container    # run all tests in a deployed VM
+just integration-ephemeral    # fast path: privileged-only tests
+just integration-container --exact privileged_verify_composefs  # single test
+just integration-container --list                               # list tests
+just integration-cleanup      # remove leftover VMs
+```
+
+Set `JUNIT_OUTPUT` to a file path to get JUnit XML results. The
+`integration-container` recipe does this automatically, writing to
+`target/integration-results.xml`.
diff --git a/tests/bootc-integration/src/lib.rs b/tests/bootc-integration/src/lib.rs
new file mode 100644 (file)
index 0000000..37f2e0a
--- /dev/null
@@ -0,0 +1,52 @@
+//! Integration test infrastructure for ostree bootc testing.
+//!
+//! Provides test registration via [`linkme`] distributed slices and the
+//! [`integration_test!`] macro, collected and executed by a custom
+//! [`libtest_mimic`] harness in `main.rs`.
+
+// linkme requires unsafe for distributed slices
+#![allow(unsafe_code)]
+
+/// A test function that returns a Result.
+pub type TestFn = fn() -> anyhow::Result<()>;
+
+/// Metadata for a registered integration test.
+#[derive(Debug)]
+pub struct IntegrationTest {
+    /// Name of the integration test.
+    pub name: &'static str,
+    /// Test function to execute.
+    pub f: TestFn,
+}
+
+impl IntegrationTest {
+    /// Create a new integration test with the given name and function.
+    pub const fn new(name: &'static str, f: TestFn) -> Self {
+        Self { name, f }
+    }
+}
+
+/// Distributed slice holding all registered integration tests.
+#[linkme::distributed_slice]
+pub static INTEGRATION_TESTS: [IntegrationTest];
+
+/// Register an integration test function.
+///
+/// # Examples
+///
+/// ```ignore
+/// fn test_something() -> anyhow::Result<()> {
+///     Ok(())
+/// }
+/// integration_test!(test_something);
+/// ```
+#[macro_export]
+macro_rules! integration_test {
+    ($fn_name:ident) => {
+        ::paste::paste! {
+            #[::linkme::distributed_slice($crate::INTEGRATION_TESTS)]
+            static [<$fn_name:upper>]: $crate::IntegrationTest =
+                $crate::IntegrationTest::new(stringify!($fn_name), $fn_name);
+        }
+    };
+}
diff --git a/tests/bootc-integration/src/main.rs b/tests/bootc-integration/src/main.rs
new file mode 100644 (file)
index 0000000..1b893bf
--- /dev/null
@@ -0,0 +1,93 @@
+//! Integration test runner for ostree bootc testing.
+//!
+//! This binary uses [`libtest_mimic`] as a custom test harness.
+//! Tests are registered via the [`integration_test!`] macro in submodules
+//! and collected from the [`INTEGRATION_TESTS`] distributed slice at startup.
+//!
+//! When the `JUNIT_OUTPUT` environment variable is set to a file path,
+//! JUnit XML results are written there after tests complete.
+
+// linkme requires unsafe for distributed slices
+#![allow(unsafe_code)]
+
+use std::sync::{Arc, Mutex};
+use std::time::Instant;
+
+use libtest_mimic::{Arguments, Trial};
+
+pub(crate) use ostree_bootc_integration_tests::{integration_test, INTEGRATION_TESTS};
+
+mod tests;
+
+/// Outcome of a single test, recorded during execution.
+struct TestOutcome {
+    name: String,
+    duration: std::time::Duration,
+    result: Result<(), String>,
+}
+
+fn main() {
+    let args = Arguments::from_args();
+    let outcomes: Arc<Mutex<Vec<TestOutcome>>> = Arc::new(Mutex::new(Vec::new()));
+
+    let tests: Vec<Trial> = INTEGRATION_TESTS
+        .iter()
+        .map(|t| {
+            let f = t.f;
+            let name = t.name.to_owned();
+            let outcomes = Arc::clone(&outcomes);
+            Trial::test(t.name, move || {
+                let start = Instant::now();
+                let result = f();
+                let duration = start.elapsed();
+                let outcome = TestOutcome {
+                    name,
+                    duration,
+                    result: result.as_ref().map(|_| ()).map_err(|e| format!("{e:?}")),
+                };
+                outcomes.lock().unwrap().push(outcome);
+                result.map_err(|e| format!("{e:?}").into())
+            })
+        })
+        .collect();
+
+    let conclusion = libtest_mimic::run(&args, tests);
+
+    // Write JUnit XML if requested
+    if let Ok(path) = std::env::var("JUNIT_OUTPUT") {
+        if let Err(e) = write_junit(&path, &outcomes.lock().unwrap()) {
+            eprintln!("warning: failed to write JUnit XML to {path}: {e}");
+        }
+    }
+
+    std::process::exit(if conclusion.has_failed() { 101 } else { 0 });
+}
+
+fn write_junit(path: &str, outcomes: &[TestOutcome]) -> anyhow::Result<()> {
+    use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite};
+
+    let mut report = Report::new("ostree-bootc-integration-tests");
+    let mut suite = TestSuite::new("privileged");
+
+    for outcome in outcomes {
+        let status = match &outcome.result {
+            Ok(()) => TestCaseStatus::success(),
+            Err(msg) => {
+                let mut status = TestCaseStatus::non_success(NonSuccessKind::Failure);
+                status.set_message(msg.clone());
+                status
+            }
+        };
+        let mut tc = TestCase::new(outcome.name.clone(), status);
+        tc.set_time(outcome.duration);
+        suite.add_test_case(tc);
+    }
+
+    report.add_test_suite(suite);
+    let xml = report
+        .to_string()
+        .map_err(|e| anyhow::anyhow!("JUnit serialization failed: {e}"))?;
+    std::fs::write(path, xml)?;
+    eprintln!("JUnit XML written to {path}");
+    Ok(())
+}
diff --git a/tests/bootc-integration/src/tests/mod.rs b/tests/bootc-integration/src/tests/mod.rs
new file mode 100644 (file)
index 0000000..1fd840e
--- /dev/null
@@ -0,0 +1,3 @@
+//! Integration test modules.
+
+pub mod privileged;
diff --git a/tests/bootc-integration/src/tests/privileged.rs b/tests/bootc-integration/src/tests/privileged.rs
new file mode 100644 (file)
index 0000000..cfb8708
--- /dev/null
@@ -0,0 +1,338 @@
+//! Integration tests requiring root privileges.
+//!
+//! Tests are split into two categories:
+//!
+//! * **Booted** (`booted_test!`) — need a fully deployed ostree system
+//!   (composefs, sysroot, boot markers). When not root these dispatch via
+//!   `bcvk libvirt run` which does a full `bootc install to-disk`.
+//!
+//! * **Privileged** (`privileged_test!`) — just need root and the ostree
+//!   binary installed. When not root these dispatch via the much faster
+//!   `bcvk ephemeral run-ssh` (no disk install).
+
+use anyhow::{ensure, Result};
+use xshell::{cmd, Shell};
+
+use crate::integration_test;
+
+/// How a test should be dispatched when not running as root.
+/// See the module-level docs for details on each mode.
+enum RunMode {
+    Booted,
+    Privileged,
+}
+
+macro_rules! booted_test {
+    ($fn_name:ident, $body:expr) => {
+        fn $fn_name() -> Result<()> {
+            if require_root(stringify!($fn_name), RunMode::Booted)?.is_some() {
+                return Ok(());
+            }
+            $body
+        }
+        integration_test!($fn_name);
+    };
+}
+
+macro_rules! privileged_test {
+    ($fn_name:ident, $body:expr) => {
+        fn $fn_name() -> Result<()> {
+            if require_root(stringify!($fn_name), RunMode::Privileged)?.is_some() {
+                return Ok(());
+            }
+            $body
+        }
+        integration_test!($fn_name);
+    };
+}
+
+/// Returns `Ok(None)` if already root (test proceeds normally).
+/// Otherwise dispatches to a VM per `mode` and returns `Ok(Some(()))`.
+fn require_root(test_name: &str, mode: RunMode) -> Result<Option<()>> {
+    if rustix::process::getuid().is_root() {
+        return Ok(None);
+    }
+
+    // We're on the host without root — delegate to a VM.
+    if std::env::var_os("OSTREE_IN_VM").is_some() {
+        anyhow::bail!("OSTREE_IN_VM is set but we're not root — VM setup is broken");
+    }
+
+    let image = std::env::var("OSTREE_TEST_IMAGE").map_err(|_| {
+        anyhow::anyhow!(
+            "not root and OSTREE_TEST_IMAGE not set; \
+             run `just integration-container` to build and test"
+        )
+    })?;
+
+    let sh = Shell::new()?;
+    let bcvk = std::env::var("BCVK_PATH").unwrap_or_else(|_| "bcvk".into());
+
+    match mode {
+        RunMode::Booted => {
+            // Use a unique VM name per test to avoid collisions
+            let vm_name = format!("ostree-test-{}", test_name.replace('_', "-"));
+
+            // Deploy a full VM (bootc install to-disk + boot) and wait for SSH
+            cmd!(
+                sh,
+                "{bcvk} libvirt run --name {vm_name} --replace --detach --ssh-wait {image}"
+            )
+            .run()?;
+
+            // Run the test inside the deployed VM
+            let result = cmd!(
+                sh,
+                "{bcvk} libvirt ssh {vm_name} -- ostree-bootc-integration-tests --exact {test_name}"
+            )
+            .run();
+
+            // Always clean up the VM
+            let _ = cmd!(sh, "{bcvk} libvirt rm --stop --force {vm_name}").run();
+
+            // Propagate the test result
+            result?;
+        }
+        RunMode::Privileged => {
+            // Fast path: ephemeral container, no disk install needed
+            cmd!(
+                sh,
+                "{bcvk} ephemeral run-ssh {image} -- ostree-bootc-integration-tests --exact {test_name}"
+            )
+            .run()?;
+        }
+    }
+
+    Ok(Some(()))
+}
+
+booted_test!(privileged_verify_ostree_booted, {
+    let sh = Shell::new()?;
+
+    // /run/ostree-booted is the canonical marker
+    ensure!(
+        std::path::Path::new("/run/ostree-booted").exists(),
+        "/run/ostree-booted does not exist — system was not booted via ostree"
+    );
+
+    // ostree admin status should succeed and show a deployment
+    let status = cmd!(sh, "ostree admin status").read()?;
+    ensure!(
+        !status.is_empty(),
+        "ostree admin status returned empty output"
+    );
+
+    Ok(())
+});
+
+booted_test!(privileged_verify_sysroot, {
+    ensure!(
+        std::path::Path::new("/sysroot").is_dir(),
+        "/sysroot does not exist"
+    );
+    ensure!(
+        std::path::Path::new("/ostree").is_dir(),
+        "/ostree does not exist"
+    );
+    ensure!(
+        std::path::Path::new("/ostree/repo").is_dir(),
+        "/ostree/repo does not exist"
+    );
+
+    // Verify there's at least one deployment
+    let deploy_dir = std::path::Path::new("/ostree/deploy");
+    ensure!(deploy_dir.is_dir(), "/ostree/deploy does not exist");
+
+    let has_stateroot = std::fs::read_dir(deploy_dir)?
+        .any(|entry| entry.is_ok_and(|e| e.file_type().is_ok_and(|ft| ft.is_dir())));
+    ensure!(
+        has_stateroot,
+        "no stateroot directories found under /ostree/deploy"
+    );
+
+    Ok(())
+});
+
+booted_test!(privileged_verify_composefs, {
+    let sh = Shell::new()?;
+
+    // With composefs enabled, the root filesystem should be an overlay
+    let fstype = cmd!(sh, "findmnt -n -o FSTYPE /").read()?;
+    ensure!(
+        fstype.trim() == "overlay",
+        "expected root filesystem type 'overlay' (composefs), got '{}'",
+        fstype.trim()
+    );
+
+    // The composefs private directory should exist with restricted permissions
+    let private_dir = std::path::Path::new("/run/ostree/.private");
+    ensure!(
+        private_dir.exists(),
+        "/run/ostree/.private does not exist — composefs may not be active"
+    );
+
+    let metadata = std::fs::metadata(private_dir)?;
+    let mode = std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o777;
+    ensure!(
+        mode == 0,
+        "/run/ostree/.private has mode {mode:#o}, expected 0o000"
+    );
+
+    Ok(())
+});
+
+booted_test!(privileged_verify_ostree_cli, {
+    let sh = Shell::new()?;
+
+    // Verify ostree binary works and reports a version
+    let version_output = cmd!(sh, "ostree --version").read()?;
+    ensure!(
+        version_output.contains("libostree"),
+        "ostree --version output doesn't mention libostree: {version_output}"
+    );
+
+    // ostree admin status should succeed
+    let status = cmd!(sh, "ostree admin status").read()?;
+    ensure!(
+        !status.is_empty(),
+        "ostree admin status returned empty output"
+    );
+
+    // ostree refs should succeed (may be empty, but shouldn't error)
+    cmd!(sh, "ostree refs --repo=/ostree/repo").run()?;
+
+    Ok(())
+});
+
+booted_test!(privileged_verify_sysroot_readonly, {
+    let sh = Shell::new()?;
+    let options = cmd!(sh, "findmnt -n -o OPTIONS /sysroot").read()?;
+    ensure!(
+        options.contains("ro"),
+        "/sysroot is not mounted read-only: {options}"
+    );
+    Ok(())
+});
+
+booted_test!(privileged_verify_ostree_run_metadata, {
+    let metadata = std::fs::metadata("/run/ostree")?;
+    let mode = std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o777;
+    ensure!(
+        mode == 0o755,
+        "/run/ostree has mode {mode:#o}, expected 0o755"
+    );
+    Ok(())
+});
+
+booted_test!(privileged_verify_immutable_bit, {
+    let sh = Shell::new()?;
+    // If composefs (overlay), the immutable bit doesn't apply
+    let fstype = cmd!(sh, "findmnt -n -o FSTYPE /").read()?;
+    if fstype.trim() == "overlay" {
+        return Ok(());
+    }
+    // https://bugzilla.redhat.com/show_bug.cgi?id=1867601
+    let lsattr_out = cmd!(sh, "lsattr -d /").read()?;
+    ensure!(
+        lsattr_out.contains("-i-"),
+        "immutable bit not set on /: {lsattr_out}"
+    );
+    Ok(())
+});
+
+booted_test!(privileged_verify_osinit_unshare, {
+    let sh = Shell::new()?;
+    // Create a test stateroot; this should not disturb /run/ostree permissions
+    cmd!(sh, "ostree admin os-init ostreetestsuite").run()?;
+    let metadata = std::fs::metadata("/run/ostree")?;
+    let mode = std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o777;
+    ensure!(
+        mode == 0o755,
+        "/run/ostree has mode {mode:#o} after os-init, expected 0o755"
+    );
+    Ok(())
+});
+
+privileged_test!(privileged_verify_nofifo, {
+    let sh = Shell::new()?;
+    let tmp = tempfile::tempdir()?;
+    let tmpdir = tmp.path();
+    let _guard = sh.push_dir(tmpdir);
+    cmd!(sh, "ostree --repo=repo init --mode=archive").run()?;
+    cmd!(sh, "mkdir tmproot").run()?;
+    cmd!(sh, "mkfifo tmproot/afile").run()?;
+    let result = cmd!(
+        sh,
+        "ostree --repo=repo commit -b fifotest -s 'commit fifo' --tree=dir=./tmproot"
+    )
+    .ignore_status()
+    .output()?;
+    ensure!(
+        !result.status.success(),
+        "committing a FIFO should have failed"
+    );
+    let stderr = String::from_utf8_lossy(&result.stderr);
+    ensure!(
+        stderr.contains("Not a regular file or symlink"),
+        "expected 'Not a regular file or symlink' in stderr, got: {stderr}"
+    );
+    Ok(())
+});
+
+privileged_test!(privileged_verify_mtime, {
+    let sh = Shell::new()?;
+    let tmp = tempfile::tempdir()?;
+    let tmpdir = tmp.path();
+    let _guard = sh.push_dir(tmpdir);
+    cmd!(sh, "ostree --repo=repo init --mode=archive").run()?;
+    cmd!(sh, "mkdir tmproot").run()?;
+    cmd!(sh, "bash -c 'echo afile > tmproot/afile'").run()?;
+    cmd!(sh, "ostree --repo=repo commit -b test --tree=dir=tmproot")
+        .ignore_stdout()
+        .run()?;
+    let ts_before = tmpdir.join("repo").metadata()?.modified()?;
+    std::thread::sleep(std::time::Duration::from_secs(1));
+    cmd!(
+        sh,
+        "ostree --repo=repo commit -b test -s 'bump mtime' --tree=dir=tmproot"
+    )
+    .ignore_stdout()
+    .run()?;
+    let ts_after = tmpdir.join("repo").metadata()?.modified()?;
+    ensure!(
+        ts_before != ts_after,
+        "repo mtime did not change after second commit"
+    );
+    Ok(())
+});
+
+privileged_test!(privileged_verify_extensions, {
+    let sh = Shell::new()?;
+    let tmp = tempfile::tempdir()?;
+    let tmpdir = tmp.path();
+    let _guard = sh.push_dir(tmpdir);
+    cmd!(sh, "ostree --repo=repo init --mode=bare").run()?;
+    let extensions = tmpdir.join("repo/extensions");
+    ensure!(
+        extensions.exists(),
+        "repo/extensions directory was not created by ostree init"
+    );
+    Ok(())
+});
+
+booted_test!(privileged_verify_selinux_labels, {
+    let sh = Shell::new()?;
+
+    // Check that /etc has the correct SELinux type
+    let ls_output = cmd!(sh, "ls -dZ /etc").read()?;
+    if !ls_output.contains(':') {
+        // SELinux is likely disabled; skip this check
+        return Ok(());
+    }
+    ensure!(
+        ls_output.contains(":etc_t:"),
+        "/etc does not have :etc_t: SELinux label: {ls_output}"
+    );
+
+    Ok(())
+});