diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ece1cc11 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Keep the build context small. +# +# Several test harnesses build images with the repository root as the context +# (testing/deb-install/test.sh and testing/dns-resolver/test.sh among them), and +# without this every one of them uploads the whole Cargo target directory to the +# daemon before running a build that does not use a single file from it. On a +# developer's machine that directory reaches double-digit gigabytes. +# +# Deliberately narrow. Nothing here excludes testing/**/.cache, which +# testing/deb-install/test.sh copies a package out of, and no Dockerfile in the +# tree copies from target/ on the host: examples/k8s-sidecar/Dockerfile builds +# its own inside the image with a multi-stage COPY --from. +target/ +.git/ +deploy/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26677706..212b7a80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,8 @@ env: # ───────────────────────────────────────────────────────────────────────────── # CI parity invariant # -# This GitHub integration matrix and the local default suite set +# This workflow's integration matrices — the `integration:` job and the +# `deb-install:` job — and the local default suite set # (testing/ci-local.sh) MUST run the same integration suites, EXCEPT for the # deliberate local-only entries below. Adding a suite to one runner without # the other means "local green" and "GitHub green" stop being equivalent. @@ -504,29 +505,6 @@ jobs: # recovers from delay, and never panics. - suite: stun-faults type: stun-faults - # ── Real-deb install across target distros ───────────────────── - # Boots a privileged systemd container per distro, runs - # `apt install ./fips_*.deb` with the locally-built package, - # then asserts end-to-end `.fips` resolution + the - # gateway/daemon default-pairing. The most thorough single - # test surface — exercises packaging, maintainer scripts, - # systemd unit ordering, real TUN, and the DNS responder - # filter on a per-distro resolver backend. - - suite: deb-install-debian12 - type: deb-install - scenario: debian12 - - suite: deb-install-debian13 - type: deb-install - scenario: debian13 - - suite: deb-install-ubuntu22 - type: deb-install - scenario: ubuntu22 - - suite: deb-install-ubuntu24 - type: deb-install - scenario: ubuntu24 - - suite: deb-install-ubuntu26 - type: deb-install - scenario: ubuntu26 # ── DNS resolver multi-backend coverage ──────────────────────── # Exercises every fips-dns-setup backend (resolved, dnsmasq, # NM+dnsmasq, dns-delegate, no-resolver) across five distros, @@ -734,36 +712,6 @@ jobs: docker compose -f testing/static/docker-compose.yml \ --profile gateway down --volumes --remove-orphans - # ── Real-deb install integration ──────────────────────────────────── - # The deb-install harness builds its own .deb from source in a - # cargo-deb builder image; the pre-built Linux binary from the - # build job is intentionally not used here so the test exercises - # the full packaging pipeline. ~5-7 min cold-cache on a fresh - # runner (.deb build dominates), ~1-2 min warm-cache. - - name: Run deb-install scenario - if: matrix.type == 'deb-install' - timeout-minutes: 25 - run: bash testing/deb-install/test.sh ${{ matrix.scenario }} - - - name: Collect logs on failure (deb-install) - if: matrix.type == 'deb-install' && failure() - run: | - docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do - echo "--- ${c} fips.service ---" - docker exec "$c" journalctl -u fips.service --no-pager 2>&1 | tail -100 || true - echo "--- ${c} fips-dns.service ---" - docker exec "$c" journalctl -u fips-dns.service --no-pager 2>&1 | tail -100 || true - echo "--- ${c} fips-gateway.service ---" - docker exec "$c" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -100 || true - done - - - name: Stop containers (deb-install) - if: matrix.type == 'deb-install' && always() - run: | - docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do - docker rm -f "$c" >/dev/null 2>&1 || true - done - # ── Native datagram API ───────────────────────────────────────────── # Reads FIPS_TEST_IMAGE rather than defaulting to a name, so it runs # against the image this workflow built. The two-node check creates and @@ -817,3 +765,113 @@ jobs: docker ps -a --filter "name=fips-dns-test-" --format '{{.Names}}' | while read -r c; do docker rm -f "$c" >/dev/null 2>&1 || true done + +# ───────────────────────────────────────────────────────────────────────────── +# Job 4 – The .deb the install suite installs +# +# Built once, here, by the same script the release workflow and a local run +# call, so the package the suite installs is built the way the shipped one is. +# That was not true before: each install leg built its own package on a fresh +# runner with no cache, so one run performed five complete Rust release builds +# and four were waste — and none of them was built the way the release is, so +# the suite could not exhibit a defect that only the release environment +# produced. +# +# The script builds in the pinned container from packaging/build-floor.env and +# runs testing/check-glibc-floor.sh on the result, so this job is also where a +# floor violation stops the run. +# ───────────────────────────────────────────────────────────────────────────── + deb-package: + name: Build .deb + runs-on: ubuntu-latest + needs: [build, test] + if: ${{ !inputs.skip_integration }} + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Build the .deb in the pinned build container + timeout-minutes: 30 + run: bash packaging/debian/build-deb-container.sh --output-dir deploy + + - name: Upload the .deb + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: fips-deb + path: deploy/fips_*.deb + if-no-files-found: error + retention-days: 1 + +# ───────────────────────────────────────────────────────────────────────────── +# Job 5 – Real-deb install across target distros +# +# Boots a privileged systemd container per distro, runs `apt install +# ./fips_*.deb` with the package job 4 built, then asserts end-to-end `.fips` +# resolution + the gateway/daemon default-pairing. The most thorough single +# test surface — exercises packaging, maintainer scripts, systemd unit +# ordering, real TUN, and the DNS responder filter on a per-distro resolver +# backend. +# +# A job of its own rather than legs of the integration matrix: the install legs +# are the only ones that need the package, and as integration legs every other +# integration suite would wait on the package build. +# +# The legs keep `type: deb-install` and `scenario:` because +# testing/check-ci-parity.sh reads those to match this matrix against the local +# suite's distro list; the steps below use `scenario:` only. +# ───────────────────────────────────────────────────────────────────────────── + deb-install: + name: Deb install (${{ matrix.scenario }}) + runs-on: ubuntu-latest + needs: [deb-package] + if: ${{ !inputs.skip_integration }} + + strategy: + fail-fast: false + matrix: + include: + - type: deb-install + scenario: debian12 + - type: deb-install + scenario: debian13 + - type: deb-install + scenario: ubuntu22 + - type: deb-install + scenario: ubuntu24 + - type: deb-install + scenario: ubuntu26 + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Download the .deb + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: fips-deb + path: _deb + + - name: Run deb-install scenario + timeout-minutes: 25 + run: | + deb=$(find _deb -maxdepth 1 -type f -name 'fips_*.deb' | sort | head -1) + [ -n "$deb" ] || { echo "no .deb in the downloaded artifact" >&2; exit 1; } + bash testing/deb-install/test.sh --deb "$deb" ${{ matrix.scenario }} + + - name: Collect logs on failure + if: failure() + run: | + docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do + echo "--- ${c} fips.service ---" + docker exec "$c" journalctl -u fips.service --no-pager 2>&1 | tail -100 || true + echo "--- ${c} fips-dns.service ---" + docker exec "$c" journalctl -u fips-dns.service --no-pager 2>&1 | tail -100 || true + echo "--- ${c} fips-gateway.service ---" + docker exec "$c" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -100 || true + done + + - name: Stop containers + if: always() + run: | + docker ps -a --filter "name=fips-deb-test-${{ matrix.scenario }}" --format '{{.Names}}' | while read -r c; do + docker rm -f "$c" >/dev/null 2>&1 || true + done diff --git a/.github/workflows/package-linux.yml b/.github/workflows/package-linux.yml index b0d53271..d4526c4d 100644 --- a/.github/workflows/package-linux.yml +++ b/.github/workflows/package-linux.yml @@ -49,6 +49,11 @@ jobs: runs-on: ${{ matrix.os }} needs: determine-versioning + # Both legs build in the same pinned container. Nothing passes --platform, + # so the arm runner resolves the arm64 variant of the base image and builds + # natively; the floor check runs on that package too, so an aarch64 build + # above the floor fails the leg rather than shipping. What the runner + # supplies is Docker and the checkout -- neither leg compiles on the host. strategy: fail-fast: false matrix: @@ -68,32 +73,76 @@ jobs: - name: Set SOURCE_DATE_EPOCH from git run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libdbus-1-dev llvm + # The host no longer compiles anything: the container carries the + # toolchain and the build dependencies. llvm is here only for llvm-strip, + # which build-tarball.sh uses on the binaries recovered from the package. + - name: Install host packaging tools + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends llvm - - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 - with: - cache: false - rustflags: '' + # Build in the pinned container rather than on the runner. The runner's + # glibc is what put a GLIBC_2.39 requirement into every Linux artifact + # from v0.3.0 onward, so the package installed cleanly and then could not + # load on Debian 12 or Ubuntu 22.04. packaging/build-floor.env declares + # the base image and the floor; the script builds there and runs + # testing/check-glibc-floor.sh on the package it produced, so a build that + # would ship an unloadable binary fails here instead of at the user. + # + # This is the same script ci.yml and a local run call, so the package that + # passes the five-distro suite is built the way this one is. + - name: Build Debian package in the pinned container + id: deb + shell: bash + run: | + set -euo pipefail + : ${GITHUB_OUTPUT:=/tmp/github_output} - - name: Cache Cargo registry + build - if: ${{ env.ACT != 'true' }} - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}- + packaging/debian/build-deb-container.sh \ + --version "${{ needs.determine-versioning.outputs.linux_package_version }}" \ + --output-dir deploy \ + | tee /tmp/build-deb-container.log - - name: Install cargo-deb - run: cargo install cargo-deb --version 3.6.3 --locked + # The script prints the package path as its last line of stdout. + # Only stdout is captured; its diagnostics go to stderr and straight + # to the job log, so nothing can land after the path. + DEB_FILE=$(tail -n 1 /tmp/build-deb-container.log) + if [[ ! -f "$DEB_FILE" ]]; then + echo "build-deb-container.sh did not name a package: '$DEB_FILE'" >&2 + exit 1 + fi + case "$DEB_FILE" in + *_${{ matrix.deb_arch }}.deb) ;; + *) + echo "Package $DEB_FILE is not ${{ matrix.deb_arch }}" >&2 + exit 1 + ;; + esac - - name: Build release binaries - run: cargo build --release + # Record it relative to the checkout: upload-artifact derives the + # archive layout from the common ancestor of its paths, and an + # absolute path here would nest the package under directories the + # release job's dist/*.deb glob does not look in. + echo "deb=${DEB_FILE#"$PWD"/}" >> "$GITHUB_OUTPUT" + + # The container writes its target directory to a Docker volume, so the + # runner's target/release is empty. Recover the four binaries from the + # package instead: they are the container-built ones, so the tarball ships + # what the package ships rather than a second, runner-built set that the + # floor check never saw and that no package manager would refuse. + - name: Stage container-built binaries for the tarball + shell: bash + run: | + set -euo pipefail + UNPACK=$(mktemp -d) + dpkg-deb -x "${{ steps.deb.outputs.deb }}" "$UNPACK" + mkdir -p target/release + for bin in fips fipsctl fipstop fips-gateway; do + if [[ ! -f "$UNPACK/usr/bin/$bin" ]]; then + echo "Package is missing usr/bin/$bin" >&2 + exit 1 + fi + install -m 0755 "$UNPACK/usr/bin/$bin" "target/release/$bin" + done + rm -rf "$UNPACK" - name: Build systemd tarball env: @@ -104,11 +153,23 @@ jobs: --arch "${{ matrix.artifact_arch }}" \ --no-build - - name: Build Debian package + # The tarball has no package manager to refuse it, so nothing at install + # time would notice a bad floor. Check the binaries out of the finished + # tarball, after the strip, rather than trusting that they are the same + # objects the package check already passed. + - name: Check the tarball against the declared glibc floor + shell: bash run: | - packaging/debian/build-deb.sh \ - --version "${{ needs.determine-versioning.outputs.linux_package_version }}" \ - --no-build + set -euo pipefail + TARBALL="deploy/fips-${{ needs.determine-versioning.outputs.linux_package_version }}-linux-${{ matrix.artifact_arch }}.tar.gz" + UNPACK=$(mktemp -d) + tar -xzf "$TARBALL" -C "$UNPACK" + testing/check-glibc-floor.sh \ + "$UNPACK"/*/fips \ + "$UNPACK"/*/fipsctl \ + "$UNPACK"/*/fipstop \ + "$UNPACK"/*/fips-gateway + rm -rf "$UNPACK" - name: Resolve Linux asset paths id: linux-assets @@ -122,14 +183,8 @@ jobs: exit 1 fi - DEB_FILE=$(find deploy -maxdepth 1 -type f -name "fips_*_${{ matrix.deb_arch }}.deb" | sort | head -n 1) - if [[ -z "$DEB_FILE" ]]; then - echo "Missing Debian package for ${{ matrix.deb_arch }}" >&2 - exit 1 - fi - echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT" - echo "deb=$DEB_FILE" >> "$GITHUB_OUTPUT" + echo "deb=${{ steps.deb.outputs.deb }}" >> "$GITHUB_OUTPUT" - name: SHA-256 hashes run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index c026b52f..dc67cdb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `fipstop` as "Own Loopback". `req_duplicate` returns to meaning only what it says. +#### Packaging + +- The Linux `.deb` and the systemd tarball now install and run on Debian 12 and + Ubuntu 22.04. Every Linux artifact from v0.3.0 through v0.5.0 was built on the + newest available runner, whose C library made the standard library's `pidfd` + references a hard `GLIBC_2.39` version requirement instead of the weak, + runtime-checked ones it is meant to compile to. The loader refuses an image on + that entry alone, so `fips`, `fipstop` and `fips-gateway` could not start; + `fipsctl` was unaffected, which is why an install that was checked by running + it looked healthy while the daemon was dead. No source code caused this and + none was changed. The Linux artifacts are now built in a container pinned to + the oldest supported distribution, declared with the floor in + `packaging/build-floor.env`, and every producer runs + `testing/check-glibc-floor.sh` on what it made, so a package or a tarball that + would not load fails the build rather than reaching a user. The declared + dependency is derived from the binaries instead of hand-written, so it states + the floor it was built against. + ## [0.5.0] - 2026-08-30 ### Added diff --git a/Cargo.toml b/Cargo.toml index 7dc3f8f9..99025faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,7 +76,15 @@ copyright = "2026 Johnathan Corgan" license-file = ["LICENSE", "0"] section = "net" priority = "optional" -depends = "libc6, systemd, libdbus-1-3" +# "$auto" runs dpkg-shlibdeps over each packaged binary and derives the real +# dependencies, including the libc6 version floor. Written by hand this field +# said only "libc6", which no glibc fails to satisfy, so a package whose +# binaries needed 2.39 installed happily on a system with 2.35 and the daemon +# then could not start. Deriving it also picks up a libdbus floor the hand +# written list lacked, and re-derives per architecture, which matters because +# arm64 links libdbus in all four binaries where amd64 links it in one. +# systemd stays by hand: nothing links it, so nothing can derive it. +depends = "$auto, systemd" recommends = "bluez" extended-description = """\ FIPS is a distributed, decentralized network routing protocol for mesh \ diff --git a/packaging/Makefile b/packaging/Makefile index f83e3ea6..69bb106f 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -4,7 +4,8 @@ # All outputs are placed in deploy/ at the project root. # # Usage: -# make deb Build a Debian/Ubuntu .deb package +# make deb Build a Debian/Ubuntu .deb package in the pinned container +# make deb-host Build a .deb with the host toolchain (see below) # make tarball Build a systemd install tarball # make ipk Build an OpenWrt .ipk package (opkg, OpenWrt 24.x and earlier) # make apk Build an OpenWrt .apk package (apk-tools, mandatory on OpenWrt 25+) @@ -19,11 +20,23 @@ SHELL := /bin/bash PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..) -.PHONY: all deb tarball ipk apk aur pkg freebsd zip clean +.PHONY: all deb deb-host tarball ipk apk aur pkg freebsd zip clean all: deb tarball +# `deb` builds in the pinned container so the package carries the declared glibc +# floor and can install on every supported distribution. It also checks that +# floor before it hands the package back. deb: + @bash $(PACKAGING_DIR)/debian/build-deb-container.sh + +# `deb-host` builds with whatever toolchain and C library the host has. It is +# for iterating locally and NOT for anything anyone else installs: on a modern +# host it produces a package that installs cleanly and then cannot start on +# Debian 12 or Ubuntu 22.04, which is the defect that made the container build +# necessary. Nothing checks its floor, deliberately, so the check stays +# attached to the artifact that ships. +deb-host: @bash $(PACKAGING_DIR)/debian/build-deb.sh tarball: diff --git a/packaging/README.md b/packaging/README.md index 44588744..3859942e 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -7,7 +7,7 @@ and `make apk` write to `dist/` instead. ## Quick Start ```sh -make deb # Debian/Ubuntu .deb +make deb # Debian/Ubuntu .deb (built in the pinned container) make tarball # systemd install tarball make ipk # OpenWrt .ipk (opkg, OpenWrt 24.x and earlier) make apk # OpenWrt .apk (apk-tools, mandatory on OpenWrt 25+) @@ -18,8 +18,29 @@ make zip # Windows .zip package make all # deb + tarball (default) ``` +## The two Debian build paths + +`make deb` builds in a container pinned to the oldest supported +distribution, named with the glibc floor in +[build-floor.env](build-floor.env), and checks the package it produced +against that floor before handing it back. Its only host prerequisite is +docker: the toolchain and the build dependencies live in the image. This +is the path the release workflow, the integration suite and the internal +builder all take, so a package that passes locally is built the way the +shipped one is. + +`make deb-host` is the old path. It builds on the host, at whatever glibc +the host has, and it is checked against nothing. Use it for local +iteration only. A package built on a current distribution records a +version dependency that the loader refuses on Debian 12 and Ubuntu 22.04, +which is what shipped in every Linux artifact from v0.3.0 through v0.5.0, +so it must not produce anything anyone else installs. + ## Build Prerequisites +The prerequisites below apply to the host-build targets. `make deb` needs +docker and nothing else. + These targets build FIPS from source, so the host needs a build environment in addition to a Rust toolchain (the version pinned in `rust-toolchain.toml` is auto-installed by rustup). diff --git a/packaging/build-floor.env b/packaging/build-floor.env new file mode 100644 index 00000000..08be956c --- /dev/null +++ b/packaging/build-floor.env @@ -0,0 +1,35 @@ +# The glibc floor for the Linux release artifacts, and the image that produces it. +# +# Sourced by packaging/debian/build-deb-container.sh and by +# testing/check-glibc-floor.sh. It exists so the floor is a decision written +# down in one place rather than a side effect of whichever build host ran last. +# +# The rule it encodes: FIPS installs on every version of a supported operating +# system that its distributor still supports for free. As of 2026-09-05 that is +# +# Ubuntu 22.04 glibc 2.35 free support ends April 2027 +# Debian 12 glibc 2.36 LTS ends 2028-06-30 +# Ubuntu 24.04 glibc 2.39 +# Debian 13 glibc 2.41 LTS ends 2030-06-30 +# Ubuntu 26.04 glibc 2.43 +# +# so the lowest is Ubuntu 22.04 and the floor is its 2.35. Debian 11 left the +# set on 2026-08-31 and is deliberately not counted. +# +# Deliberately NOT a GitHub runner label. Runner availability follows GitHub's +# rule of supporting the newest two images; the floor follows distributors' +# support windows. Those are different clocks, and ubuntu-26.04 being in preview +# means the ubuntu-22.04 runner will very likely retire before Canonical's date. +# A container base outlives the runner label and builds the same way on a +# developer's machine, which is what lets local and GitHub CI do identical work. +# +# Changing FIPS_GLIBC_FLOOR drops support for every distribution below it. Check +# the table above first, and expect check-glibc-floor.sh to hold you to it. + +# Base image for the build. Pinned to the oldest supported distribution. +FIPS_BUILD_IMAGE="ubuntu:22.04" + +# Highest glibc symbol version any shipped binary may require. Building on +# FIPS_BUILD_IMAGE currently yields 2.34, one step below this, so there is a +# little headroom: the check is an upper bound, not an equality. +FIPS_GLIBC_FLOOR="2.35" diff --git a/packaging/debian/Dockerfile.build b/packaging/debian/Dockerfile.build new file mode 100644 index 00000000..52182848 --- /dev/null +++ b/packaging/debian/Dockerfile.build @@ -0,0 +1,49 @@ +# Build image for the Linux release artifacts. +# +# Pinned to the oldest distribution FIPS supports, because the glibc a binary is +# linked against decides the glibc it will run on. See packaging/build-floor.env +# for the floor and the reasoning; BASE is passed from there, not written here, +# so there is one place to change it. +# +# This image carries the toolchain and the build dependencies only. It never +# carries the source: the source is mounted at run time, so editing a file does +# not invalidate the image and a warm rebuild costs seconds rather than minutes. +ARG BASE=ubuntu:22.04 +FROM ${BASE} + +ENV DEBIAN_FRONTEND=noninteractive + +# dpkg-dev is not in a bare ubuntu:22.04 and is what provides dpkg-shlibdeps, +# which cargo-deb's "$auto" dependency resolution shells out to. Without it the +# declared dependencies would silently lose their versions again. +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libdbus-1-dev \ + libclang-dev \ + clang \ + binutils \ + dpkg-dev \ + curl \ + ca-certificates \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# The toolchain version is passed in, read from rust-toolchain.toml by the +# calling script, and the image tag carries it -- so the image cannot drift from +# the compiler the rest of CI uses, and bumping the pin rebuilds the image. The +# builder this replaces installed `stable` and never copied rust-toolchain.toml, +# so it compiled with a different compiler from the release and nothing said so. +ARG RUST_TOOLCHAIN +ENV RUSTUP_HOME=/usr/local/rustup \ + CARGO_HOME=/usr/local/cargo \ + PATH=/usr/local/cargo/bin:$PATH +RUN test -n "${RUST_TOOLCHAIN}" \ + && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}" \ + && chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" + +ARG CARGO_DEB_VERSION=3.6.3 +RUN cargo install cargo-deb --version "${CARGO_DEB_VERSION}" --locked \ + && chmod -R a+w "$CARGO_HOME" + +WORKDIR /src diff --git a/packaging/debian/build-deb-container.sh b/packaging/debian/build-deb-container.sh new file mode 100755 index 00000000..1b147a54 --- /dev/null +++ b/packaging/debian/build-deb-container.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# Build the Debian package in the pinned build container, then check its floor. +# +# This is the one place the Linux artifacts are produced. The release workflow, +# the CI integration job and a local run all call it, so all three build the +# same way and a package that passes locally is the package that ships. That was +# not true before: the test suite built its own package inside a Debian 12 image +# while the release built on the newest GitHub runner, so the suite could not +# exhibit a defect that only the release environment produced -- and for five +# releases it did not. +# +# Usage: build-deb-container.sh [--output-dir DIR] [--version V] [--features LIST] +# [--rebuild-image] +# +# Requires docker. The image is cached between runs and rebuilt only when the +# Dockerfile or the floor changes; the source is mounted rather than copied, so +# editing code does not invalidate it. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# shellcheck source=../build-floor.env +. "$REPO_ROOT/packaging/build-floor.env" + +DEST_DIR="$REPO_ROOT/deploy" +VERSION="" +FEATURES="" +REBUILD_IMAGE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --output-dir) DEST_DIR="${2:?missing value for --output-dir}"; shift 2 ;; + --version) VERSION="${2:?missing value for --version}"; shift 2 ;; + --features) FEATURES="${2:?missing value for --features}"; shift 2 ;; + --rebuild-image) REBUILD_IMAGE=1; shift ;; + -h|--help) sed -n '2,17p' "$0"; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac +done + +command -v docker >/dev/null 2>&1 || { + echo "build-deb-container: docker is required and was not found." >&2 + exit 2 +} + +# Read the toolchain from the pin rather than choosing one here, and put it in +# the tag so a bump rebuilds the image instead of silently reusing a stale one. +RUST_TOOLCHAIN=$(awk -F'"' '/^channel *=/{print $2; exit}' "$REPO_ROOT/rust-toolchain.toml") +[ -n "$RUST_TOOLCHAIN" ] || { + echo "build-deb-container: could not read channel from rust-toolchain.toml" >&2 + exit 2 +} + +IMAGE_TAG="fips-deb-builder:${FIPS_BUILD_IMAGE//[:\/]/-}-rust${RUST_TOOLCHAIN}" + +if [ "$REBUILD_IMAGE" -eq 1 ] || ! docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then + echo "=== Building $IMAGE_TAG from $FIPS_BUILD_IMAGE with Rust $RUST_TOOLCHAIN ===" >&2 + docker build \ + --build-arg "BASE=$FIPS_BUILD_IMAGE" \ + --build-arg "RUST_TOOLCHAIN=$RUST_TOOLCHAIN" \ + -t "$IMAGE_TAG" \ + -f "$SCRIPT_DIR/Dockerfile.build" \ + "$SCRIPT_DIR" +else + echo "=== Using cached $IMAGE_TAG ===" >&2 +fi + +# Derive the version and the timestamp on the host, where git works, and pass +# both in. The container then never runs git, which matters for two reasons: a +# worktree's .git is a file pointing outside the mount and would not resolve, +# and a bind-mounted repository trips git's dubious-ownership check. +if [ -z "$VERSION" ]; then + CRATE_VERSION=$(awk -F'"' '/^version = /{print $2; exit}' "$REPO_ROOT/Cargo.toml") + if [[ "$CRATE_VERSION" == *-dev ]]; then + GIT_DATE=$(git -C "$REPO_ROOT" log -1 --format=%cs | tr -d '-') + GIT_SHA=$(git -C "$REPO_ROOT" rev-parse --short HEAD) + DIRTY="" + [ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null)" ] && DIRTY=".dirty" + VERSION="${CRATE_VERSION%-dev}~dev+git${GIT_DATE}.${GIT_SHA}${DIRTY}-1" + else + VERSION="$CRATE_VERSION" + fi +fi +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$REPO_ROOT" log -1 --format=%ct)}" + +mkdir -p "$DEST_DIR" +DEST_ABS="$(cd "$DEST_DIR" && pwd)" + +echo "=== Building fips $VERSION in $IMAGE_TAG ===" >&2 + +# A feature build hands the whole job to build-deb.sh rather than pre-building: +# --features has to reach cargo, and build-deb.sh is also what marks the version +# so a feature package is distinguishable from the default build of the same +# commit. It refuses --features with --no-build for exactly that reason, so the +# two cases cannot share one command. +if [ -n "$FEATURES" ]; then + # build-deb.sh does the whole job here: --features has to reach cargo, and + # it is also what marks the version so a feature package is distinguishable + # from the default build of the same commit. It refuses --features with + # --no-build for that reason, so the two cases cannot share one command. + # The version still comes from the host, because the image has no git. + BUILD_CMD="packaging/debian/build-deb.sh --features '$FEATURES' --version '$VERSION' --output-dir /out" +else + BUILD_CMD="cargo build --release --locked + packaging/debian/build-deb.sh --no-build --version '$VERSION' --output-dir /out" +fi + +# The source is mounted read-only so a build cannot leave artifacts in the tree. +# CARGO_TARGET_DIR and the registry live in named volumes, which is what makes a +# second run fast; they are per-base-image so a floor change does not reuse +# objects linked against the wrong C library. +VOL_SUFFIX="${FIPS_BUILD_IMAGE//[:\/]/-}" +docker run --rm \ + -v "$REPO_ROOT":/src:ro \ + -v "$DEST_ABS":/out \ + -v "fips-deb-target-${VOL_SUFFIX}":/target \ + -v "fips-deb-registry-${VOL_SUFFIX}":/usr/local/cargo/registry \ + -e CARGO_TARGET_DIR=/target \ + -e SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \ + -w /src \ + "$IMAGE_TAG" \ + bash -euo pipefail -c "$BUILD_CMD" >&2 + +DEB=$(find "$DEST_ABS" -maxdepth 1 -name "fips_*_*.deb" -newermt '-10 minutes' -print | sort | tail -1) +[ -n "$DEB" ] || { echo "build-deb-container: no .deb was produced." >&2; exit 1; } + +# Check the artifact here rather than in one workflow, so every producer is +# gated: the release, the CI job, a local run and packaging/Makefile all reach +# the check through this script. +"$REPO_ROOT/testing/check-glibc-floor.sh" "$DEB" >&2 + +echo "=== Built $DEB ===" >&2 +printf '%s\n' "$DEB" diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index 188255ee..f1088f92 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -23,6 +23,9 @@ Options: --features Cargo features to build with (comma-separated). Marks the auto-derived Version so the package is distinguishable from a default build of the same commit. + --output-dir Where to put the finished .deb. Defaults to deploy/ under + the project root. Exists so the container build can write + to a mount and leave the source tree read-only. -h, --help Show this help EOF } @@ -31,6 +34,7 @@ TARGET_TRIPLE="" VERSION_OVERRIDE="" NO_BUILD=0 FEATURES="" +DEST_DIR="" while [[ $# -gt 0 ]]; do case "$1" in @@ -50,6 +54,10 @@ while [[ $# -gt 0 ]]; do FEATURES="${2:?missing value for --features}" shift 2 ;; + --output-dir) + DEST_DIR="${2:?missing value for --output-dir}" + shift 2 + ;; -h|--help) usage exit 0 @@ -124,9 +132,20 @@ if [[ -z "${VERSION_OVERRIDE}" ]]; then echo "Auto-derived dev Version: ${VERSION_OVERRIDE}" fi elif [[ -n "${FEATURES}" ]]; then - echo "Warning: --version was given with --features, so the Version carries no" >&2 - echo "feature marker and this package is indistinguishable from a default" >&2 - echo "build of the same commit. Mark it yourself if that matters." >&2 + # An explicit version needs the same marker for the same reason, and it is + # the only way a caller that cannot derive the version here can get one. + # The container build is that caller: it derives the version on the host + # because the image has no git, and the source is mounted read-only from a + # worktree whose .git is a file pointing outside the mount. + if [[ "${VERSION_OVERRIDE}" == *"+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')"* ]]; then + : # already marked by the caller + elif [[ "${VERSION_OVERRIDE}" == *-* ]]; then + # Split off the Debian revision so the marker lands on the upstream part. + VERSION_OVERRIDE="${VERSION_OVERRIDE%-*}+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')-${VERSION_OVERRIDE##*-}" + else + VERSION_OVERRIDE="${VERSION_OVERRIDE}+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')" + fi + echo "Feature-marked Version: ${VERSION_OVERRIDE}" fi # Build the .deb package @@ -149,8 +168,11 @@ if [[ -n "${FEATURES}" ]]; then fi cargo "${cargo_args[@]}" -# Move output to deploy/ -mkdir -p deploy +# Move output to the requested directory, or deploy/ by default. Note the +# distinction from OUTPUT_DIR above, which is cargo-deb's temporary staging +# directory and is removed by the EXIT trap. +: "${DEST_DIR:=deploy}" +mkdir -p "${DEST_DIR}" DEB_FILE=$(find "${OUTPUT_DIR}" -maxdepth 1 -name '*.deb' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2) if [ -z "${DEB_FILE}" ]; then @@ -158,10 +180,10 @@ if [ -z "${DEB_FILE}" ]; then exit 1 fi -cp "${DEB_FILE}" deploy/ +cp "${DEB_FILE}" "${DEST_DIR}/" BASENAME=$(basename "${DEB_FILE}") -echo "Package built: deploy/${BASENAME}" +echo "Package built: ${DEST_DIR}/${BASENAME}" echo "" -echo "Install with: sudo dpkg -i deploy/${BASENAME}" +echo "Install with: sudo dpkg -i ${DEST_DIR}/${BASENAME}" echo "Remove with: sudo dpkg -r fips" echo "Purge with: sudo dpkg -P fips (removes config and identity keys)" diff --git a/testing/README.md b/testing/README.md index f2b20f65..9658b5c6 100644 --- a/testing/README.md +++ b/testing/README.md @@ -125,11 +125,12 @@ machines. Not a Docker harness. [`ci-local.sh`](ci-local.sh) runs the full local CI pipeline — build, clippy, unit tests, and the integration suites (including the chaos -scenarios) — mirroring the GitHub `ci.yml` integration matrix. Run +scenarios) — mirroring the GitHub `ci.yml` integration matrices. Run `./ci-local.sh --help` for the full option list and `--list` for the available suites. Every run starts with a parity check that verifies the local suite set covers the same work as the GitHub matrix, per scenario for -chaos and per distro for deb-install; a divergence fails the run. GitHub +chaos and per distro for deb-install, across every job that carries a +matrix; a divergence fails the run. GitHub runs the same check as its own `ci-parity` job. `--check-parity` runs it alone (see [check-ci-parity.sh](check-ci-parity.sh)). diff --git a/testing/check-ci-parity.sh b/testing/check-ci-parity.sh index a3690636..9c2fd897 100755 --- a/testing/check-ci-parity.sh +++ b/testing/check-ci-parity.sh @@ -1,10 +1,10 @@ #!/bin/bash # ── CI parity invariant guard ─────────────────────────────────────────────── -# The GitHub integration matrix (.github/workflows/ci.yml) and the local -# default suite set (ci-local.sh) MUST run the same integration suites, -# EXCEPT for the deliberate local-only entries listed below. Adding a suite -# to one runner without the other means "local green" and "GitHub green" stop -# being equivalent claims. +# The GitHub integration matrices (.github/workflows/ci.yml, swept across every +# job) and the local default suite set (ci-local.sh) MUST run the same +# integration suites, EXCEPT for the deliberate local-only entries listed below. +# Adding a suite to one runner without the other means "local green" and +# "GitHub green" stop being equivalent claims. # # Deliberate local-only (NOT on the GitHub gate), with reason: # tor-socks5 — requires live Tor network; opt-in via --with-tor, @@ -19,8 +19,9 @@ # names differ cosmetically between runners and are ignored # — `scenario:` is the identity. # deb-install — per distro. GitHub splits into per-distro legs carrying -# `scenario:`; local runs the same distro set in one suite, -# enumerated by ALL_SCENARIOS in deb-install/test.sh. +# `scenario:`, in a job of their own; local runs the same +# distro set in one suite, enumerated by ALL_SCENARIOS in +# deb-install/test.sh. # everything else — per suite name. # # dns-resolver is the one leg still compared at leg granularity rather than @@ -134,7 +135,21 @@ for name, entries in arrays.items(): with open(ci_yml_path, encoding="utf-8") as fh: doc = yaml.safe_load(fh) -include = doc["jobs"]["integration"]["strategy"]["matrix"]["include"] +# Sweep every job's matrix rather than one named job: the install legs live in +# a job of their own so the rest of the integration matrix does not wait on the +# package build, and a guard keyed to a job name reports them as local-only the +# moment they move. Identity comes from the leg's own fields, so where a leg +# lives does not matter; a leg deleted from every job still shows up as +# local-only, because the local side is the reference. +include = [] +for job in (doc.get("jobs") or {}).values(): + legs = (((job.get("strategy") or {}).get("matrix") or {}).get("include") or []) + if isinstance(legs, list): + include += [leg for leg in legs if isinstance(leg, dict)] +if not include: + print("check-ci-parity: no matrix include: found in any job of " + f"{ci_yml_path}; cannot verify CI parity", file=sys.stderr) + sys.exit(2) github_chaos, github_deb, github = {}, set(), set() malformed = [] for leg in include: diff --git a/testing/check-glibc-floor.sh b/testing/check-glibc-floor.sh new file mode 100755 index 00000000..1ca81deb --- /dev/null +++ b/testing/check-glibc-floor.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# Fail when a shipped binary needs a newer glibc than the declared floor. +# +# The defect this exists to catch installs cleanly and then cannot run. Rust's +# standard library references pidfd_spawnp and pidfd_getpid as weak undefined +# symbols guarded by a runtime check, so a binary is meant to fall back where +# the C library lacks them. Linking against a glibc that HAS them records a +# version dependency instead, and the loader refuses the whole image on that +# entry alone regardless of the symbols being weak. Every Linux artifact from +# v0.3.0 to v0.5.0 shipped that way and could not start on Debian 12 or Ubuntu +# 22.04, while apt reported success and fipsctl -- which never spawns a process +# and so never referenced those symbols -- ran perfectly. +# +# Usage: check-glibc-floor.sh ... +# A .deb is unpacked and every executable under usr/bin is checked. +# Anything else is treated as a single ELF binary. +# +# Reads the floor from packaging/build-floor.env unless FIPS_GLIBC_FLOOR is set. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [ -z "${FIPS_GLIBC_FLOOR:-}" ]; then + # shellcheck source=../packaging/build-floor.env + . "$REPO_ROOT/packaging/build-floor.env" +fi +FLOOR="${FIPS_GLIBC_FLOOR:?no floor declared}" + +for tool in readelf dpkg dpkg-deb; do + command -v "$tool" >/dev/null 2>&1 || { + echo "check-glibc-floor: $tool is not installed; cannot check anything." >&2 + echo " Refusing to report a pass I did not establish." >&2 + exit 2 + } +done + +# The maximum glibc version a binary requires. +# +# Reads the `Version needs` section, which is the table the dynamic loader +# enforces and the one that carries the fatal entry. Do NOT compute this from +# `objdump -T | grep GLIBC_ | sort -V | tail -1`: that sorts whole lines, the +# version is not the leading field, and a data symbol at GLIBC_2.2.5 therefore +# sorts last. Measured against the shipped v0.5.0 fips binary, that pipeline +# reports 2.2.5 for a binary whose real floor is 2.39 -- it would have passed +# every affected release. +# +# Prints nothing and returns 1 when it finds no GLIBC requirement at all, so an +# unreadable or non-dynamic input cannot be scored as a pass. +max_glibc_need() { + local bin="$1" found + found=$(readelf -VW "$bin" 2>/dev/null \ + | awk '/Version needs section/,0' \ + | grep -oE 'GLIBC_[0-9.]+' \ + | sed 's/GLIBC_//' \ + | sort -V \ + | tail -1) || true + [ -n "$found" ] || return 1 + printf '%s\n' "$found" + # Explicit: the caller tests this status to tell "no requirement" from a + # value, so it must not be whatever printf happened to return. + return 0 +} + +FAILED=0 +CHECKED=0 + +is_elf() { readelf -hW "$1" >/dev/null 2>&1; } + +check_binary() { + local bin="$1" label="$2" need + if ! need=$(max_glibc_need "$bin"); then + # A static binary is a legitimate no-requirement case, so distinguish + # it from a file that could not be read rather than passing both. + if readelf -hW "$bin" >/dev/null 2>&1; then + echo " ok $label (no glibc version requirement)" + CHECKED=$((CHECKED + 1)) + return + fi + echo " ERROR $label is not a readable ELF object" >&2 + FAILED=$((FAILED + 1)) + return + fi + CHECKED=$((CHECKED + 1)) + if dpkg --compare-versions "$need" gt "$FLOOR"; then + echo " FAIL $label needs glibc $need, above the declared floor $FLOOR" >&2 + FAILED=$((FAILED + 1)) + else + echo " ok $label needs glibc $need" + fi +} + +check_deb() { + local deb="$1" tmp + tmp=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf '$tmp'" RETURN + dpkg-deb -x "$deb" "$tmp" + # A package legitimately ships executable shell scripts alongside its + # binaries -- fips-dns-setup and its teardown are two -- so filter to ELF + # objects rather than treating a script as an unreadable binary. A .deb + # with no ELF object at all is still an error: it means the glob or the + # layout moved and this check examined nothing. + local found=0 f + while IFS= read -r -d '' f; do + is_elf "$f" || continue + found=1 + check_binary "$f" "$(basename "$deb"):$(basename "$f")" + done < <(find "$tmp" -type f -perm -u+x -print0) + if [ "$found" -eq 0 ]; then + echo " ERROR $(basename "$deb") contains no ELF executables" >&2 + FAILED=$((FAILED + 1)) + fi +} + +[ $# -gt 0 ] || { + echo "usage: check-glibc-floor.sh ..." >&2 + exit 2 +} + +echo "=== glibc floor check (declared floor: $FLOOR) ===" +for arg in "$@"; do + [ -e "$arg" ] || { echo " ERROR $arg does not exist" >&2; FAILED=$((FAILED + 1)); continue; } + case "$arg" in + *.deb) check_deb "$arg" ;; + *) check_binary "$arg" "$(basename "$arg")" ;; + esac +done + +# Nothing examined is a failure, not a pass. An argument list that matched no +# binary means the caller's glob went stale, and reporting that as green is how +# a guard quietly stops guarding. +if [ "$CHECKED" -eq 0 ] && [ "$FAILED" -eq 0 ]; then + echo "check-glibc-floor: examined no binaries; refusing to report a pass." >&2 + exit 2 +fi + +if [ "$FAILED" -ne 0 ]; then + echo "check-glibc-floor: $FAILED problem(s) across $CHECKED binaries." >&2 + echo " A binary above the floor installs cleanly and then fails to start." >&2 + echo " Build through packaging/debian/build-deb-container.sh, which pins" >&2 + echo " the build image to the oldest supported distribution." >&2 + exit 1 +fi + +echo "=== glibc floor check passed ($CHECKED binaries, all at or below $FLOOR) ===" diff --git a/testing/ci-local.sh b/testing/ci-local.sh index d6923301..9b097be5 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -1006,11 +1006,49 @@ run_dns_resolver() { # that wedges -- a docker daemon that stops answering, a container that never # boots. 40 minutes is well above the observed cold-cache cost of a full # five-distro run and is not a performance budget. +# +# It bounds the container build and the five installs separately rather than +# both together: the budget was sized for the installs, and a cold build that +# ate into it would shrink theirs. Neither phase is left unbounded, which is +# the property this exists for. DEB_INSTALL_TIMEOUT=${DEB_INSTALL_TIMEOUT:-2400} run_deb_install() { - info "[deb-install] Running multi-distro test (slow — builds .deb + per-distro install)" - local rc=0 - timeout "$DEB_INSTALL_TIMEOUT" bash testing/deb-install/test.sh 2>&1 || rc=$? + # Build once through the shared container script, then install that one + # artifact into every distro. The harness would build its own package if + # handed none, and that fallback goes through the same script -- but the + # GitHub job builds explicitly and passes `--deb`, so doing it explicitly + # here too makes the two systems read as the same work rather than leaving + # a reader to discover that a fallback happens to match. + info "[deb-install] Building the package in the pinned container" + local build_log deb rc=0 + build_log=$(mktemp "/tmp/ci-deb-install-build.XXXXXX") + # stdout carries the package path on its last line, so it is captured; + # stderr stays on the console so build progress is still visible live. + timeout "$DEB_INSTALL_TIMEOUT" \ + bash packaging/debian/build-deb-container.sh | tee "$build_log" || rc=$? + if [[ $rc -ne 0 ]]; then + if [[ $rc -eq 124 ]]; then + echo " ERROR: the container build exceeded ${DEB_INSTALL_TIMEOUT}s and was killed;" >&2 + echo " no package was produced, so this is not an assertion failure." >&2 + else + echo " ERROR: the container build failed (exit $rc); no package to install." >&2 + fi + rm -f "$build_log" + record "deb-install" "$rc" + return + fi + deb=$(tail -n 1 "$build_log") + rm -f "$build_log" + if [[ -z "$deb" || ! -f "$deb" ]]; then + echo " ERROR: the container build reported success but its last line of stdout" >&2 + echo " was not a package path: '${deb}'" >&2 + record "deb-install" 1 + return + fi + + info "[deb-install] Running multi-distro install test against $deb" + rc=0 + timeout "$DEB_INSTALL_TIMEOUT" bash testing/deb-install/test.sh --deb "$deb" 2>&1 || rc=$? if [[ $rc -eq 124 ]]; then # Say so explicitly. A bare red here reads as a failed assertion, and # the difference matters: a timeout means no assertion was reached. diff --git a/testing/deb-install/test.sh b/testing/deb-install/test.sh index 81f4de65..3eaab9ca 100755 --- a/testing/deb-install/test.sh +++ b/testing/deb-install/test.sh @@ -51,6 +51,11 @@ PASS=0 FAIL=0 SKIP=0 +# Set by --deb. DEB_PREPARED keeps the copy-into-cache to a single operation +# however many scenarios run in one process. +SUPPLIED_DEB="" +DEB_PREPARED=0 + # ───────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────── @@ -169,6 +174,25 @@ container_systemd_version() { build_deb() { mkdir -p "$DEB_CACHE_DIR" + # A supplied package wins outright and bypasses the staleness check below. + # That check compares mtimes, so a cached package could otherwise beat the + # artifact the caller explicitly handed over, which is exactly the silent + # substitution this suite exists to stop making. + if [ -n "$SUPPLIED_DEB" ]; then + if [ "$DEB_PREPARED" -eq 1 ]; then + return 0 + fi + if [ ! -f "$SUPPLIED_DEB" ]; then + echo " ERROR: --deb $SUPPLIED_DEB does not exist" >&2 + return 1 + fi + rm -f "$DEB_CACHE_DIR"/*.deb + cp "$SUPPLIED_DEB" "$DEB_CACHE_DIR/" + DEB_PREPARED=1 + log "Installing the supplied package $(basename "$SUPPLIED_DEB")" + return 0 + fi + local cached_deb cached_deb=$(ls "$DEB_CACHE_DIR"/fips_*_amd64.deb 2>/dev/null | head -1) @@ -188,45 +212,25 @@ build_deb() { log "No cached .deb, building" fi - local builder_tag="fips-deb-test:builder" - log "Building Debian 12 cargo-deb builder image (slow on first run)" - docker build -t "$builder_tag" -f - "$REPO_ROOT" <<'DOCKERFILE' >/dev/null -FROM debian:12 -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential pkg-config libdbus-1-dev curl ca-certificates \ - libclang-dev clang && \ - apt-get clean && rm -rf /var/lib/apt/lists/* -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --default-toolchain stable --profile minimal -ENV PATH="/root/.cargo/bin:${PATH}" -RUN cargo install cargo-deb --version 3.6.3 --locked -WORKDIR /src -COPY Cargo.toml Cargo.lock build.rs LICENSE README.md ./ -COPY src ./src -COPY packaging ./packaging -COPY docs ./docs -RUN cargo build --release && cargo deb --no-build -DOCKERFILE - - if [ ! "$(docker images -q "$builder_tag" 2>/dev/null)" ]; then - echo " ERROR: builder image build failed" + # Build through the shared container script rather than a builder defined + # here. This suite used to compile its own package inside a Debian 12 image + # while the release compiled on the newest GitHub runner, so the package it + # tested had a lower glibc floor than the package users received and could + # not exhibit a defect that only the release environment produced. It stayed + # green through five releases that could not start on two of the five + # distributions in its own matrix. + log "Building the .deb in the pinned build container (slow on first run)" + if ! bash "$REPO_ROOT/packaging/debian/build-deb-container.sh" \ + --output-dir "$DEB_CACHE_DIR" >&2; then + echo " ERROR: container build failed" >&2 return 1 fi - log "Extracting .deb from builder image" - rm -f "$DEB_CACHE_DIR"/*.deb - local cid - cid=$(docker create "$builder_tag") - docker cp "$cid:/src/target/debian/." "$DEB_CACHE_DIR/" >/dev/null 2>&1 - docker rm "$cid" >/dev/null - # Cargo-deb leaves intermediate artifacts; keep just the .deb. - find "$DEB_CACHE_DIR" -mindepth 1 -not -name 'fips_*_amd64.deb' -delete 2>/dev/null || true cached_deb=$(ls "$DEB_CACHE_DIR"/fips_*_amd64.deb 2>/dev/null | head -1) if [ -n "$cached_deb" ]; then log "Cached at $cached_deb ($(stat -c %s "$cached_deb") bytes)" else - echo " ERROR: no .deb produced by cargo-deb" + echo " ERROR: no .deb produced by the container build" >&2 return 1 fi } @@ -565,6 +569,35 @@ test_ubuntu26() { _run_deb_install_scenario ubuntu26 ubuntu:26.04; } ALL_SCENARIOS="debian12 debian13 ubuntu22 ubuntu24 ubuntu26" +# `--deb PATH` installs a package the caller already built, which is how one +# build serves all five distributions and how GitHub CI and a local run come to +# do the same work: both build once through the container script and hand the +# result here. Keep ALL_SCENARIOS above on its own line at column zero; +# check-ci-parity.sh reads it to compare this matrix against the GitHub one. +_args=() +while [ $# -gt 0 ]; do + case "$1" in + --deb) + SUPPLIED_DEB="${2:?--deb requires a path}" + shift 2 + ;; + -h|--help) + echo "usage: test.sh [--deb PATH] [scenario ...]" + echo "scenarios: $ALL_SCENARIOS" + exit 0 + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + _args+=("$1") + shift + ;; + esac +done +set -- ${_args[@]+"${_args[@]}"} + if [ $# -eq 0 ]; then scenarios="$ALL_SCENARIOS" else