Files
fips/testing/rpm-install/test.sh
Gustavo Lima Chaves 92b431c3e6 Install-test the RPM on AlmaLinux 9 and Fedora
The RPM is built on every push and was installed by nobody. rpm-install
is the counterpart of deb-install: it boots systemd containers, installs
fips-mesh with dnf, and checks what an operator meets.

The package under test is built the way the release builds it: binaries
recovered from the container-built .deb, packaged with
build-rpm-container.sh --no-build. That is the path only the release
workflow takes -- make rpm and make rpm-host both reach the other branch
-- so the suite also asserts the script's own exit status and that it
refuses --features on that path. ci-local.sh and the GitHub job hand it
the .deb deb-install already uses, so this adds no build.

Four scenarios:

- almalinux9 and fedora: install leaves fips and fips-dns enabled and
  not started, fips-firewall and fips-gateway disabled; fips.yaml is
  seeded 600 and rpm reports it owned by no package; hosts and fips.nft
  carry the %config and noreplace file flags; /run/fips ends up 750
  root:fips. Started by hand, fips-dns picks global-drop-in on EL9's
  systemd 252 and dns-delegate on Fedora's 259, and a .fips name
  resolves end to end -- the first install-suite coverage of
  dns-delegate in the tree. The erase runs with fips-dns stopped and all
  four DNS routing files planted, so %preun's stop runs no teardown and
  only %postun can remove them; it must, and must restart
  systemd-resolved (a new InvocationID). /etc/fips is kept.

- no-resolver: a stock EL9 host, with no systemd-resolved, dnsmasq or
  NetworkManager dnsmasq plugin, so no backend applies. What it pins is
  the documented outcome rather than a failure: fips-dns.service
  settles active having configured nothing, the saved backend is none,
  the journal carries the warning, nothing is written, and fips0 is up.
  The Debian legs install systemd-resolved into their images too, so
  the other scenarios test a host with a resolver on both sides.

- upgrade: a running daemon upgraded to the same binaries at a higher
  Release. The transaction must not wait on the restart, the daemon
  must come back on a new PID (both PIDs read, neither empty nor 0), a
  host that never enabled the gateway must not acquire it, an
  operator's edit to fips.yaml must survive and no .rpmnew or .rpmsave
  may appear. It also checks the installed %postun queues the restart
  with --no-block directly, because the timing alone does not catch
  that: with a healthy daemon, a blocking try-restart finishes in
  seconds as well. With --no-block removed from the spec, the upgrade
  took 1 s, the daemon restarted, and only the scriptlet check failed.
  With --rpm, the binaries it repacks are recovered from the given
  package, never left over from an earlier run.

A check that a failed docker exec could satisfy is not counted as a
pass: absence is exit 1 from test -e and nothing else, and ownership is
rpm's own "is not owned by any package".

check-ci-parity.sh compares the scenarios per name, as it does
deb-install's distros; with the upgrade leg removed from ci.yml it
reports it local-only. The container plumbing comes from
testing/lib/systemd-scenario.sh, as deb-install's does, so the two boot
and time out the same way.

Verified: all 77 checks pass locally from a given .deb with the Debian
builder image absent, as on a CI runner. With the systemd-resolved
restart removed from %postun, the erase check fails on exactly that.
2026-10-06 22:01:57 -07:00

817 lines
32 KiB
Bash
Executable File

