mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
The pick was made once per matrix entry, in a workflow step ahead of the drill,
and every distro of that entry then ran on it. Coverage followed: one entry
exercised one filesystem, and the swarm never reached zfs at all, because
resolve.sh's userland_for() kept it out of any entry containing arch, centos or
fedora - which is every automatic sweep. The draw now happens inside the loop in
distros.sh, once per distro, so a five-distro entry covers zfs with 87% and all
three kinds with 62% probability instead of one kind with certainty.
The workflow input changes meaning with it. It was a single kind, offered as a
choice in the manual entry; it is now a space-separated allow-list. Empty draws
from all three, and naming kinds narrows the draw to them and marks the pick
required, so a host that cannot deliver one of them fails rather than quietly
running on something else. A name that is not a filesystem this project puts a
data root on is rejected outright instead of reaching the applying step.
For the draw to mean anything the images have to carry the userlands, so
scripts/install/zfs.sh becomes scripts/install/filesystem.sh and installs ext4
and btrfs alongside zfs. That is not symmetric work: btrfs-progs does not exist
in the EL family at all, because RHEL dropped btrfs, and a plain
`dnf install btrfs-progs` there fails the whole transaction and takes e2fsprogs
with it. EPEL carries it, so the EL family goes through EPEL from the start -
split by distro rather than by a failed first attempt, since Fedora has no
epel-release to fall back to and routing a transient failure there would turn it
into a fatal one. verify() now demands all four binaries, so a packaging
regression fails the build instead of leaving a hole the drill discovers later.
Measured on all five bases: debian trixie, ubuntu 26.04, arch, fedora 44 and
centos 10 each end with mkfs.ext4, mkfs.btrfs, zfs and zpool present and ldd
fully resolved.
Two defects only became reachable once a single job could ask for different
filesystems in turn:
* docker_dataroot.sh answered "already prepared" and returned for any mount it
found, so from the second distro on, every draw would have been silently
ignored on a host that survives the sweep - the runner in compose and host
mode. It now compares what is mounted against what was asked for and
dismantles the old root when they differ. Everything docker cached there
goes with it; that is what covering more than one filesystem per entry
costs.
* reclaim_stale_pool ran only when the new pick was zfs, so a zfs to btrfs
transition left the previous distro's pool imported on the shared kernel,
pinning a loop device over an image its dead container had already unlinked.
It now runs on every pick, which needed the /dev/zfs handling lifted out of
the zfs branch into zfs_device_ready().
The restore_docker trap is armed before anything can stop docker, rather than
after, so a failure while dismantling a data root cannot leave a host without a
daemon. resolve.sh splits its allow-list into an array before testing it, so a
whitespace-only input is the empty list it looks like rather than a narrowed one
that yields an empty pick marked required. pick.sh clears both keys when it
declines to draw: they are declared in default.env, so a generated .env carries
whatever was exported when it was made, and a caller gating its apply on the
value would otherwise repoint the developer's own docker in the very case the
skip exists to prevent.
Verified by execution rather than by reading: a privileged container ran
docker_dataroot.sh with real loop devices and mkfs through ext4 -> ext4 -> btrfs
-> ext4, confirming the recognition, the replacement in both directions and that
no loop leaks across it; a second harness with a recording zpool stub confirmed
that a btrfs pick does reclaim a stale pool, that no pool means no destroy, and
that a host without /dev/zfs still completes an ext4 pick without touching zpool.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
238 lines
8.3 KiB
Bash
Executable File
238 lines
8.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Bake the userlands for every filesystem the docker data root can run on into
|
|
# the distro image: ext4, btrfs and zfs. The kernel side always comes from the
|
|
# host, but the tools have to sit in the image - dockerd resolves `zfs` on PATH
|
|
# and opens /dev/zfs before it accepts a data root, and mkfs.ext4 / mkfs.btrfs
|
|
# on the host are equally useless to a container that cannot call them. A pick
|
|
# the kernel could serve still fails when the image lacks the userland.
|
|
#
|
|
# The three differ sharply in what that costs:
|
|
#
|
|
# * ext4 and btrfs are ordinary packages everywhere except the EL family,
|
|
# where RHEL dropped btrfs and btrfs-progs exists only in EPEL. A plain
|
|
# `dnf install btrfs-progs` there fails the whole transaction and takes
|
|
# e2fsprogs down with it, so EPEL goes in first. The split is by distro
|
|
# rather than by a failed first attempt, because on Fedora there is no
|
|
# epel-release to fall back to: routing a transient failure there would
|
|
# turn it into a fatal one and hide the cause behind an EPEL message.
|
|
# * The OpenZFS `zfs` RPM carries an ungated `Requires: %{name}-kmod`, so a
|
|
# plain `dnf install zfs` resolves through zfs-dkms and drags in
|
|
# kernel-devel, kernel-debug-core and a DKMS build against a kernel the
|
|
# container never boots: measured at 389 extra packages and 1.44 GB. A
|
|
# dummy package that only *provides* zfs-kmod keeps the same install at 60
|
|
# packages with no kernel package at all. Its version must equal the exact
|
|
# %{version} dnf resolves for `zfs`; a mismatch silently falls back to DKMS.
|
|
# Because that version is pinned, the repository is removed again once the
|
|
# userland is in: leaving it enabled means the next `dnf upgrade` finds a
|
|
# newer zfs whose kmod dependency the dummy no longer satisfies, and the
|
|
# whole DKMS pull returns at deploy time rather than at build time.
|
|
# * Debian keeps zfsutils-linux in contrib, which the base image does not
|
|
# enable. Without the extra source apt reports no installation candidate.
|
|
# * Arch has no zfs userland in core/extra. archzfs publishes a prebuilt
|
|
# zfs-utils, but only on a rolling release tag whose asset filename carries
|
|
# the version, so any pinned URL 404s on the next upstream bump. Building
|
|
# the AUR PKGBUILD (which configures --with-config=user, userland only) is
|
|
# the reproducible route and costs about six minutes. That build runs with
|
|
# --skippgpcheck rather than --skipinteg: the release tarball's sha256 and
|
|
# b2 sums stay verified, and only the detached OpenZFS signature is skipped,
|
|
# because checking it means carrying the release key in the builder's
|
|
# keyring while the same PKGBUILD already supplies both the sums and the
|
|
# list of accepted keys, so the signature adds no trust the sums do not.
|
|
set -euo pipefail
|
|
|
|
RETRIES=3
|
|
AUR_BUILDER=pkgbuild
|
|
ZFS_RELEASE_FEDORA="3-1 3-0 2-8"
|
|
ZFS_RELEASE_EL="3-0 2-8 2-3"
|
|
|
|
log() { echo "[filesystem] $*"; }
|
|
|
|
# Param: $@ command to run, retried on failure with a widening backoff
|
|
retry() {
|
|
local attempt=1
|
|
until "$@"; do
|
|
if [ "${attempt}" -ge "${RETRIES}" ]; then
|
|
log "ERROR: '$*' failed after ${attempt} attempts"
|
|
return 1
|
|
fi
|
|
log "attempt ${attempt} of '$1' failed; retrying"
|
|
sleep $((attempt * 5))
|
|
attempt=$((attempt + 1))
|
|
done
|
|
}
|
|
|
|
install_apt() {
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
local apt_opts=(-o Acquire::Retries="${RETRIES}" -qq)
|
|
|
|
if [ "${ID:-}" = debian ]; then
|
|
log "enabling contrib for ${VERSION_CODENAME:?}, which is where zfsutils-linux lives"
|
|
echo "deb http://deb.debian.org/debian ${VERSION_CODENAME} contrib" \
|
|
>/etc/apt/sources.list.d/contrib.list
|
|
fi
|
|
|
|
retry apt-get "${apt_opts[@]}" update
|
|
retry apt-get "${apt_opts[@]}" install -y --no-install-recommends \
|
|
e2fsprogs btrfs-progs zfsutils-linux
|
|
rm -rf /var/lib/apt/lists/*
|
|
}
|
|
|
|
install_pacman() {
|
|
retry pacman -Sy --noconfirm --needed base-devel git e2fsprogs btrfs-progs
|
|
|
|
if ! id "${AUR_BUILDER}" >/dev/null 2>&1; then
|
|
useradd -m -s /bin/bash "${AUR_BUILDER}"
|
|
fi
|
|
install -d -m 0755 /etc/sudoers.d
|
|
printf '%s ALL=(ALL) NOPASSWD: /usr/bin/pacman\n' "${AUR_BUILDER}" \
|
|
>/etc/sudoers.d/99-"${AUR_BUILDER}"-pacman
|
|
chmod 0440 /etc/sudoers.d/99-"${AUR_BUILDER}"-pacman
|
|
|
|
local build_root=/tmp/zfs-utils-build
|
|
rm -rf "${build_root}"
|
|
install -d -o "${AUR_BUILDER}" -g "${AUR_BUILDER}" "${build_root}"
|
|
|
|
retry su "${AUR_BUILDER}" -c \
|
|
"git clone --depth 1 https://aur.archlinux.org/zfs-utils.git '${build_root}'" # nocheck: url - AUR serves this path to git clients only; a plain GET is a 404 by design
|
|
su "${AUR_BUILDER}" -c \
|
|
"cd '${build_root}' && makepkg --syncdeps --noconfirm --cleanbuild --skippgpcheck"
|
|
|
|
local pkg
|
|
pkg="$(find "${build_root}" -maxdepth 1 -type f -name 'zfs-utils-*.pkg.tar.*' \
|
|
! -name '*-debug-*' | head -n1)"
|
|
if [ -z "${pkg}" ]; then
|
|
log "ERROR: makepkg produced no zfs-utils package"
|
|
return 1
|
|
fi
|
|
|
|
pacman -U --noconfirm "${pkg}"
|
|
rm -rf "${build_root}" /var/cache/pacman/pkg
|
|
}
|
|
|
|
install_dnf_native() {
|
|
case "${ID:-}" in
|
|
fedora)
|
|
retry dnf install -y e2fsprogs btrfs-progs
|
|
return
|
|
;;
|
|
esac
|
|
log "adding EPEL, the only place the EL family carries btrfs-progs"
|
|
retry dnf install -y epel-release
|
|
retry dnf install -y e2fsprogs btrfs-progs
|
|
}
|
|
|
|
# Param: $1 path segment of the zfsonlinux repository, `fedora` or `epel`
|
|
# Param: $2 space-separated zfs-release versions to try, newest first
|
|
release_install() {
|
|
local base="$1" candidates="$2" dist version url
|
|
dist="$(rpm --eval '%{dist}')"
|
|
for version in ${candidates}; do
|
|
url="https://zfsonlinux.org/${base}/zfs-release-${version}${dist}.noarch.rpm"
|
|
if dnf install -y "${url}"; then
|
|
log "zfs-release: ${url}"
|
|
return 0
|
|
fi
|
|
log "no zfs-release ${version} for ${dist}"
|
|
done
|
|
log "ERROR: none of the zfs-release versions '${candidates}' exists for ${dist}"
|
|
return 1
|
|
}
|
|
|
|
install_dnf() {
|
|
install_dnf_native
|
|
|
|
case "${ID:-}" in
|
|
fedora) retry release_install fedora "${ZFS_RELEASE_FEDORA}" ;;
|
|
*) retry release_install epel "${ZFS_RELEASE_EL}" ;;
|
|
esac
|
|
|
|
local zfs_version
|
|
zfs_version="$(dnf --quiet repoquery --qf '%{version}\n' zfs | sort -V | tail -n1)"
|
|
if [ -z "${zfs_version}" ]; then
|
|
log "ERROR: the zfs-release repository offers no zfs package"
|
|
return 1
|
|
fi
|
|
log "zfs userland offered by the repository: ${zfs_version}"
|
|
|
|
if ! command -v rpmbuild >/dev/null 2>&1; then
|
|
retry dnf install -y rpm-build
|
|
fi
|
|
|
|
local topdir=/tmp/zfs-kmod-container
|
|
rm -rf "${topdir}"
|
|
mkdir -p "${topdir}/SPECS"
|
|
cat >"${topdir}/SPECS/zfs-kmod-container.spec" <<SPEC
|
|
Name: zfs-kmod-container
|
|
Version: ${zfs_version}
|
|
Release: 1
|
|
Summary: Satisfies the zfs kernel module dependency inside a container
|
|
License: CDDL
|
|
BuildArch: noarch
|
|
Provides: zfs-kmod = ${zfs_version}
|
|
|
|
%description
|
|
The container never loads a zfs module; the host supplies it and the container
|
|
reaches it through /dev/zfs. This package exists only so the zfs userland
|
|
resolves without pulling a DKMS toolchain and a kernel it will never boot.
|
|
|
|
%files
|
|
SPEC
|
|
|
|
rpmbuild --define "_topdir ${topdir}" -bb "${topdir}/SPECS/zfs-kmod-container.spec"
|
|
dnf install -y "${topdir}"/RPMS/noarch/zfs-kmod-container-*.rpm
|
|
retry dnf install -y zfs
|
|
|
|
if rpm -qa 'kernel-devel*' 'kernel-debug*' 'zfs-dkms' | grep -q .; then
|
|
log "ERROR: the transaction pulled kernel or DKMS packages, so the dummy did not take"
|
|
rpm -qa 'kernel-devel*' 'kernel-debug*' 'zfs-dkms' >&2
|
|
return 1
|
|
fi
|
|
|
|
dnf remove -y zfs-release
|
|
rm -rf "${topdir}"
|
|
dnf clean all
|
|
}
|
|
|
|
verify() {
|
|
local tool
|
|
for tool in mkfs.ext4 mkfs.btrfs zfs zpool; do
|
|
if ! command -v "${tool}" >/dev/null 2>&1; then
|
|
log "ERROR: ${tool} is missing after the install"
|
|
return 1
|
|
fi
|
|
done
|
|
|
|
if ldd "$(command -v zpool)" | grep -q 'not found'; then
|
|
log "ERROR: zpool has unresolved shared libraries"
|
|
ldd "$(command -v zpool)" | grep 'not found' >&2
|
|
return 1
|
|
fi
|
|
|
|
if command -v dnf >/dev/null 2>&1 && dnf repolist --enabled 2>/dev/null | grep -q '^zfs'; then
|
|
log "ERROR: a zfs repository is still enabled, so an upgrade would re-resolve the kmod"
|
|
return 1
|
|
fi
|
|
|
|
log "installed mkfs.ext4, mkfs.btrfs and $(command -v zpool); all libraries resolved"
|
|
}
|
|
|
|
main() {
|
|
# shellcheck source=/dev/null
|
|
. /etc/os-release
|
|
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
install_apt
|
|
elif command -v pacman >/dev/null 2>&1; then
|
|
install_pacman
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
install_dnf
|
|
else
|
|
log "ERROR: no supported package manager (apt/pacman/dnf)"
|
|
exit 1
|
|
fi
|
|
|
|
verify
|
|
}
|
|
|
|
main "$@"
|