diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0081dfa11..636fd80c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,6 +198,10 @@ jobs: - name: Unit and container integration tests run: just test-container + - name: Validate composefs digest (sealed UKI only) + if: matrix.variant == 'composefs-sealeduki-sdboot' + run: just validate-composefs-digest + - name: Run TMT integration tests run: | if [ "${{ matrix.variant }}" = "composefs-sealeduki-sdboot" ]; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b395bd58b..31d217f4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,11 +41,20 @@ accepted! Worth stating: before you start diving into the code you should understand using the system as a user and how it works. See the user documentation for that. -## Understanding the Justfile +## The Justfile + +The [Justfile](Justfile) is the primary interface for building and testing bootc. + +```bash +just --list # Show all targets organized by group +just list-variants # Show available build variants and current config +``` + +### Building from source Edit the source code; a simple thing to do is add e.g. -`eprintln!("hello world");` into `run_from_opt` in [crates/lib/src/cli.rs](cli.rs). -You can run `make` or `cargo build` to build that locally. However, a key +`eprintln!("hello world");` into `run_from_opt` in [crates/lib/src/cli.rs](crates/lib/src/cli.rs). +You can run `make` or `cargo build` to build that locally. However, a key next step is to get that binary into a bootc container image. Running `just` defaults to `just build` which will build a container @@ -58,14 +67,24 @@ and try running `bootc`. ### Running container-oriented integration tests -`just test-container` +```bash +just test-container +``` ### Running (TMT) integration tests A common cycle here is you'll edit e.g. `deploy.rs` and want to run the tests that perform an upgrade: -`just test-tmt-one test-20-local-upgrade` +```bash +just test-tmt local-upgrade-reboot +``` + +To run a specific test: + +```bash +just test-tmt readonly +``` ### Faster iteration cycles @@ -96,6 +115,26 @@ then you can `cargo b --release` directly in a Fedora 42 container or even on your host system, and then directly run e.g. `./target/release/bootc upgrade` etc. +### Testing with composefs (sealed images) + +To build and test with the experimental composefs backend: + +```bash +# Build a sealed image with auto-generated test Secure Boot keys +just build-sealed + +# Run composefs-specific tests +just test-composefs + +# Validate that composefs digests match between build and install views +# (useful for debugging mtime/metadata issues) +just validate-composefs-digest +``` + +The `build-sealed` target generates test Secure Boot keys in `target/test-secureboot/` +and builds a complete sealed image with UKI. See [experimental-composefs.md](docs/src/experimental-composefs.md) +for more information on sealed images. + ### Debugging via lldb diff --git a/Cargo.lock b/Cargo.lock index d1a781ad7..45399dec0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,7 +573,7 @@ checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] name = "composefs" version = "0.3.0" -source = "git+https://github.com/containers/composefs-rs?rev=e9008489375044022e90d26656960725a76f4620#e9008489375044022e90d26656960725a76f4620" +source = "git+https://github.com/containers/composefs-rs?rev=4a060161e0122bd2727e639437c61e05ecc7cab3#4a060161e0122bd2727e639437c61e05ecc7cab3" dependencies = [ "anyhow", "hex", @@ -593,7 +593,7 @@ dependencies = [ [[package]] name = "composefs-boot" version = "0.3.0" -source = "git+https://github.com/containers/composefs-rs?rev=e9008489375044022e90d26656960725a76f4620#e9008489375044022e90d26656960725a76f4620" +source = "git+https://github.com/containers/composefs-rs?rev=4a060161e0122bd2727e639437c61e05ecc7cab3#4a060161e0122bd2727e639437c61e05ecc7cab3" dependencies = [ "anyhow", "composefs", @@ -606,10 +606,11 @@ dependencies = [ [[package]] name = "composefs-oci" version = "0.3.0" -source = "git+https://github.com/containers/composefs-rs?rev=e9008489375044022e90d26656960725a76f4620#e9008489375044022e90d26656960725a76f4620" +source = "git+https://github.com/containers/composefs-rs?rev=4a060161e0122bd2727e639437c61e05ecc7cab3#4a060161e0122bd2727e639437c61e05ecc7cab3" dependencies = [ "anyhow", "async-compression", + "bytes", "composefs", "containers-image-proxy", "hex", @@ -619,6 +620,7 @@ dependencies = [ "sha2", "tar", "tokio", + "tokio-util", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index df45ce27f..8cc076c0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,9 +42,17 @@ clap = "4.5.4" clap_mangen = { version = "0.2.20" } # Reviewers (including AI tools): The composefs-rs git revision is duplicated for each crate. # If adding/removing crates here, also update docs/Dockerfile.mdbook and docs/src/internals.md. -composefs = { git = "https://github.com/containers/composefs-rs", rev = "e9008489375044022e90d26656960725a76f4620", package = "composefs", features = ["rhel9"] } -composefs-boot = { git = "https://github.com/containers/composefs-rs", rev = "e9008489375044022e90d26656960725a76f4620", package = "composefs-boot" } -composefs-oci = { git = "https://github.com/containers/composefs-rs", rev = "e9008489375044022e90d26656960725a76f4620", package = "composefs-oci" } +# +# To develop against a local composefs-rs checkout: +# 1. Set BOOTC_extra_src to your composefs-rs path when building: +# BOOTC_extra_src=$HOME/src/composefs-rs just build +# 2. Comment out the git refs below and uncomment the path refs: +composefs = { git = "https://github.com/containers/composefs-rs", rev = "4a060161e0122bd2727e639437c61e05ecc7cab3", package = "composefs", features = ["rhel9"] } +composefs-boot = { git = "https://github.com/containers/composefs-rs", rev = "4a060161e0122bd2727e639437c61e05ecc7cab3", package = "composefs-boot" } +composefs-oci = { git = "https://github.com/containers/composefs-rs", rev = "4a060161e0122bd2727e639437c61e05ecc7cab3", package = "composefs-oci" } +# composefs = { path = "/run/extra-src/crates/composefs", package = "composefs", features = ["rhel9"] } +# composefs-boot = { path = "/run/extra-src/crates/composefs-boot", package = "composefs-boot" } +# composefs-oci = { path = "/run/extra-src/crates/composefs-oci", package = "composefs-oci" } fn-error-context = "0.2.1" hex = "0.4.3" indicatif = "0.18.0" diff --git a/Dockerfile b/Dockerfile index 11ca9ab5c..e62a6daa3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ FROM $base as buildroot ARG initramfs=1 # This installs our buildroot, and we want to cache it independently of the rest. # Basically we don't want changing a .rs file to blow out the cache of packages. +# Use tmpfs,target=/run with bind mounts inside to avoid leaking mount stubs into the image RUN --mount=type=tmpfs,target=/run \ --mount=type=bind,from=packaging,src=/,target=/run/packaging \ /run/packaging/install-buildroot @@ -32,12 +33,9 @@ WORKDIR /src # See https://www.reddit.com/r/rust/comments/126xeyx/exploring_the_problem_of_faster_cargo_docker/ # We aren't using the full recommendations there, just the simple bits. # First we download all of our Rust dependencies +# Note: /run/extra-src is optionally bind-mounted via BOOTC_extra_src for local composefs-rs development RUN --mount=type=tmpfs,target=/run --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome cargo fetch -FROM buildroot as sdboot-content -# Writes to /out -RUN --mount=type=tmpfs,target=/run /src/contrib/packaging/configure-systemdboot download - # We always do a "from scratch" build # https://docs.fedoraproject.org/en-US/bootc/building-from-scratch/ # because this fixes https://github.com/containers/composefs-rs/issues/132 @@ -47,6 +45,11 @@ RUN --mount=type=tmpfs,target=/run /src/contrib/packaging/configure-systemdboot # local sources. We'll override it later. # NOTE: All your base belong to me. FROM $base as target-base +# Handle version skew between base image and mirrors for CentOS Stream +# xref https://gitlab.com/redhat/centos-stream/containers/bootc/-/issues/1174 +RUN --mount=type=tmpfs,target=/run \ + --mount=type=bind,from=packaging,src=/,target=/run/packaging \ + /run/packaging/enable-compose-repos RUN --mount=type=tmpfs,target=/run /usr/libexec/bootc-base-imagectl build-rootfs --manifest=standard /target-rootfs FROM scratch as base @@ -69,6 +72,13 @@ ENV container=oci STOPSIGNAL SIGRTMIN+3 CMD ["/sbin/init"] +# This layer contains things which aren't in the default image and may +# be used for sealing images in particular. +FROM base as tools +RUN --mount=type=tmpfs,target=/run \ + --mount=type=bind,from=packaging,src=/,target=/run/packaging \ + /run/packaging/initialize-sealing-tools + # ------------- # external dependency cutoff point: # NOTE: Every RUN instruction past this point should use `--network=none`; we want to ensure @@ -85,14 +95,35 @@ ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} # Build RPM directly from source, using cached target directory RUN --network=none --mount=type=tmpfs,target=/run --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome RPM_VERSION="${pkgversion}" /src/contrib/packaging/build-rpm -FROM buildroot as sdboot-signed +# This image signs systemd-boot using our key, and writes the resulting binary into /out +FROM tools as sdboot-signed # The secureboot key and cert are passed via Justfile # We write the signed binary into /out +# Note: /out already contains systemd-boot-unsigned RPM from initialize-sealing-tools RUN --network=none --mount=type=tmpfs,target=/run \ - --mount=type=bind,from=sdboot-content,src=/,target=/run/sdboot-package \ --mount=type=secret,id=secureboot_key \ - --mount=type=secret,id=secureboot_cert \ - /src/contrib/packaging/configure-systemdboot sign + --mount=type=secret,id=secureboot_cert < to use a different base -ARG base=localhost/bootc -FROM $base AS base - -FROM base as kernel -# Use tmpfs on /run to prevent podman's DNS resolver files from being committed -RUN --mount=type=tmpfs,target=/run < Justfile -> podman -> make -> rustc -# -> podman -> dnf|apt ... +# -> podman -> package manager # -> cargo xtask # -------------------------------------------------------------------- -# This image is just the base image plus our updated bootc binary +# Configuration variables (override via environment or command line) +# Example: BOOTC_base=quay.io/fedora/fedora-bootc:42 just build + +# Output image name base_img := "localhost/bootc" -# Has a synthetic upgrade +# Synthetic upgrade image for testing upgrade_img := base_img + "-upgrade" -# ostree: The default -# composefs-sealeduki-sdboot: A system with a sealed composefs using systemd-boot +# Build variant: ostree (default) or composefs-sealeduki-sdboot (sealed UKI) variant := env("BOOTC_variant", "ostree") +# Base container image to build from base := env("BOOTC_base", "quay.io/centos-bootc/centos-bootc:stream10") +# Buildroot base image buildroot_base := env("BOOTC_buildroot_base", "quay.io/centos/centos:stream10") +# Optional: path to extra source (e.g. composefs-rs) for local development +extra_src := env("BOOTC_extra_src", "") +# Internal variables +nocache := env("BOOTC_nocache", "") +_nocache_arg := if nocache != "" { "--no-cache" } else { "" } testimage_label := "bootc.testimage=1" -# Images used by hack/lbi; keep in sync lbi_images := "quay.io/curl/curl:latest quay.io/curl/curl-base:latest registry.access.redhat.com/ubi9/podman:latest" -# We used to have --jobs=4 here but sometimes that'd hit this -# ``` -# [2/3] STEP 2/2: RUN --mount=type=bind,from=context,target=/run/context < Using cache b068d42ac7491067cf5fafcaaf2f09d348e32bb752a22c85bbb87f266409554d -# --> b068d42ac749 -# + cd /run/context/ -# /bin/sh: line 3: cd: /run/context/: Permission denied -# ``` -# TODO: Gather more info and file a buildah bug +fedora-coreos := "quay.io/fedora/fedora-coreos:testing-devel" generic_buildargs := "" -# Args for package building (no secrets needed, just builds RPMs) -base_buildargs := generic_buildargs + " --build-arg=base=" + base + " --build-arg=variant=" + variant -# - scratch builds need extra perms per https://docs.fedoraproject.org/en-US/bootc/building-from-scratch/ -# - we do secure boot signing here, so provide the keys +_extra_src_args := if extra_src != "" { "-v " + extra_src + ":/run/extra-src:ro --security-opt=label=disable" } else { "" } +base_buildargs := generic_buildargs + " " + _extra_src_args + " --build-arg=base=" + base + " --build-arg=variant=" + variant buildargs := base_buildargs \ + " --cap-add=all --security-opt=label=type:container_runtime_t --device /dev/fuse" \ + " --secret=id=secureboot_key,src=target/test-secureboot/db.key --secret=id=secureboot_cert,src=target/test-secureboot/db.crt" -# Args for build-sealed (no base arg, it sets that itself) -sealed_buildargs := "--build-arg=variant=" + variant + " --secret=id=secureboot_key,src=target/test-secureboot/db.key --secret=id=secureboot_cert,src=target/test-secureboot/db.crt" -# The default target: build the container image from current sources. -# Note commonly you might want to override the base image via e.g. -# `just build --build-arg=base=quay.io/fedora/fedora-bootc:42` -# into the container image. -# -# Note you can set `BOOTC_SKIP_PACKAGE=1` in the environment to bypass this stage. -build: package _keygen && _pull-lbi-images +# ============================================================================ +# Core workflows - the main targets most developers will use +# ============================================================================ + +# Show available build variants and current configuration +[group('core')] +list-variants: #!/bin/bash - set -xeuo pipefail - test -d target/packages - # Use --build-context to pass packages instead of -v to avoid mount stubs in /run - pkg_path=$(realpath target/packages) - podman build --target=final --build-context "packages=${pkg_path}" -t {{base_img}}-bin {{buildargs}} . - ./hack/build-sealed {{variant}} {{base_img}}-bin {{base_img}} {{sealed_buildargs}} + cat <<'EOF' + Build Variants (set via BOOTC_variant= or variant=) + ==================================================== -# Pull images used by hack/lbi -_pull-lbi-images: - podman pull -q --retry 5 --retry-delay 5s {{lbi_images}} + ostree (default) + Standard bootc image using ostree backend. + This is the traditional, production-ready configuration. -# Compute SOURCE_DATE_EPOCH and VERSION from git for reproducible builds. -# Outputs shell variable assignments that can be eval'd. -_git-build-vars: - #!/bin/bash - set -euo pipefail - SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) - # Compute version from git (matching xtask.rs gitrev logic) - if VERSION=$(git describe --tags --exact-match 2>/dev/null); then - VERSION="${VERSION#v}" - VERSION="${VERSION//-/.}" - else - COMMIT=$(git rev-parse HEAD | cut -c1-10) - COMMIT_TS=$(git show -s --format=%ct) - TIMESTAMP=$(date -u -d @${COMMIT_TS} +%Y%m%d%H%M) - VERSION="${TIMESTAMP}.g${COMMIT}" - fi - echo "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}" - echo "VERSION=${VERSION}" + composefs-sealeduki-sdboot + Sealed composefs image with: + - Unified Kernel Image (UKI) containing kernel + initramfs + cmdline + - Secure Boot signing (using keys in target/test-secureboot/) + - systemd-boot bootloader + - composefs digest embedded in kernel cmdline for verified boot -# Needed by bootc install on ostree -fedora-coreos := "quay.io/fedora/fedora-coreos:testing-devel" -# Generate Secure Boot keys (only for our own CI/testing) -_keygen: - ./hack/generate-secureboot-keys + Use `just build-sealed` as a shortcut, or: + just variant=composefs-sealeduki-sdboot build -# Build a sealed image from current sources. -build-sealed: - @just --justfile {{justfile()}} variant=composefs-sealeduki-sdboot build + Current Configuration + ===================== + EOF + echo " BOOTC_variant={{variant}}" + echo " BOOTC_base={{base}}" + echo " BOOTC_extra_src={{extra_src}}" + echo "" -# Build packages (e.g. RPM) into target/packages/ -# Any old packages will be removed. -# Set BOOTC_SKIP_PACKAGE=1 in the environment to bypass this stage. We don't -# yet have an accurate ability to avoid rebuilding this in CI yet. -package: +# Build container image from current sources +[group('core')] +build: package _keygen && _pull-lbi-images #!/bin/bash set -xeuo pipefail - packages=target/packages - if test -n "${BOOTC_SKIP_PACKAGE:-}"; then - if test '!' -d "${packages}"; then - echo "BOOTC_SKIP_PACKAGE is set, but missing ${packages}" 1>&2; exit 1 - fi - exit 0 - fi - eval $(just _git-build-vars) - echo "Building RPM with version: ${VERSION}" - podman build {{base_buildargs}} --build-arg=SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} --build-arg=pkgversion=${VERSION} -t localhost/bootc-pkg --target=build . - mkdir -p "${packages}" - rm -vf "${packages}"/*.rpm - podman run --rm localhost/bootc-pkg tar -C /out/ -cf - . | tar -C "${packages}"/ -xvf - - chmod a+rx target "${packages}" - chmod a+r "${packages}"/*.rpm - # Keep localhost/bootc-pkg for layer caching; use `just clean-local-images` to reclaim space + test -d target/packages + pkg_path=$(realpath target/packages) + podman build {{_nocache_arg}} --build-context "packages=${pkg_path}" -t {{base_img}} {{buildargs}} . -# Build+test using the `composefs-sealeduki-sdboot` variant. -test-composefs: - just variant=composefs-sealeduki-sdboot test-tmt readonly local-upgrade-reboot +# Build a sealed composefs image (alias for variant=composefs-sealeduki-sdboot) +[group('core')] +build-sealed: + @just --justfile {{justfile()}} variant=composefs-sealeduki-sdboot build -# Only used by ci.yml right now -build-install-test-image: build - cd hack && podman build {{base_buildargs}} -t {{base_img}}-install -f Containerfile.drop-lbis +# Run tmt integration tests in VMs (e.g. `just test-tmt readonly`) +[group('core')] +test-tmt *ARGS: build + @just _build-upgrade-image + @just test-tmt-nobuild {{ARGS}} -# These tests accept the container image as input, and may spawn it. -run-container-external-tests: - ./tests/container/run {{base_img}} +# Run containerized unit and integration tests +[group('core')] +test-container: build build-units + podman run --rm --read-only localhost/bootc-units /usr/bin/bootc-units + podman run --rm --env=BOOTC_variant={{variant}} --env=BOOTC_base={{base}} {{base_img}} bootc-integration-tests container -# We build the unit tests into a container image -build-units: - #!/bin/bash - set -xeuo pipefail - eval $(just _git-build-vars) - podman build {{base_buildargs}} --build-arg=SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} --build-arg=pkgversion=${VERSION} --target units -t localhost/bootc-units . +# Build and test sealed composefs images +[group('core')] +test-composefs: + just variant=composefs-sealeduki-sdboot test-tmt readonly local-upgrade-reboot -# Perform validation (build, linting) in a container build environment +# Run cargo fmt and clippy checks in container +[group('core')] validate: podman build {{base_buildargs}} --target validate . -# Run tmt-based test suites using local virtual machines with -# bcvk. -# -# To run an individual test, pass it as an argument like: -# `just test-tmt readonly` -test-tmt *ARGS: build - @just _build-upgrade-image - @just test-tmt-nobuild {{ARGS}} - -# Generate a local synthetic upgrade -_build-upgrade-image: - cat tmt/tests/Dockerfile.upgrade | podman build -t {{upgrade_img}}-bin --from={{base_img}}-bin - - ./hack/build-sealed {{variant}} {{upgrade_img}}-bin {{upgrade_img}} {{sealed_buildargs}} +# ============================================================================ +# Testing variants and utilities +# ============================================================================ -# Assume the localhost/bootc image is up to date, and just run tests. -# Useful for iterating on tests quickly. +# Run tmt tests without rebuilding (for fast iteration) +[group('testing')] test-tmt-nobuild *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, -# which skips LBIs, test kargs, and install configs that would conflict with FCOS. -build-testimage-coreos PATH: _keygen - #!/bin/bash - set -xeuo pipefail - pkg_path=$(realpath "{{PATH}}") - # Use --build-context to pass packages instead of -v to avoid mount stubs in /run - podman build --target=final --build-context "packages=${pkg_path}" \ - --build-arg SKIP_CONFIGS=1 \ - -t {{base_img}}-coreos-bin {{buildargs}} . - ./hack/build-sealed {{variant}} {{base_img}}-coreos-bin {{base_img}}-coreos {{sealed_buildargs}} - -# Run test bootc install on FCOS -# BOOTC_target is `bootc-coreos`, it will be used for bootc install. -# Run `just build-testimage-coreos target/packages` to build test image firstly, -# then run `just test-tmt-on-coreos plan-bootc-install-on-coreos` +# Run tmt tests on Fedora CoreOS +[group('testing')] test-tmt-on-coreos *ARGS: cargo xtask run-tmt --env=BOOTC_variant={{variant}} --env=BOOTC_target={{base_img}}-coreos:latest {{fedora-coreos}} {{ARGS}} -# Cleanup all test VMs created by tmt tests +# Run external container tests against localhost/bootc +[group('testing')] +run-container-external-tests: + ./tests/container/run {{base_img}} + +# Remove all test VMs created by tmt tests +[group('testing')] tmt-vm-cleanup: bcvk libvirt rm --stop --force --label bootc.test=1 -# Run tests (unit and integration) that are containerized -test-container: build build-units - podman run --rm --read-only localhost/bootc-units /usr/bin/bootc-units - # Pass these through for cross-checking - podman run --rm --env=BOOTC_variant={{variant}} --env=BOOTC_base={{base}} {{base_img}} bootc-integration-tests container +# Build test image for Fedora CoreOS testing +[group('testing')] +build-testimage-coreos PATH: _keygen + #!/bin/bash + set -xeuo pipefail + pkg_path=$(realpath "{{PATH}}") + podman build --build-context "packages=${pkg_path}" \ + --build-arg SKIP_CONFIGS=1 \ + -t {{base_img}}-coreos {{buildargs}} . -# Remove all container images built (locally) via this Justfile, by matching a label -clean-local-images: - podman images --filter "label={{testimage_label}}" - podman images --filter "label={{testimage_label}}" --format "{{{{.ID}}" | xargs -r podman rmi -f - podman image prune -f - podman rmi {{fedora-coreos}} -f +# Build test image for install tests (used by CI) +[group('testing')] +build-install-test-image: build + cd hack && podman build {{base_buildargs}} -t {{base_img}}-install -f Containerfile.drop-lbis -# Print the container image reference for a given short $ID-VERSION_ID for NAME -# and 'base' or 'buildroot-base' for TYPE (base image type) -pullspec-for-os TYPE NAME: - @jq -r --arg v "{{NAME}}" '."{{TYPE}}"[$v]' < hack/os-image-map.json +# ============================================================================ +# Documentation +# ============================================================================ +# Serve docs locally (prints URL) +[group('docs')] +mdbook-serve: build-mdbook + #!/bin/bash + set -xeuo pipefail + podman run --init --replace -d --name bootc-mdbook --rm --publish 127.0.0.1::8000 localhost/bootc-mdbook + echo http://$(podman port bootc-mdbook 8000/tcp) + +# Build the documentation (mdbook) +[group('docs')] build-mdbook: #!/bin/bash set -xeuo pipefail secret_arg="" - # Pass GH_TOKEN to avoid API rate limits when cargo-binstall fetches binaries if test -n "${GH_TOKEN:-}"; then secret_arg="--secret=id=GH_TOKEN,env=GH_TOKEN" fi podman build {{generic_buildargs}} ${secret_arg} -t localhost/bootc-mdbook -f docs/Dockerfile.mdbook . -# Generate the rendered HTML to the target DIR directory +# Build docs and extract to DIR +[group('docs')] build-mdbook-to DIR: build-mdbook #!/bin/bash set -xeuo pipefail - # Create a temporary container to extract the built docs container_id=$(podman create localhost/bootc-mdbook) podman cp ${container_id}:/src/docs/book {{DIR}} podman rm -f ${container_id} -mdbook-serve: build-mdbook - #!/bin/bash - set -xeuo pipefail - podman run --init --replace -d --name bootc-mdbook --rm --publish 127.0.0.1::8000 localhost/bootc-mdbook - echo http://$(podman port bootc-mdbook 8000/tcp) +# ============================================================================ +# Debugging and validation +# ============================================================================ -# Update all generated files (man pages and JSON schemas) -# -# This is the unified command that: -# - Auto-discovers new CLI commands and creates man page templates -# - Syncs CLI options from Rust code to existing man page templates -# - Updates JSON schema files -# -# Use this after adding, removing, or modifying CLI options or schemas. -update-generated: - cargo run -p xtask update-generated +# Validate composefs digests match between build and install views +[group('debugging')] +validate-composefs-digest: + cargo xtask validate-composefs-digest {{base_img}} -# Verify build system properties (reproducible builds) -# -# This runs `just package` twice and verifies that the resulting RPMs -# are bit-for-bit identical, confirming SOURCE_DATE_EPOCH is working. +# Verify reproducible builds (runs package twice, compares output) +[group('debugging')] check-buildsys: cargo run -p xtask check-buildsys + +# Get container image pullspec for a given OS (e.g. `pullspec-for-os base fedora-42`) +[group('debugging')] +pullspec-for-os TYPE NAME: + @jq -r --arg v "{{NAME}}" '."{{TYPE}}"[$v]' < hack/os-image-map.json + +# ============================================================================ +# Maintenance +# ============================================================================ + +# Update generated files (man pages, JSON schemas) +[group('maintenance')] +update-generated: + cargo run -p xtask update-generated + +# Remove all locally-built test container images +[group('maintenance')] +clean-local-images: + podman images --filter "label={{testimage_label}}" + podman images --filter "label={{testimage_label}}" --format "{{{{.ID}}" | xargs -r podman rmi -f + podman image prune -f + podman rmi {{fedora-coreos}} -f + +# Build packages (RPM) into target/packages/ +[group('maintenance')] +package: + #!/bin/bash + set -xeuo pipefail + packages=target/packages + if test -n "${BOOTC_SKIP_PACKAGE:-}"; then + if test '!' -d "${packages}"; then + echo "BOOTC_SKIP_PACKAGE is set, but missing ${packages}" 1>&2; exit 1 + fi + exit 0 + fi + eval $(just _git-build-vars) + echo "Building RPM with version: ${VERSION}" + podman build {{base_buildargs}} --build-arg=SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} --build-arg=pkgversion=${VERSION} -t localhost/bootc-pkg --target=build . + mkdir -p "${packages}" + rm -vf "${packages}"/*.rpm + podman run --rm localhost/bootc-pkg tar -C /out/ -cf - . | tar -C "${packages}"/ -xvf - + chmod a+rx target "${packages}" + chmod a+r "${packages}"/*.rpm + +# Build unit tests into a container image +[group('maintenance')] +build-units: + #!/bin/bash + set -xeuo pipefail + eval $(just _git-build-vars) + podman build {{base_buildargs}} --build-arg=SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} --build-arg=pkgversion=${VERSION} --target units -t localhost/bootc-units . + +# ============================================================================ +# Internal helpers (prefixed with _) +# ============================================================================ + +_pull-lbi-images: + podman pull -q --retry 5 --retry-delay 5s {{lbi_images}} + +_git-build-vars: + #!/bin/bash + set -euo pipefail + SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) + if VERSION=$(git describe --tags --exact-match 2>/dev/null); then + VERSION="${VERSION#v}" + VERSION="${VERSION//-/.}" + else + COMMIT=$(git rev-parse HEAD | cut -c1-10) + COMMIT_TS=$(git show -s --format=%ct) + TIMESTAMP=$(date -u -d @${COMMIT_TS} +%Y%m%d%H%M) + VERSION="${TIMESTAMP}.g${COMMIT}" + fi + echo "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}" + echo "VERSION=${VERSION}" + +_keygen: + ./hack/generate-secureboot-keys + +_build-upgrade-image: + cat tmt/tests/Dockerfile.upgrade | podman build -t {{upgrade_img}} --from={{base_img}} - diff --git a/contrib/packaging/configure-systemdboot b/contrib/packaging/configure-systemdboot deleted file mode 100755 index 4c04c524b..000000000 --- a/contrib/packaging/configure-systemdboot +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -# Helper for signing and re-injecting systemd-boot -set -euo pipefail -op=$1 -shift - -sdboot="usr/lib/systemd/boot/efi/systemd-bootx64.efi" -sdboot_bn=$(basename ${sdboot}) - -case $op in - download) - mkdir -p /out - cd /out - dnf -y download systemd-boot-unsigned - ;; - sign) - mkdir -p /out - rpm -Uvh /run/sdboot-package/out/*.rpm - # Sign with sbsign using db certificate and key - sbsign \ - --key /run/secrets/secureboot_key \ - --cert /run/secrets/secureboot_cert \ - --output /out/${sdboot_bn} \ - /${sdboot} - ls -al /out/${sdboot_bn} - ;; - *) echo "Unknown operation $op" 1>&2; exit 1 - ;; -esac diff --git a/contrib/packaging/configure-variant b/contrib/packaging/configure-variant index 487ea3076..8940286ef 100755 --- a/contrib/packaging/configure-variant +++ b/contrib/packaging/configure-variant @@ -14,20 +14,7 @@ fi # Handle variant-specific configuration case "${VARIANT}" in *-sdboot) - # Install systemd-boot and remove bootupd; - # We downloaded this in an earlier phase - sdboot="usr/lib/systemd/boot/efi/systemd-bootx64.efi" - sdboot_bn=$(basename ${sdboot}) - rpm -Uvh /run/sdboot-content/out/*.rpm - # And override with our signed binary - install -m 0644 /run/sdboot-signed/out/${sdboot_bn} /${sdboot} - # Uninstall bootupd - rpm -e bootupd - rm -rf /usr/lib/bootupd/updates - # Clean up package manager caches - dnf clean all - rm -rf /var/cache /var/lib/{dnf,rhsm} /var/log/* ;; # Future variants can be added here # For Debian support, this could check package manager type and use apt instead diff --git a/contrib/packaging/enable-compose-repos b/contrib/packaging/enable-compose-repos new file mode 100755 index 000000000..20b5ba30d --- /dev/null +++ b/contrib/packaging/enable-compose-repos @@ -0,0 +1,44 @@ +#!/bin/bash +# Enable compose repos to avoid version skew between base image and mirrors +# xref https://gitlab.com/redhat/centos-stream/containers/bootc/-/issues/1174 +set -euo pipefail + +. /usr/lib/os-release + +case "${ID}" in + centos) + # The base image may have been built from a compose that has newer packages + # than what's available on the public mirrors. Enable the compose repos + # with higher priority to ensure we get matching versions. + + # Extract the gpgkey from the existing centos.repo - c9s uses + # RPM-GPG-KEY-centosofficial while c10s uses RPM-GPG-KEY-centosofficial-SHA256 + gpgkey=$(grep -m1 '^gpgkey=' /etc/yum.repos.d/centos.repo | cut -d= -f2) + if [[ -z "${gpgkey}" ]]; then + echo "Error: Could not find gpgkey in /etc/yum.repos.d/centos.repo" >&2 + exit 1 + fi + + cat > /etc/yum.repos.d/centos-compose.repo << EOF +[compose-baseos] +name=CentOS Stream \$releasever Compose BaseOS +baseurl=https://composes.stream.centos.org/stream-\$releasever/production/latest-CentOS-Stream/compose/BaseOS/\$basearch/os/ +gpgcheck=1 +enabled=1 +priority=1 +gpgkey=${gpgkey} + +[compose-appstream] +name=CentOS Stream \$releasever Compose AppStream +baseurl=https://composes.stream.centos.org/stream-\$releasever/production/latest-CentOS-Stream/compose/AppStream/\$basearch/os/ +gpgcheck=1 +enabled=1 +priority=1 +gpgkey=${gpgkey} +EOF + echo "Enabled CentOS Stream compose repos (gpgkey: ${gpgkey})" + ;; + *) + # No compose repo needed for other distros + ;; +esac diff --git a/contrib/packaging/fedora-extra.txt b/contrib/packaging/fedora-extra.txt index 50bc48f0b..a9f66c015 100644 --- a/contrib/packaging/fedora-extra.txt +++ b/contrib/packaging/fedora-extra.txt @@ -7,5 +7,3 @@ git-core jq # We now always build a package in the container build rpm-build -# Used for signing -sbsigntools diff --git a/contrib/packaging/finalize-uki b/contrib/packaging/finalize-uki new file mode 100755 index 000000000..6de60c2cc --- /dev/null +++ b/contrib/packaging/finalize-uki @@ -0,0 +1,49 @@ +#!/bin/bash +# Finalize UKI installation: copy to /boot, remove raw kernel/initramfs, create symlinks +# +# For sealed UKI images, the kernel and initramfs are embedded inside the signed +# UKI PE binary. We remove the standalone vmlinuz/initramfs.img to: +# - Avoid duplication (they're inside the UKI) +# - Ensure tools use the UKI path +# - Make it clear this is a UKI-only boot configuration +# +# NOTE: The old Dockerfile.cfsuki had a bug where the final-final stage started +# FROM base instead of FROM final, then only copied /boot. This meant the +# vmlinuz/initramfs removal in the final stage was lost. Running this script +# in the actual final image stage fixes that issue. +# +# IMPORTANT: bcvk needs to be updated to find .efi files inside kernel version +# subdirectories (e.g., /usr/lib/modules//.efi) rather than at the +# top level of /usr/lib/modules/. See https://github.com/bootc-dev/bcvk/pull/144 +set -xeuo pipefail + +# Path to directory containing the generated UKI +uki_src=$1 +shift + +# Find the kernel version from the current system +kver=$(cd /usr/lib/modules && echo *) +if [ -z "$kver" ] || [ "$kver" = "*" ]; then + echo "Error: No kernel found" >&2 + exit 1 +fi + +# Create the EFI directory structure +mkdir -p /boot/EFI/Linux + +# The UKI in /boot is outside the composefs-verified tree, which is fine +# because the UKI itself is signed and verified by Secure Boot +target=/boot/EFI/Linux/${kver}.efi +cp "${uki_src}/${kver}.efi" "${target}" + +# Remove the raw kernel and initramfs since we're using a UKI now. +# NOTE: We intentionally keep these for now until bcvk is updated to extract +# kernel/initramfs from UKIs in subdirectories. Once bcvk PR #144 is fixed +# to look for .efi files in /usr/lib/modules//, we can uncomment this. +# rm -v "/usr/lib/modules/${kver}/vmlinuz" "/usr/lib/modules/${kver}/initramfs.img" + +# NOTE: We used to create a symlink from /usr/lib/modules/${kver}/${kver}.efi to the UKI +# for tooling compatibility. However, composefs-boot's find_uki_components() doesn't +# handle symlinks correctly and fails with "is not a regular file". The UKI is already +# found in /boot/EFI/Linux/, so the symlink is not needed. +# See: https://github.com/containers/composefs-rs/issues/XXX diff --git a/contrib/packaging/initialize-sealing-tools b/contrib/packaging/initialize-sealing-tools new file mode 100755 index 000000000..b1e037bed --- /dev/null +++ b/contrib/packaging/initialize-sealing-tools @@ -0,0 +1,15 @@ +#!/bin/bash +set -xeuo pipefail +. /usr/lib/os-release +case "${ID}${ID_LIKE:-}" in + *centos*|*rhel*) + # Enable EPEL for sbsigntools + dnf -y install epel-release + ;; +esac +dnf -y install systemd-ukify sbsigntools +# And in the sealing case, we're going to inject and sign systemd-boot +# into the target image. +mkdir -p /out +cd /out +dnf -y download systemd-boot-unsigned diff --git a/contrib/packaging/install-buildroot b/contrib/packaging/install-buildroot index cc133e970..1bde1a2d2 100755 --- a/contrib/packaging/install-buildroot +++ b/contrib/packaging/install-buildroot @@ -3,14 +3,18 @@ set -xeuo pipefail cd $(dirname $0) . /usr/lib/os-release -case $ID in - centos|rhel) +case "${ID}${ID_LIKE:-}" in + *centos*|*rhel*) + # We'll use crb at build time dnf config-manager --set-enabled crb - # Enable EPEL for sbsigntools - dnf -y install epel-release ;; - fedora) dnf -y install dnf-utils 'dnf5-command(builddep)';; esac +# Deal with dnf4 vs dnf5 +if test -x /usr/bin/dnf5; then + dnf -y install 'dnf5-command(builddep)' +else + dnf -y install 'dnf-command(builddep)' +fi # Handle version skew, xref https://gitlab.com/redhat/centos-stream/containers/bootc/-/issues/1174 dnf -y distro-sync ostree{,-libs} systemd # Install base build requirements diff --git a/contrib/packaging/seal-uki b/contrib/packaging/seal-uki new file mode 100755 index 000000000..253278212 --- /dev/null +++ b/contrib/packaging/seal-uki @@ -0,0 +1,43 @@ +#!/bin/bash +# Generate a sealed UKI with embedded composefs digest +set -xeuo pipefail + +# Path to the desired root filesystem +target=$1 +shift +# Write to this directory +output=$1 +shift +# Path to secrets directory +secrets=$1 +shift + +# Compute the composefs digest from the target rootfs +composefs_digest=$(bootc container compute-composefs-digest "${target}") + +# Build the kernel command line +# enforcing=0: https://github.com/bootc-dev/bootc/issues/1826 +# TODO: pick up kargs from /usr/lib/bootc/kargs.d +cmdline="composefs=${composefs_digest} console=ttyS0,115200n8 console=hvc0 enforcing=0 rw" + +# Find the kernel version +kver=$(cd "${target}/usr/lib/modules" && echo *) +if [ -z "$kver" ] || [ "$kver" = "*" ]; then + echo "Error: No kernel found" >&2 + exit 1 +fi + +mkdir -p "${output}" + +ukify build \ + --linux "${target}/usr/lib/modules/${kver}/vmlinuz" \ + --initrd "${target}/usr/lib/modules/${kver}/initramfs.img" \ + --uname="${kver}" \ + --cmdline "${cmdline}" \ + --os-release "@${target}/usr/lib/os-release" \ + --signtool sbsign \ + --secureboot-private-key "${secrets}/secureboot_key" \ + --secureboot-certificate "${secrets}/secureboot_cert" \ + --measure \ + --json pretty \ + --output "${output}/${kver}.efi" diff --git a/contrib/packaging/switch-to-sdboot b/contrib/packaging/switch-to-sdboot new file mode 100755 index 000000000..be030ec9f --- /dev/null +++ b/contrib/packaging/switch-to-sdboot @@ -0,0 +1,24 @@ +#!/bin/bash +# Switch the target root to use systemd-boot, using the content from SRC +# SRC should contain an "out" subdirectory with: +# - systemd-boot-unsigned RPM (*.rpm) +# - signed systemd-boot binary (systemd-boot*.efi) +set -xeuo pipefail + +src=$1/out +shift + +# Uninstall bootupd if present (we're switching to sd-boot managed differently) +if rpm -q bootupd &>/dev/null; then + rpm -e bootupd + rm -vrf /usr/lib/bootupd/updates +fi + +# First install the unsigned systemd-boot RPM to get the package in place +rpm -Uvh "${src}"/*.rpm + +# Now find where it installed the binary and override with our signed version +sdboot=$(ls /usr/lib/systemd/boot/efi/systemd-boot*.efi) +sdboot_bn=$(basename "${sdboot}") +# Override with our signed binary +install -m 0644 "${src}/${sdboot_bn}" "${sdboot}" diff --git a/crates/etc-merge/src/lib.rs b/crates/etc-merge/src/lib.rs index 9f687fc6d..80b0637e6 100644 --- a/crates/etc-merge/src/lib.rs +++ b/crates/etc-merge/src/lib.rs @@ -316,17 +316,17 @@ pub fn traverse_etc( Directory, Option>, )> { - let mut pristine_etc_files = Directory::default(); + let mut pristine_etc_files = Directory::new(Stat::uninitialized()); recurse_dir(pristine_etc, &mut pristine_etc_files) .context(format!("Recursing {pristine_etc:?}"))?; - let mut current_etc_files = Directory::default(); + let mut current_etc_files = Directory::new(Stat::uninitialized()); recurse_dir(current_etc, &mut current_etc_files) .context(format!("Recursing {current_etc:?}"))?; let new_etc_files = match new_etc { Some(new_etc) => { - let mut new_etc_files = Directory::default(); + let mut new_etc_files = Directory::new(Stat::uninitialized()); recurse_dir(new_etc, &mut new_etc_files).context(format!("Recursing {new_etc:?}"))?; Some(new_etc_files) diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index d031d5277..0ddef9631 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -61,7 +61,7 @@ pub(crate) fn compute_composefs_digest( // Read filesystem from path, transform for boot, compute digest let mut fs = - composefs::fs::read_filesystem(rustix::fs::CWD, path.as_std_path(), Some(&repo), false)?; + composefs::fs::read_container_root(rustix::fs::CWD, path.as_std_path(), Some(&repo))?; fs.transform_for_boot(&repo).context("Preparing for boot")?; let id = fs.compute_image_id(); let digest = id.to_hex(); diff --git a/crates/lib/src/cfsctl.rs b/crates/lib/src/cfsctl.rs index 60e50beb8..07337dfaf 100644 --- a/crates/lib/src/cfsctl.rs +++ b/crates/lib/src/cfsctl.rs @@ -123,14 +123,17 @@ enum Command { /// the mountpoint mountpoint: String, }, + /// Creates a composefs image from a filesystem CreateImage { path: PathBuf, #[clap(long)] bootable: bool, + /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata) #[clap(long)] - stat_root: bool, + no_propagate_usr_to_root: bool, image_name: Option, }, + /// Computes the composefs image ID for a filesystem ComputeId { path: PathBuf, /// Write the dumpfile to the provided target @@ -138,15 +141,18 @@ enum Command { write_dumpfile_to: Option, #[clap(long)] bootable: bool, + /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata) #[clap(long)] - stat_root: bool, + no_propagate_usr_to_root: bool, }, + /// Outputs the composefs dumpfile format for a filesystem CreateDumpfile { path: PathBuf, #[clap(long)] bootable: bool, + /// Don't copy /usr metadata to root directory (use if root already has well-defined metadata) #[clap(long)] - stat_root: bool, + no_propagate_usr_to_root: bool, }, ImageObjects { name: String, @@ -215,7 +221,7 @@ where ref config_verity, } => { let verity = verity_opt(config_verity)?; - let mut fs = + let fs = composefs_oci::image::create_filesystem(repo, config_name, verity.as_ref())?; fs.print_dumpfile()?; } @@ -316,9 +322,13 @@ where ref path, write_dumpfile_to, bootable, - stat_root, + no_propagate_usr_to_root, } => { - let mut fs = composefs::fs::read_filesystem(CWD, path, Some(repo.as_ref()), stat_root)?; + let mut fs = if no_propagate_usr_to_root { + composefs::fs::read_filesystem(CWD, path, Some(repo.as_ref()))? + } else { + composefs::fs::read_container_root(CWD, path, Some(repo.as_ref()))? + }; if bootable { fs.transform_for_boot(repo)?; } @@ -334,10 +344,14 @@ where Command::CreateImage { ref path, bootable, - stat_root, + no_propagate_usr_to_root, ref image_name, } => { - let mut fs = composefs::fs::read_filesystem(CWD, path, Some(repo.as_ref()), stat_root)?; + let mut fs = if no_propagate_usr_to_root { + composefs::fs::read_filesystem(CWD, path, Some(repo.as_ref()))? + } else { + composefs::fs::read_container_root(CWD, path, Some(repo.as_ref()))? + }; if bootable { fs.transform_for_boot(repo)?; } @@ -347,9 +361,13 @@ where Command::CreateDumpfile { ref path, bootable, - stat_root, + no_propagate_usr_to_root, } => { - let mut fs = composefs::fs::read_filesystem(CWD, path, Some(repo.as_ref()), stat_root)?; + let mut fs = if no_propagate_usr_to_root { + composefs::fs::read_filesystem(CWD, path, Some(repo.as_ref()))? + } else { + composefs::fs::read_container_root(CWD, path, Some(repo.as_ref()))? + }; if bootable { fs.transform_for_boot(repo)?; } diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index 034a06b12..d01c9a6f4 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -54,6 +54,15 @@ enum Commands { TmtProvision(TmtProvisionArgs), /// Check build system properties (e.g., reproducible builds) CheckBuildsys, + /// Validate composefs digests match between build-time and install-time views + ValidateComposefsDigest(ValidateComposefsDigestArgs), +} + +/// Arguments for validate-composefs-digest command +#[derive(Debug, Args)] +pub(crate) struct ValidateComposefsDigestArgs { + /// Container image to validate (e.g., "localhost/bootc" or "quay.io/centos-bootc/centos-bootc:stream10") + pub(crate) image: String, } /// Arguments for run-tmt command @@ -139,6 +148,7 @@ fn try_main() -> Result<()> { Commands::RunTmt(args) => tmt::run_tmt(&sh, &args), Commands::TmtProvision(args) => tmt::tmt_provision(&sh, &args), Commands::CheckBuildsys => buildsys::check_buildsys(&sh, "Dockerfile".into()), + Commands::ValidateComposefsDigest(args) => validate_composefs_digest(&sh, &args), } } @@ -406,3 +416,56 @@ fn update_generated(sh: &Shell) -> Result<()> { Ok(()) } + +/// Validate that composefs digests match between build-time and install-time views. +/// +/// Compares dumpfiles generated from: +/// 1. The mounted filesystem (what seal-uki sees at build time via --mount=type=image) +/// 2. The OCI tar layers in containers-storage (what bootc upgrade sees) +/// +/// This helps debug mtime and metadata discrepancies that cause sealed boot failures. +#[context("Validating composefs digest")] +fn validate_composefs_digest(sh: &Shell, args: &ValidateComposefsDigestArgs) -> Result<()> { + let image = &args.image; + + // Generate dumpfile from mounted filesystem (build-time view) + let build_dumpfile = cmd!( + sh, + "podman run --rm --mount=type=image,source={image},target=/target {image} bootc container compute-composefs-digest /target" + ) + .read()?; + + // Generate dumpfile from containers-storage (install-time view) + let format_arg = "{{.Store.GraphRoot}}"; + let graphroot = cmd!(sh, "podman system info -f {format_arg}").read()?; + let graphroot = graphroot.trim(); + let storage_vol = format!("{graphroot}:/run/host-container-storage:ro"); + let storage_dumpfile = cmd!( + sh, + "podman run --rm --privileged --security-opt=label=disable + -v {storage_vol} + -v /sys:/sys:ro + --tmpfs=/var + {image} + bootc container compute-composefs-digest-from-storage" + ) + .read()?; + + // Compare dumpfiles + if build_dumpfile == storage_dumpfile { + println!("OK: Dumpfiles match"); + Ok(()) + } else { + println!("MISMATCH: Dumpfiles differ:"); + // Use diff via process substitution by writing to temp files + let tmpdir = tempfile::tempdir()?; + let build_path = tmpdir.path().join("build.dumpfile"); + let storage_path = tmpdir.path().join("storage.dumpfile"); + std::fs::write(&build_path, &build_dumpfile)?; + std::fs::write(&storage_path, &storage_dumpfile)?; + cmd!(sh, "diff -u {build_path} {storage_path}") + .ignore_status() + .run()?; + anyhow::bail!("Composefs digest mismatch"); + } +} diff --git a/docs/src/experimental-composefs.md b/docs/src/experimental-composefs.md index 37d94d7cd..7e3dd8dba 100644 --- a/docs/src/experimental-composefs.md +++ b/docs/src/experimental-composefs.md @@ -11,71 +11,148 @@ The composefs backend is an experimental alternative storage backend that uses [ **Status**: Experimental. The composefs backend is under active development and not yet suitable for production use. The feature is always compiled in as of bootc v1.10.1. -## Key Benefits +A key goal is custom "sealed" images, signed with your own Secure Boot keys. +This is based on [Unified Kernel Images](https://uapi-group.org/specifications/specs/unified_kernel_image/) +that embed a digest of the target container root filesystem, typically alongside a bootloader (such +as systemd-boot) also signed with your key. -- **Native container integration**: Direct use of container image formats without the ostree layer -- **UKI support**: First-class support for Unified Kernel Images (UKIs) and systemd-boot -- **Sealed images**: Enables building cryptographically sealed, securely-bootable images -- **Simpler architecture**: Reduces dependency on ostree as an implementation detail +## How Sealed Images Work + +A sealed image is a cryptographically signed and verified bootc image that provides end-to-end integrity protection. This is achieved through: + +- **Unified Kernel Images (UKIs)**: Combining kernel, initramfs, and boot parameters into a single signed binary +- **Composefs integration**: Using composefs with fsverity for content-addressed filesystem verification +- **Secure Boot**: Cryptographic signatures on both the UKI and systemd-boot loader + +A sealed image includes: + +1. **composefs digest**: A SHA-512 hash of the entire root filesystem, computed at build time +2. **Unified Kernel Image (UKI)**: A single EFI binary containing the kernel, initramfs, and kernel command line with the composefs digest embedded +3. **Secure Boot signature**: The UKI is signed with your private key + +At boot time, the composefs digest in the kernel command line (e.g., `composefs=`) is verified against the mounted root filesystem. This creates a chain of trust from firmware to userspace, ensuring the system will only boot if the root filesystem matches exactly what was signed. ## Building Sealed Images -### Using `just build-sealed` +### Prerequisites -This is an entrypoint focused on *bootc development* itself - it builds bootc -from source. +For sealed images, the container must: -```bash -just build-sealed -``` +- Include a kernel and initramfs in `/usr/lib/modules//` +- Have systemd-boot available (and NOT have `bootupd`) +- Not include a pre-built UKI (the build process generates one) -We are working on documenting individual steps to build a sealed image outside of -this tooling. +Sealed images also require: -## How Sealed Images Work +- Secure Boot support in the target system firmware +- A filesystem with fsverity support (e.g., ext4, btrfs) for the root partition -A sealed image includes: -- A Unified Kernel Image (UKI) that combines kernel, initramfs, and boot parameters -- The composefs fsverity digest embedded in the kernel command line -- Secure Boot signatures on both the UKI and systemd-boot loader +### Build Pattern: Compute Digest and Generate UKI in One Stage -The UKI is placed in `/boot/EFI/Linux/` and includes the composefs digest in its command line: -``` -composefs=${COMPOSEFS_FSVERITY} root=UUID=... -``` +The key to building sealed images is using a multi-stage Dockerfile where a separate stage mounts the target rootfs, computes its composefs digest, and generates the signed UKI in one step: -This enables the boot chain to verify the integrity of the root filesystem. +```dockerfile +# Build your rootfs with all packages and configuration +FROM as rootfs +RUN apt|dnf|zypper install ... && bootc container lint --fatal-warnings -## Installation +# Generate the sealed UKI in a tools stage +FROM as sealed-uki +RUN --mount=type=bind,from=rootfs,target=/target \ + --mount=type=secret,id=secureboot_key \ + --mount=type=secret,id=secureboot_cert <`) +4. The final stage copies the signed UKI into the rootfs without modifying any files used in the digest calculation -To run the composefs integration tests: +### The `bootc container compute-composefs-digest` Command ```bash -just test-composefs +bootc container compute-composefs-digest [PATH] ``` -This builds a sealed image and runs the composefs test suite using `bcvk` (bootc VM tooling). +Computes the composefs digest for a filesystem. The digest is a 128-character SHA-512 hex string that uniquely identifies the filesystem contents. + +**Options:** + +- `PATH`: Path to the filesystem root (default: `/target`) +- `--write-dumpfile-to `: Generate a dumpfile for debugging + +> **Note**: This command is currently hidden from `--help` output as it's part of the experimental composefs feature set. + +### Final Image Structure + +The sealed image should have: + +- The signed UKI at `/boot/EFI/Linux/.efi` +- A signed systemd-boot at `/boot/EFI/BOOT/BOOTX64.EFI` and `/boot/EFI/systemd/systemd-bootx64.efi` +- The raw `vmlinuz` and `initramfs.img` removed from `/usr/lib/modules//` (they're now embedded in the UKI) + +### External Signing Workflow + +For production environments with dedicated signing infrastructure: + +1. **Build unsigned UKI**: Compute digest and create an unsigned UKI (omit `--signtool` from ukify) +2. **Sign externally**: Take the unsigned UKI to your signing infrastructure +3. **Complete the seal**: Inject the signed UKI into the final image + +This workflow is planned for streamlining in future releases (see [#1498](https://github.com/bootc-dev/bootc/issues/1498)). + +## Developing and Testing bootc with composefs + +See [CONTRIBUTING.md](https://github.com/bootc-dev/bootc/blob/main/CONTRIBUTING.md) for information on building and testing bootc itself with composefs support. + +## Bootloader Support + +To use sealed images, the container image must have a UKI and systemd-boot installed (and not have `bootupd`). If these conditions are met, bootc will automatically detect and use the composefs backend during installation. + +## Installation + +There is a `--composefs-backend` option for `bootc install` to explicitly select a composefs backend apart from sealed images; this is not as heavily tested yet. ## Current Limitations -- **Experimental**: In particular, the on-disk formats are subject to change +- **Experimental**: The on-disk formats are subject to change - **UX refinement**: The user experience for building and managing sealed images is still being improved +- **SELinux**: Currently uses `enforcing=0` in the kernel command line (see [#1826](https://github.com/bootc-dev/bootc/issues/1826)) +- **kargs.d**: Custom kernel arguments from `/usr/lib/bootc/kargs.d` are not yet automatically included in sealed UKIs ## Related Issues - [#1190](https://github.com/bootc-dev/bootc/issues/1190) - composefs-native backend (main tracker) - [#1498](https://github.com/bootc-dev/bootc/issues/1498) - Sealed image build UX + implementation - [#1703](https://github.com/bootc-dev/bootc/issues/1703) - OCI config mismatch issues +- [#1826](https://github.com/bootc-dev/bootc/issues/1826) - SELinux enforcement with composefs - [#20](https://github.com/bootc-dev/bootc/issues/20) - Unified storage (long-term goal) - [#806](https://github.com/bootc-dev/bootc/issues/806) - UKI/systemd-boot tracker @@ -83,3 +160,6 @@ This builds a sealed image and runs the composefs test suite using `bcvk` (bootc - See [filesystem.md](filesystem.md) for information about composefs in the standard ostree backend - See [bootloaders.md](bootloaders.md) for bootloader configuration details +- [composefs-rs](https://github.com/containers/composefs-rs) - The underlying composefs implementation +- [Unified Kernel Images specification](https://uapi-group.org/specifications/specs/unified_kernel_image/) +- [ukify documentation](https://www.freedesktop.org/software/systemd/man/latest/ukify.html) - Tool for building UKIs diff --git a/hack/build-sealed b/hack/build-sealed deleted file mode 100755 index 22b668312..000000000 --- a/hack/build-sealed +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -set -euo pipefail -# This should turn into https://github.com/bootc-dev/bootc/issues/1498 - -dn=$(cd $(dirname $0) && pwd) - -variant=$1 -shift -# The un-sealed container image we want to use -input_image=$1 -shift -# The output container image -output_image=$1 -shift - -runv() { - set -x - "$@" -} - -case $variant in - ostree) - # Nothing to do - echo "Not building a sealed image; forwarding tag" - runv podman tag $input_image $output_image - exit 0 - ;; - composefs-sealeduki*) - ;; - *) - echo "Unknown variant=$variant" 1>&2; exit 1 - ;; -esac - -cfs_digest=$(${dn}/compute-composefs-digest $input_image) -runv podman build -t $output_image \ - --build-arg=COMPOSEFS_FSVERITY=${cfs_digest} --build-arg=base=${input_image} "$@" -f Dockerfile.cfsuki . diff --git a/tmt/tests/booted/test-install-outside-container.nu b/tmt/tests/booted/test-install-outside-container.nu index d854da87f..35ad6c7c0 100644 --- a/tmt/tests/booted/test-install-outside-container.nu +++ b/tmt/tests/booted/test-install-outside-container.nu @@ -6,11 +6,12 @@ use std assert use tap.nu -# In this test we install a generic image mainly because it keeps -# this test in theory independent of starting from a bootc host, -# but also because it's useful to test "skew" between the bootc binary -# doing the install and the target image. -let target_image = "docker://quay.io/centos-bootc/centos-bootc:stream10" +# FIXME: Revert to use generic images once https://github.com/bootc-dev/bootc/pull/1816 lands +# Currently using the booted image to avoid version skew between bootupd in +# the running system and the target image (e.g., different EFI file layouts). +# let target_image = "docker://quay.io/centos-bootc/centos-bootc:stream10" +bootc image copy-to-storage +let target_image = "containers-storage:localhost/bootc" # setup filesystem mkdir /var/mnt diff --git a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh index be9dd1d7b..5fe76d8a6 100644 --- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh +++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh @@ -16,28 +16,20 @@ set -xeuo pipefail -# Use a generic target image to test skew between the bootc binary doing -# the install and the target image -TARGET_IMAGE="docker://quay.io/centos-bootc/centos-bootc:stream10" +# Build a derived image with LBIs removed for installation +TARGET_IMAGE="localhost/bootc-install" echo "Testing bootc install to-filesystem with separate /var mount" -# Disable SELinux enforcement for the install -setenforce 0 +# Copy the currently booted image to container storage for podman to use +bootc image copy-to-storage -# Enable usr-overlay to allow modifications -bootc usr-overlay - -# Install required packages (bootc images are immutable, so we need to install -# after usr-overlay is enabled) -dnf install -y parted lvm2 dosfstools e2fsprogs - -# Mask off conflicting ostree state -if test -d /sysroot/ostree; then - mount --bind /usr/share/empty /sysroot/ostree -fi -rm -vrf /usr/lib/bootupd/updates -rm -vrf /usr/lib/bootc/bound-images.d +# Build a derived image that removes LBIs +cat > /tmp/Containerfile.drop-lbis <<'EOF' +FROM localhost/bootc +RUN rm -rf /usr/lib/bootc/bound-images.d/* +EOF +podman build -t "$TARGET_IMAGE" -f /tmp/Containerfile.drop-lbis # Create a 12GB sparse disk image in /var/tmp (not /tmp which may be tmpfs) DISK_IMG=/var/tmp/disk-var-mount-test.img @@ -91,7 +83,7 @@ vgcreate BL "$LVM_PART" # Create logical volumes lvcreate -L 4G -n var02 BL -lvcreate -L 5G -n root02 BL +lvcreate -l 100%FREE -n root02 BL # Create filesystems on logical volumes mkfs.ext4 -F /dev/BL/var02 @@ -122,8 +114,7 @@ echo "Filesystem layout:" mount | grep /var/mnt/target || true df -h /var/mnt/target /var/mnt/target/boot /var/mnt/target/boot/efi /var/mnt/target/var -# Run bootc install to-filesystem -# This should succeed and handle the separate /var mount correctly +# Run bootc install to-filesystem from within the container image under test podman run \ --rm --privileged \ -v /var/mnt/target:/target \ diff --git a/tmt/tests/booted/test-install-unified-flag.nu b/tmt/tests/booted/test-install-unified-flag.nu index e788967a4..389437618 100644 --- a/tmt/tests/booted/test-install-unified-flag.nu +++ b/tmt/tests/booted/test-install-unified-flag.nu @@ -11,9 +11,12 @@ use std assert use tap.nu -# Use a generic target image to test skew between the bootc binary doing -# the install and the target image -let target_image = "docker://quay.io/centos-bootc/centos-bootc:stream10" +# FIXME: Revert to use generic images once https://github.com/bootc-dev/bootc/pull/1816 lands +# Currently using the booted image to avoid version skew between bootupd in +# the running system and the target image (e.g., different EFI file layouts). +# let target_image = "docker://quay.io/centos-bootc/centos-bootc:stream10" +bootc image copy-to-storage +let target_image = "containers-storage:localhost/bootc" def main [] { tap begin "install with experimental unified storage flag"