#!/bin/bash
# Test the fips-mesh RPM install path across the RPM-family distributions.
#
# The artifact under test is the one a release publishes: the .deb is built in
# the pinned container, its binaries are recovered, and
# packaging/rpm/build-rpm-container.sh --no-build packages those. That is the
# path only the release workflow takes -- `make rpm` and `make rpm-host` both
# reach the other branch -- so a defect on it is invisible to anything a
# developer runs by hand, which is how one shipped once already.
#
# Each scenario boots a systemd container with TUN access, installs the
# package with dnf, and checks what an operator would meet:
#
# almalinux9 install, unit state, file placement, end-to-end .fips
# resolution, then erase
# fedora the same, on a systemd new enough for the dns-delegate
# backend
# no-resolver a stock EL9 host, where no resolver backend applies: the
# daemon runs and `.fips` does not resolve, which is a
# documented outcome rather than a failure
# upgrade a running daemon upgraded in place: the transaction must not
# wait on the restart, the daemon must come back, and a host
# that never enabled the gateway must not acquire it
#
# Usage: ./test.sh [--deb PATH | --rpm PATH] [scenario ...]
# --deb package the binaries from this container-built .deb, instead of
# building one (ci-local.sh passes the .deb it shares with
# deb-install and dns-resolver)
# --rpm test this package as given, skipping the build
# No scenario args = run all scenarios.
#
# Requirements: Docker able to grant SYS_ADMIN and NET_ADMIN and an
# unconfined AppArmor profile (see testing/lib/systemd-container.sh),
# /dev/net/tun on the host.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=SCRIPTDIR/../lib/systemd-container.sh
source "$SCRIPT_DIR/../lib/systemd-container.sh"
# shellcheck source=SCRIPTDIR/../lib/systemd-scenario.sh
source "$SCRIPT_DIR/../lib/systemd-scenario.sh"
# shellcheck source=SCRIPTDIR/../lib/image-build.sh
source "$SCRIPT_DIR/../lib/image-build.sh"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# shellcheck source=../../packaging/build-floor.env
source "$REPO_ROOT/packaging/build-floor.env"
CACHE_DIR="$SCRIPT_DIR/.cache"
RPM_PATH=""
DEB_PATH=""
PASS=0
FAIL=0
SKIP=0
# Bounds. Every dnf call is bounded because an install or an upgrade that
# blocks in a scriptlet is one of the defects this suite exists to catch, and
# an unbounded one would hang the run instead of failing it. The upgrade bound
# is the tighter of the two on purpose: %postun queues the restart with
# --no-block, so the transaction should return in seconds, and a transaction
# that waits on fips-dns-setup's 30 s interface wait would exceed this.
EXEC_TIMEOUT=${EXEC_TIMEOUT:-120}
DNF_TIMEOUT=${DNF_TIMEOUT:-600}
UPGRADE_DNF_TIMEOUT=${UPGRADE_DNF_TIMEOUT:-120}
UPGRADE_TRANSACTION_CEILING=${UPGRADE_TRANSACTION_CEILING:-45}
ALL_SCENARIOS="almalinux9 fedora no-resolver upgrade"
EL9_IMAGE="almalinux:9"
FEDORA_IMAGE="fedora:44"
log() { echo "=== $*"; }
pass() { echo " PASS: $*"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $*"; FAIL=$((FAIL + 1)); }
skip() { echo " SKIP: $*"; SKIP=$((SKIP + 1)); }
# The bounds testing/lib/systemd-scenario.sh reads, with deb-install's values,
# so the two suites boot, start and time out the same way. EXEC_TIMEOUT is set
# above.
BOOT_TIMEOUT=30
SERVICE_TIMEOUT=20
DAEMON_TIMEOUT=15
UNIT_START_TIMEOUT=30
# Build the artifact the release publishes, or take one from --rpm.
#
# Not `make rpm`: that path builds the .deb and packages it in one call, and
# the release workflow instead recovers the binaries from a .deb it already
# has and calls build-rpm-container.sh --no-build over them. This reproduces
# the second, and asserts the script's own exit status, because a package that
# builds and then reports failure is exactly what that path shipped before.
build_rpm() {
if [ -n "$RPM_PATH" ]; then
[ -f "$RPM_PATH" ] || { fail "no package at $RPM_PATH"; return 1; }
log "Using the package given on the command line: $(basename "$RPM_PATH")"
# make_next_package repacks whatever is in bin/, and recovers the
# binaries from this package only when bin/ is empty. Left over from an
# earlier run, they would be that run's binaries, not this package's.
rm -rf "${CACHE_DIR:?}/bin"
return 0
fi
mkdir -p "$CACHE_DIR"
local deb_dir="$CACHE_DIR/deb" bin_dir="$CACHE_DIR/bin" rpm_dir="$CACHE_DIR/rpm"
# Take the given .deb aside before the cache is cleared, in case the
# caller pointed at one inside it.
local given=""
if [ -n "$DEB_PATH" ]; then
[ -f "$DEB_PATH" ] || { fail "no .deb at $DEB_PATH"; return 1; }
given=$(mktemp "$CACHE_DIR/given.XXXXXX.deb")
cp "$DEB_PATH" "$given"
fi
rm -rf "$deb_dir" "$bin_dir" "$rpm_dir"
mkdir -p "$deb_dir" "$bin_dir" "$rpm_dir"
local deb
if [ -n "$given" ]; then
log "Packaging the binaries from the given .deb: $(basename "$DEB_PATH")"
mv "$given" "$deb_dir/$(basename "$DEB_PATH")"
deb="$deb_dir/$(basename "$DEB_PATH")"
else
log "Building the .deb in the pinned container (its binaries are what the RPM packages)"
deb=$("$REPO_ROOT/packaging/debian/build-deb-container.sh" --output-dir "$deb_dir" | tail -n 1)
if [ -z "$deb" ] || [ ! -f "$deb" ]; then
fail "the Debian container build did not produce a package"
return 1
fi
fi
# dpkg-deb is not necessarily on the host, so unpack in a container. Not
# the Debian *builder* image: that one is built locally and never pushed,
# so it exists only on the runner that built the package, and a CI leg that
# was handed the .deb as an artifact cannot pull it. Every Debian-family
# base image carries dpkg-deb, and FIPS_BUILD_IMAGE is one that any runner
# can pull.
if ! docker run --rm \
-v "$deb_dir":/deb:ro -v "$bin_dir":/bin-out \
-e "HOST_UID=$(id -u)" -e "HOST_GID=$(id -g)" \
"$FIPS_BUILD_IMAGE" bash -euo pipefail -c '
unpack=$(mktemp -d) # mount-tmpdir: runs inside the container, never mounted
dpkg-deb -x /deb/*.deb "$unpack"
for binary in fips fipsctl fipstop fips-gateway; do
install -m 0755 "$unpack/usr/bin/$binary" "/bin-out/$binary"
done
chown "$HOST_UID:$HOST_GID" /bin-out/*
' >&2; then
fail "could not recover the binaries from the .deb"
return 1
fi
log "Packaging them with --no-build, the path the release workflow takes"
local rc=0 out
out=$("$REPO_ROOT/packaging/rpm/build-rpm-container.sh" \
--no-build --bin-dir "$bin_dir" --output-dir "$rpm_dir") || rc=$?
if [ "$rc" -ne 0 ]; then
fail "build-rpm-container.sh --no-build exited $rc"
return 1
fi
pass "build-rpm-container.sh --no-build exited 0"
RPM_PATH=$(echo "$out" | tail -n 1)
if [ -z "$RPM_PATH" ] || [ ! -f "$RPM_PATH" ]; then
fail "the build did not name a package it produced ('$RPM_PATH')"
return 1
fi
pass "release path produced $(basename "$RPM_PATH")"
# --features has to reach cargo, and on this path nothing compiles, so the
# script must refuse rather than mark a Release it cannot back up.
if "$REPO_ROOT/packaging/rpm/build-rpm-container.sh" \
--no-build --bin-dir "$bin_dir" --features profiling \
--output-dir "$rpm_dir" >/dev/null 2>&1; then
fail "--features was accepted with --no-build"
else
pass "--features refused with --no-build"
fi
return 0
}
# The packages a runtime image needs on top of the distro base image.
#
# `resolver` adds systemd-resolved, which neither family installs by default
# outside Ubuntu 22.04's bundled one: the Debian legs of deb-install install it
# the same way. Without it no backend applies, which is what the no-resolver
# scenario is for.
runtime_packages() {
local with_resolver="$1"
local packages="systemd iproute dbus-daemon bind-utils procps-ng"
if [ "$with_resolver" = "resolver" ]; then
packages="$packages systemd-resolved"
fi
echo "$packages"
return 0
}
# Record whether <path> is absent in the container, as <absent_msg> or
# <present_msg>. `test -e` exits 1 for a missing path; anything else is
# cexec's own failure (a timeout, a container gone), which is neither, and is
# recorded as one rather than read as the path having been removed.
#
# Args: <name> <path> <absent_msg> <present_msg>. Returns 0 only if absent.
check_absent() {
local name="$1" path="$2" absent_msg="$3" present_msg="$4" rc=0
cexec "$name" test -e "$path" || rc=$?
case "$rc" in
1) pass "$absent_msg"; return 0 ;;
0) fail "$present_msg" ;;
*) fail "could not check for $path (exit $rc)" ;;
esac
return 1
}
# Build a runtime image for a scenario, with the package staged under /opt --
# systemd mounts a fresh /tmp during boot, which would wipe a package copied
# there.
build_runtime_image() {
local image="$1" base_image="$2" with_resolver="$3"
local rpm_basename
rpm_basename=$(basename "$RPM_PATH")
local packages enable_resolved=""
packages=$(runtime_packages "$with_resolver")
if [ "$with_resolver" = "resolver" ]; then
enable_resolved="systemctl enable systemd-resolved && \\"
fi
mkdir -p "$CACHE_DIR"
cp "$RPM_PATH" "$CACHE_DIR/rpm-for-image"
build_image "$image" "$(cat <<DOCKERFILE
FROM ${base_image}
RUN dnf install -y --setopt=install_weak_deps=False ${packages} && \\
dnf clean all && \\
${enable_resolved}
mkdir -p /opt/fips-rpm
COPY testing/rpm-install/.cache/rpm-for-image /opt/fips-rpm/${rpm_basename}
CMD ["/usr/sbin/init"]
DOCKERFILE
)"
local rc=$?
rm -f "$CACHE_DIR/rpm-for-image"
return $rc
}
# Boot a scenario's container with the package staged inside it.
boot_scenario() {
local name="$1" image="$2" base_image="$3" with_resolver="$4"
log "Building ${base_image} runtime image"
if ! build_runtime_image "$image" "$base_image" "$with_resolver"; then
fail "runtime image build failed for $base_image"
return 1
fi
start_systemd_container_with_tun "$name" "$image"
if ! wait_for_systemd "$name"; then
fail "systemd did not boot in $name; the checks below would read an unstarted system"
cleanup_container "$name"
return 1
fi
return 0
}
# Install the staged package. Bounded: an install that blocks in a scriptlet is
# one of the things this suite is here to catch.
install_package() {
local name="$1"
local rpm_basename
rpm_basename=$(basename "$RPM_PATH")
log "Installing (dnf install /opt/fips-rpm/${rpm_basename})"
local rc=0 out start=$SECONDS
out=$(timeout "$DNF_TIMEOUT" docker exec -w /opt/fips-rpm "$name" \
dnf install -y --setopt=install_weak_deps=False "./${rpm_basename}" 2>&1) || rc=$?
echo " install took $((SECONDS - start))s"
if [ "$rc" -ne 0 ]; then
fail "dnf install exited $rc"
echo "$out" | tail -20
return 1
fi
pass "dnf install completed"
return 0
}
# What the package put on disk, and in what state, before anything is started.
check_installed_state() {
local name="$1"
local installed
installed=$(cexec "$name" rpm -q fips-mesh 2>/dev/null)
if [ -n "$installed" ]; then
pass "package installed: $installed"
else
fail "rpm -q fips-mesh found nothing"
return 1
fi
if cexec "$name" getent group fips >/dev/null 2>&1; then
pass "fips group created by %post"
else
fail "fips group missing"
fi
local config_mode
config_mode=$(cexec "$name" stat -c '%a %U:%G' /etc/fips/fips.yaml 2>/dev/null)
if [ "$config_mode" = "600 root:root" ]; then
pass "/etc/fips/fips.yaml seeded (600 root:root)"
else
fail "/etc/fips/fips.yaml wrong or missing: '$config_mode' (expected '600 root:root')"
fi
# Seeded, not shipped: rpm must not own it, or an erase would take the
# operator's configuration -- and their identity keys sit beside it.
# Matched on rpm's message, not its exit status: rpm -qf also fails for a
# missing file, and cexec for a timeout, and neither means "not owned".
local owner rc=0
owner=$(cexec "$name" env LC_ALL=C rpm -qf /etc/fips/fips.yaml 2>&1) || rc=$?
if grep -q 'is not owned by any package' <<<"$owner"; then
pass "/etc/fips/fips.yaml is seeded, not owned by the package"
elif [ "$rc" -eq 0 ]; then
fail "/etc/fips/fips.yaml is owned by the package ($owner)"
else
fail "could not tell who owns /etc/fips/fips.yaml (exit $rc): $owner"
fi
# hosts and fips.nft are the opposite: owned, and %config(noreplace). rpm -qc
# lists every %config file, noreplace or not, so read the file flags: c is
# %config, n is noreplace.
local fileflags marked=0 f flags
if ! fileflags=$(cexec "$name" rpm -q --qf '[%{FILEFLAGS:fflags} %{FILENAMES}\n]' fips-mesh 2>&1); then
fail "could not read the package's file flags: $fileflags"
else
for f in /etc/fips/hosts /etc/fips/fips.nft; do
flags=$(awk -v f="$f" '$2 == f { print $1 }' <<<"$fileflags")
if [[ "$flags" == *c* && "$flags" == *n* ]]; then
marked=$((marked + 1))
else
fail "$f is not %config(noreplace) (flags '$flags')"
fi
done
fi
[ "$marked" -eq 2 ] && pass "hosts and fips.nft are %config(noreplace)"
if cexec "$name" test -d /etc/fips/fips.d; then
local dropin_mode
dropin_mode=$(cexec "$name" stat -c '%a %U:%G' /etc/fips/fips.d 2>/dev/null)
if [ "$dropin_mode" = "755 root:root" ]; then
pass "/etc/fips/fips.d/ drop-in dir present (755 root:root)"
else
fail "/etc/fips/fips.d/ wrong mode/owner: '$dropin_mode'"
fi
else
fail "/etc/fips/fips.d/ missing"
fi
# Enabled and not started, which is where the RPM follows the .deb: the
# install transaction must not bring a resolver rewrite with it.
local unit state active
for unit in fips.service fips-dns.service; do
state=$(cexec "$name" systemctl is-enabled "$unit" 2>/dev/null || true)
active=$(cexec "$name" systemctl is-active "$unit" 2>/dev/null || true)
if [ "$state" = "enabled" ] && [ "$active" = "inactive" ]; then
pass "$unit enabled and not started by the install"
else
fail "$unit is '$state'/'$active' (expected 'enabled'/'inactive')"
fi
done
for unit in fips-firewall.service fips-gateway.service; do
state=$(cexec "$name" systemctl is-enabled "$unit" 2>/dev/null || true)
if [ "$state" = "disabled" ]; then
pass "$unit disabled by default (opt-in)"
else
fail "$unit is '$state' (expected 'disabled')"
fi
done
return 0
}
# Start the units the way an operator would, and report the daemon's npub.
start_and_identify() {
local name="$1"
start_unit "$name" fips.service || true
start_unit_queued "$name" fips-dns.service || true
if wait_for_service_active "$name" fips.service; then
pass "fips.service active after explicit start"
else
fail "fips.service did not become active in ${SERVICE_TIMEOUT}s"
echo " --- fips.service status ---"
cexec "$name" systemctl status fips.service --no-pager 2>&1 | tail -20
return 1
fi
# The daemon hands /run/fips to the fips group once its control socket is
# up, which is after the unit reports active, so poll rather than read it
# once -- deb-install checks it only after the DNS listener has appeared,
# for the same reason.
local runtime_access="" _
for _ in $(seq 1 "$DAEMON_TIMEOUT"); do
runtime_access=$(cexec "$name" stat -c '%a %U:%G' /run/fips 2>/dev/null || true)
[ "$runtime_access" = "750 root:fips" ] && break
sleep 1
done
if [ "$runtime_access" = "750 root:fips" ]; then
pass "/run/fips is 750 root:fips, so group members can reach the control socket"
else
fail "/run/fips wrong: '$runtime_access' (expected '750 root:fips')"
fi
NPUB=$(cexec "$name" fipsctl show status 2>/dev/null | grep -oE 'npub1[a-z0-9]+' | head -1)
if [ -z "$NPUB" ]; then
fail "could not read an npub from fipsctl show status"
return 1
fi
echo " daemon npub: $NPUB"
return 0
}
# The end a user meets: a .fips name resolving through whichever backend
# fips-dns-setup picked.
check_resolution() {
local name="$1"
# fips-dns.service was queued, not waited on, and fips-dns-setup writes the
# backend file only after up to 30 s for fips0 and a resolver restart, so
# reading it before the oneshot finishes races the setup. RemainAfterExit
# makes is-active the right test.
if wait_for_service_active "$name" fips-dns.service; then
pass "fips-dns.service completed"
else
fail "fips-dns.service did not complete in ${SERVICE_TIMEOUT}s"
cexec "$name" journalctl -u fips-dns.service --no-pager 2>&1 | tail -20
return
fi
local backend ver expected
backend=$(cexec "$name" cat /run/fips/dns-backend 2>/dev/null || echo "(missing)")
ver=$(container_systemd_version "$name")
if [ -n "$ver" ] && [ "$ver" -ge 258 ]; then
expected="dns-delegate"
else
expected="global-drop-in"
fi
if [ "$backend" = "$expected" ]; then
pass "fips-dns.service picked $expected (systemd $ver)"
else
fail "expected $expected (systemd $ver), got: $backend"
cexec "$name" journalctl -u fips-dns.service --no-pager 2>&1 | tail -20
fi
sleep 1
local dig_output
dig_output=$(cexec "$name" dig +tries=1 +time=3 @127.0.0.53 AAAA "${NPUB}.fips" 2>&1)
if echo "$dig_output" | grep -qE '^[a-zA-Z0-9].*\sAAAA\s+[0-9a-f:]+'; then
pass "end-to-end dig @127.0.0.53 returns AAAA"
else
fail "end-to-end dig @127.0.0.53 returned no AAAA"
echo " --- dig ---"
echo "$dig_output" | tail -12
echo " --- resolved ---"
cexec "$name" resolvectl status 2>&1 | tail -20 || true
fi
}
# The four files fips-dns-setup may write outside the package, one per backend.
DNS_ROUTING_FILES="/etc/systemd/resolved.conf.d/fips.conf
/etc/systemd/dns-delegate.d/fips.dns-delegate
/etc/dnsmasq.d/fips.conf
/etc/NetworkManager/dnsmasq.d/fips.conf"
# Erase, which on rpm is the only cleanup there is: there is no purge, so
# whatever survives here survives for ever.
#
# Run with fips-dns.service stopped and all four routing files planted, which is
# the host the %postun erase branch exists for. Stopping fips-dns runs
# fips-dns-teardown, which removes the real backend file; with the unit already
# inactive, %preun's stop runs no teardown, so the planted files are left to
# %postun alone. Erasing with fips-dns active would let the teardown remove the
# backend file first, and the check would pass on files %postun never saw.
check_erase() {
local name="$1" f
cexec "$name" systemctl stop fips-dns.service >/dev/null 2>&1
if cexec "$name" systemctl is-active --quiet fips-dns.service; then
fail "fips-dns.service still active; the erase would not test the %postun branch"
return 1
fi
local planted=0
for f in $DNS_ROUTING_FILES; do
cexec "$name" sh -c "mkdir -p '$(dirname "$f")' && touch '$f'" >/dev/null 2>&1
if cexec "$name" test -f "$f"; then
planted=$((planted + 1))
else
fail "could not plant $f before the erase"
fi
done
[ "$planted" -eq 4 ] || return 1
# %postun restarts systemd-resolved when it removes one of resolved's files,
# so it must come back with a new invocation. InvocationID, as deb-install's
# remove check uses: every start gets a new one, which a reused PID would
# not guarantee. The stop above already restarted resolved, as the setup
# before it did, so clear the start counter: a restart refused by the
# start limit would read as %postun not restarting it.
cexec "$name" systemctl reset-failed systemd-resolved >/dev/null 2>&1
local resolved_before
resolved_before=$(cexec "$name" systemctl show -p InvocationID --value systemd-resolved 2>/dev/null)
if [ -z "$resolved_before" ] || ! cexec "$name" systemctl is-active --quiet systemd-resolved; then
fail "systemd-resolved not running before the erase (InvocationID '$resolved_before')"
return 1
fi
local rc=0 out
out=$(timeout "$DNF_TIMEOUT" docker exec "$name" dnf remove -y fips-mesh 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
fail "dnf remove exited $rc"
echo "$out" | tail -15
return 1
fi
pass "dnf remove completed"
check_absent "$name" /usr/bin/fips "binaries removed" "/usr/bin/fips survived the erase"
local unit
for unit in fips.service fips-dns.service; do
check_absent "$name" "/usr/lib/systemd/system/$unit" \
"$unit removed" "$unit survived the erase"
done
for f in $DNS_ROUTING_FILES; do
check_absent "$name" "$f" "%postun removed $f" "$f survived the erase"
done
local resolved_after
resolved_after=$(cexec "$name" systemctl show -p InvocationID --value systemd-resolved 2>/dev/null)
if [ -n "$resolved_after" ] && [ "$resolved_after" != "$resolved_before" ]; then
pass "%postun restarted systemd-resolved"
else
fail "%postun did not restart systemd-resolved (InvocationID '$resolved_before' -> '$resolved_after')"
echo "$out" | grep -i 'fips: warning' || true
fi
# The other half of having no purge: what must NOT be removed.
if cexec "$name" test -f /etc/fips/fips.yaml; then
pass "/etc/fips/fips.yaml kept (an erase must not take the node's identity)"
else
fail "/etc/fips/fips.yaml removed by the erase"
fi
return 0
}
# --- Scenarios ------------------------------------------------------------
# A distribution with a resolver, which is every install-suite leg on the
# Debian side too: they install systemd-resolved into the runtime image.
_run_install_scenario() {
local label="$1" base_image="$2"
local name="fips-rpm-test-${label}${FIPS_CI_NAME_SUFFIX:-}"
local image="fips-rpm-test:${label}"
log "RPM install: ${base_image}"
boot_scenario "$name" "$image" "$base_image" resolver || return
if install_package "$name"; then
check_installed_state "$name"
if start_and_identify "$name"; then
check_resolution "$name"
fi
check_erase "$name"
fi
cleanup_container "$name"
}
test_almalinux9() { _run_install_scenario almalinux9 "$EL9_IMAGE"; }
test_fedora() { _run_install_scenario fedora "$FEDORA_IMAGE"; }
# A stock EL9 host: no systemd-resolved, no dnsmasq, NetworkManager not
# configured for dnsmasq. No backend applies, and the documented outcome is a
# node that runs with .fips resolution absent -- not a failed unit. This pins
# that, because it is what an operator meets on a default install and nothing
# else in the tree asserts it.
test_no_resolver() {
local name="fips-rpm-test-no-resolver${FIPS_CI_NAME_SUFFIX:-}"
local image="fips-rpm-test:no-resolver"
log "RPM install: ${EL9_IMAGE} with no resolver backend available"
boot_scenario "$name" "$image" "$EL9_IMAGE" bare || return
if install_package "$name"; then
start_unit "$name" fips.service || true
start_unit_queued "$name" fips-dns.service || true
if wait_for_service_active "$name" fips.service; then
pass "fips.service active without any resolver backend"
else
fail "fips.service did not start"
fi
# Type=oneshot + RemainAfterExit=yes + `exit 0` on the last branch, so
# the unit settles active even though it configured nothing.
if wait_for_service_active "$name" fips-dns.service; then
pass "fips-dns.service reports active (exited) with nothing configured"
else
fail "fips-dns.service is '$(cexec "$name" systemctl is-active fips-dns.service 2>/dev/null)' (expected 'active')"
fi
local backend
backend=$(cexec "$name" cat /run/fips/dns-backend 2>/dev/null || echo "(missing)")
if [ "$backend" = "none" ]; then
pass "saved backend is 'none'"
else
fail "saved backend is '$backend' (expected 'none')"
fi
if cexec "$name" journalctl -u fips-dns.service --no-pager 2>&1 \
| grep -q "No supported DNS resolver detected"; then
pass "journal carries the no-resolver warning"
else
fail "no-resolver warning missing from the journal"
fi
# Nothing configured means nothing written.
local f
for f in $DNS_ROUTING_FILES; do
check_absent "$name" "$f" "$f not written" \
"$f was written with no backend detected"
done
# The mesh itself is unaffected, which is the point of calling this
# documented rather than broken.
if cexec "$name" ip link show fips0 >/dev/null 2>&1; then
pass "fips0 is up: the node works, only .fips names do not resolve"
else
fail "fips0 missing"
fi
fi
cleanup_container "$name"
}
# The package this run built, repacked at a higher Release from the same
# binaries, so the upgrade runs this tree's scriptlets without a second
# compile. With --rpm, the binaries are taken back out of the given package.
make_next_package() {
local bin_dir="$CACHE_DIR/bin" next_dir="$CACHE_DIR/next"
rm -rf "$next_dir"
mkdir -p "$next_dir" "$bin_dir"
if [ ! -x "$bin_dir/fips" ]; then
if ! docker run --rm \
-v "$(dirname "$RPM_PATH")":/rpm:ro -v "$bin_dir":/bin-out \
-e "HOST_UID=$(id -u)" -e "HOST_GID=$(id -g)" \
"$FIPS_RPM_BUILD_IMAGE" bash -euo pipefail -c "
dnf install -y -q --setopt=install_weak_deps=False cpio >/dev/null
cd \$(mktemp -d) # mount-tmpdir: runs inside the container, never mounted
rpm2cpio /rpm/$(basename "$RPM_PATH") | cpio -idm --quiet
for binary in fips fipsctl fipstop fips-gateway; do
install -m 0755 usr/bin/\$binary /bin-out/\$binary
done
chown \"\$HOST_UID:\$HOST_GID\" /bin-out/*
" >&2; then
return 1
fi
fi
# Release 999 sorts above any dev Release (0.dev...) and any tag's 1.
local version
version=$(rpm -qp --queryformat '%{VERSION}' "$RPM_PATH" 2>/dev/null \
|| awk -F'"' '/^version = /{print $2; exit}' "$REPO_ROOT/Cargo.toml" | sed 's/-dev$//')
NEXT_RPM=$("$REPO_ROOT/packaging/rpm/build-rpm-container.sh" \
--no-build --bin-dir "$bin_dir" --version "${version}-999" \
--output-dir "$next_dir" | tail -n 1)
[ -n "$NEXT_RPM" ] && [ -f "$NEXT_RPM" ]
}
# A running daemon upgraded in place. %postun of the *old* package is what
# restarts it, so this is also the check that the first published RPM gets it
# right: an upgrade is only as good as the scriptlet it upgrades away from.
test_upgrade() {
local name="fips-rpm-test-upgrade${FIPS_CI_NAME_SUFFIX:-}"
local image="fips-rpm-test:upgrade"
log "RPM upgrade: ${EL9_IMAGE}"
if ! make_next_package; then
fail "could not build the higher-Release package for the upgrade"
return
fi
boot_scenario "$name" "$image" "$EL9_IMAGE" resolver || return
install_package "$name" || { cleanup_container "$name"; return; }
start_unit "$name" fips.service || true
if ! wait_for_service_active "$name" fips.service; then
fail "fips.service did not start before the upgrade"
cleanup_container "$name"
return
fi
# Both PIDs must be real before they are compared: an empty or 0 one, from a
# failed systemctl show or a daemon not running, differs from anything and
# would read as a restart.
local pid_before
pid_before=$(cexec "$name" systemctl show fips.service -p MainPID --value 2>/dev/null)
if [ -z "$pid_before" ] || [ "$pid_before" = "0" ]; then
fail "could not read the daemon's MainPID before the upgrade ('$pid_before')"
cleanup_container "$name"
return
fi
# The timing check below only bites when the restart is slow, which a
# healthy daemon's is not: a blocking try-restart of a daemon that comes
# straight back finishes in seconds too. What makes the transaction safe
# is the --no-block in the scriptlet that runs on upgrade -- the installed
# package's %postun -- so assert that directly.
if cexec "$name" rpm -q --scripts fips-mesh 2>/dev/null \
| grep -q 'systemctl --no-block try-restart'; then
pass "installed %postun queues the restart (--no-block)"
else
fail "installed %postun does not queue the restart with --no-block"
fi
# An operator's edit, which must survive.
cexec "$name" sh -c 'echo "# operator edit" >> /etc/fips/fips.yaml'
local next_basename
next_basename=$(basename "$NEXT_RPM")
timeout "$EXEC_TIMEOUT" docker cp "$NEXT_RPM" "$name:/opt/fips-rpm/$next_basename"
local rc=0 out start=$SECONDS secs
out=$(timeout "$UPGRADE_DNF_TIMEOUT" docker exec -w /opt/fips-rpm "$name" \
dnf upgrade -y --setopt=install_weak_deps=False "./$next_basename" 2>&1) || rc=$?
secs=$((SECONDS - start))
echo " upgrade took ${secs}s"
if [ "$rc" -ne 0 ]; then
fail "dnf upgrade exited $rc"
echo "$out" | tail -15
cleanup_container "$name"
return
fi
pass "dnf upgrade completed"
# The restart is queued with --no-block; a transaction that waits on it
# would take at least fips-dns-setup's interface wait.
if [ "$secs" -le "$UPGRADE_TRANSACTION_CEILING" ]; then
pass "upgrade transaction did not wait on the restart (${secs}s)"
else
fail "upgrade transaction took ${secs}s (ceiling ${UPGRADE_TRANSACTION_CEILING}s)"
fi
# The queued restart lands after dnf returns.
local pid_after="" _
for _ in $(seq 1 30); do
pid_after=$(cexec "$name" systemctl show fips.service -p MainPID --value 2>/dev/null)
if [ -n "$pid_after" ] && [ "$pid_after" != "0" ] && [ "$pid_after" != "$pid_before" ]; then
break
fi
sleep 1
done
if [ -n "$pid_after" ] && [ "$pid_after" != "0" ] && [ "$pid_after" != "$pid_before" ]; then
pass "daemon restarted onto the new package (MainPID $pid_before -> $pid_after)"
else
fail "daemon was not restarted (MainPID $pid_before -> $pid_after)"
fi
local gw
gw=$(cexec "$name" systemctl is-enabled fips-gateway.service 2>/dev/null || true)
if [ "$gw" = "disabled" ]; then
pass "fips-gateway not opted in by the upgrade"
else
fail "fips-gateway is '$gw' after the upgrade (expected 'disabled')"
fi
if cexec "$name" grep -q "^# operator edit" /etc/fips/fips.yaml; then
pass "operator edit to fips.yaml survived the upgrade"
else
fail "operator edit to fips.yaml lost in the upgrade"
fi
if cexec "$name" sh -c 'ls /etc/fips/*.rpmnew /etc/fips/*.rpmsave' >/dev/null 2>&1; then
fail "the upgrade left .rpmnew or .rpmsave files in /etc/fips"
else
pass "no .rpmnew or .rpmsave left in /etc/fips"
fi
cleanup_container "$name"
}
# --- Main -----------------------------------------------------------------
SCENARIOS=()
while [ $# -gt 0 ]; do
case "$1" in
--rpm) RPM_PATH="$(cd "$(dirname "${2:?missing value for --rpm}")" && pwd)/$(basename "$2")"; shift 2 ;;
--deb) DEB_PATH="$(cd "$(dirname "${2:?missing value for --deb}")" && pwd)/$(basename "$2")"; shift 2 ;;
-h | --help) sed -n '2,35p' "$0"; exit 0 ;;
*) SCENARIOS+=("$1"); shift ;;
esac
done
[ ${#SCENARIOS[@]} -eq 0 ] && read -ra SCENARIOS <<<"$ALL_SCENARIOS"
build_rpm || { echo "Results: $PASS passed, $FAIL failed, $SKIP skipped"; exit 1; }
for scenario in "${SCENARIOS[@]}"; do
case "$scenario" in
almalinux9) test_almalinux9 ;;
fedora) test_fedora ;;
no-resolver) test_no_resolver ;;
upgrade) test_upgrade ;;
*) fail "unknown scenario: $scenario (have: $ALL_SCENARIOS)" ;;
esac
done
echo ""
echo "Results: $PASS passed, $FAIL failed, $SKIP skipped"
[ "$FAIL" -eq 0 ]