Files
computer-playbook/scripts/tests/deploy/utils/filesystem/docker_dataroot.sh
T
kevinveenbirkenbachandClaude Opus 5 f9d6f4525b fix(swarm-lab): give each node its own zfs pool name
In job 91983553540 all five lab nodes derived the same pool name,
infinito_ci_unknown, and the run died on `cannot destroy
'infinito_ci_unknown': pool is busy`. The name came from `hostname`, which
returns non-zero inside the node containers, so the `|| echo unknown` fallback
answered for every one of them. One node won the race and created the pool; the
others found it, took it for a leftover, and tried to tear down a pool a
sibling was actively using.

234c1158c already fixed this collision class three days ago by deriving the
name from `hostname` instead of a constant, and its message named the zfs
exclusion in resolve.sh as the only thing keeping the collision out of sight.
That fix was never exercised, because the exclusion stayed up until ed1121053
removed it - and it does not work, because the call it rests on fails in the
containers it was written for. Guessing the identity is the defect; the
playbook has known it all along as inventory_hostname, and ci/one.sh knows its
own deploy mode. Both now state it, as a required fourth argument with no
default, the way SIZE is already stated. verdict() stops guessing too: every
row it wrote into the job summary said `unknown`, which is why the summary was
never worth reading.

The second half is independent of the naming. reclaim_stale_pool ran
`zpool destroy -f` as a bare command under `set -euo pipefail`, the only
failure path in this script that bypassed decline(). A pool that refuses to be
destroyed is by definition not a leftover - something holds it - so that case
now declines through the usual contract: a drawn pick falls back and reports,
a stated one fails loudly as required-but-unavailable. A reclaim can no longer
be more destructive than the state it exists to clean up.

Verified over the real invocation path rather than a model of it: an
ansible-playbook run using the same `script:` task and docker connection as
compose/swarm/playbook.yml, against three containers in one play, has each node
address its own pool - infinito_ci_race-mgr-01, -wrk-01, -nfs-01 - with
failed=0 throughout. A stubbed-pool harness covers the paths that need a kernel
module this machine does not have: a sibling holding the pool declines with rc
0 instead of ending the run, the same case with a stated pick exits 1 through
the contract, a genuine leftover is still destroyed, and a missing identity is
a hard error. The earlier replacement harness (ext4 recognised, btrfs
replacing it, no loop leaked) still passes against the stricter signature.
Pool names now carry dashes, which zfs_namecheck.c accepts; at 42 characters
they sit far inside the 240 limit and match no reserved prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:47 +02:00

317 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Put the docker data root on a stated filesystem, so CI exercises the code
# paths that depend on it - above all the snapshot mode of
# svc-bkp-volume-2-local, which only engages on btrfs or zfs.
#
# Runs on the machine whose daemon it reconfigures - the runner for compose and
# host mode, each node container for swarm - and restarts that daemon, so it
# must run before anything is pulled or started.
#
# Arguments:
# $1 FSTYPE ext4 | btrfs | zfs; empty is a no-op
# $2 REQUIRED true when the filesystem was stated: a host that cannot
# deliver it fails the run. false (default) declines and reports.
# $3 SIZE loop image size, e.g. 30G (sparse, so it costs what it holds)
# $4 IDENTITY what this machine is called in the zfs pool namespace, which
# the lab nodes share with the runner. Stated by the caller
# rather than read from `hostname`, which returns non-zero in the
# node containers; its `unknown` fallback collapses every node
# onto one pool name, and they then race for a single pool.
set -euo pipefail
FSTYPE="${1:-}"
REQUIRED="${2:-false}"
SIZE="${3:?SIZE is required - state the loop image size (e.g. 30G) at the call site}"
IDENTITY="${4:?IDENTITY is required - state what this machine is called at the call site}"
MOUNT=/mnt/docker-fs
IMAGE=/var/tmp/docker-fs.img
POOL="infinito_ci_$(printf '%s' "${IDENTITY}" | tr -dc 'A-Za-z0-9_.:-')"
DAEMON=/etc/docker/daemon.json
MISC_MAJOR=10
report() { echo "docker-dataroot-filesystem: $*"; }
current_fstype() {
stat -f -c %T "${MOUNT}/docker" 2>/dev/null ||
stat -f -c %T /var/lib/docker 2>/dev/null ||
stat -f -c %T / 2>/dev/null ||
echo unknown
}
# Param: $1 status word recorded for this host
# Param: $2 reason behind the status, empty when it needs none
verdict() {
local status="$1" reason effective
reason="$(printf '%s' "${2:-}" | tr '\n' ' ')"
effective="$(current_fstype)"
report "status=${status} requested=${FSTYPE:-none} effective=${effective}${reason:+ reason=${reason}}"
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0
echo "- \`${IDENTITY}\` docker data root: **${effective}** (requested \`${FSTYPE:-none}\`, ${status})${reason:+ - ${reason}}" \
>>"${GITHUB_STEP_SUMMARY}"
}
# Param: $1 loop device to detach
# Param: $2 mount point it backs
arm_autoclear() {
local loop="$1" mount="$2" armed=""
if ! losetup -d "${loop}"; then
report "WARNING: ${loop} refused the detach and will leak into the next round"
return 0
fi
if ! mountpoint -q "${mount}"; then
report "FAIL: detaching ${loop} tore down ${mount}"
exit 1
fi
armed="$(losetup -l -n -O AUTOCLEAR "${loop}" 2>/dev/null | tr -d '[:space:]')"
[ "${armed}" = 1 ] ||
report "WARNING: ${loop} did not arm autoclear (AUTOCLEAR='${armed}')"
}
# Make /dev/zfs usable, reporting 1 when the module is not loaded at all.
#
# A node container's /dev is populated once at creation, and udevd is masked, so
# a module loaded afterwards leaves no device node behind. /dev/zfs is a misc
# device, which means it appears in /proc/misc and never in /proc/devices.
zfs_device_ready() {
local minor
[ ! -c /dev/zfs ] || return 0
minor="$(awk '$2 == "zfs" {print $1}' /proc/misc)"
[ -n "${minor}" ] || return 1
report "the module is loaded but this /dev predates it; creating the node from /proc/misc"
mknod /dev/zfs c "${MISC_MAJOR}" "${minor}"
}
# Param: $1 pool whose leftovers from an earlier bring-up have to go
#
# Pools live in the kernel, which the lab nodes share with the runner and with
# every later distro of the same sweep, while the container teardown only removes
# containers. A pool of this name is therefore still imported when the next
# iteration starts, and `zpool create` refuses the name rather than reusing it.
# Its vdev is a loop device backed by an image the dead container already
# unlinked, so that space stays claimed until the loop is detached as well.
#
# A pool that refuses the destroy is by definition not a leftover: something
# still holds it. That case declines through the usual contract instead of
# ending the run, so a reclaim can never be more destructive than the state it
# was written to clean up.
reclaim_stale_pool() {
local pool="$1" vdev destroy_out
zpool list -H -o name "${pool}" >/dev/null 2>&1 || return 0
vdev="$(zpool list -vHP "${pool}" | awk 'NR == 2 {print $1}')"
report "destroying ${pool}, left imported on this kernel by an earlier bring-up"
if ! destroy_out="$(zpool destroy -f "${pool}" 2>&1)"; then
decline "${pool} is held elsewhere and is therefore not stale: ${destroy_out}"
fi
if [ -b "${vdev}" ]; then
losetup -d "${vdev}"
report "detached its vdev ${vdev}"
else
report "WARNING: its vdev '${vdev}' is no block device; that loop stays attached"
fi
}
# Param: $1 filesystem the caller asked for
#
# stat reports ext4 as ext2/ext3, so the requested name and the reported one are
# compared through this table rather than directly.
prepared_matches() {
case "$1:$(current_fstype)" in
ext4:ext2/ext3 | btrfs:btrfs | zfs:zfs) return 0 ;;
*) return 1 ;;
esac
}
# Give the prepared data root up so a different filesystem can take its place.
#
# The pick is drawn per distro iteration, so on a host that survives the sweep -
# the runner in compose and host mode - the second distro usually asks for
# something else. Docker has to stop before its data root can be dismantled, and
# the loop device only releases the backing image once the mount above it is
# gone. Everything docker cached on the old root goes with it; that is the price
# of covering more than one filesystem per entry.
release_prepared() {
local loop
report "replacing the prepared $(current_fstype) data root with ${FSTYPE}"
systemctl stop docker.socket 2>/dev/null || true
systemctl stop docker 2>/dev/null || true
DOCKER_STOPPED=true
if zpool list -H -o name "${POOL}" >/dev/null 2>&1; then
reclaim_stale_pool "${POOL}"
else
umount "${MOUNT}"
fi
loop="$(losetup -j "${IMAGE}" -O NAME -n | head -n1)"
if [ -n "${loop}" ] && [ -b "${loop}" ]; then
losetup -d "${loop}"
fi
rm -f "${IMAGE}"
}
decline() {
if [ "${REQUIRED}" = true ]; then
report "FAIL: ${FSTYPE} was stated but cannot be delivered: $*"
verdict required-but-unavailable "$*"
exit 1
fi
report "skipping ${FSTYPE}: $*"
verdict declined "$*"
exit 0
}
case "${FSTYPE}" in
ext4 | btrfs | zfs) ;;
"")
report "no filesystem stated, leaving the data root where it is"
verdict unchanged
exit 0
;;
*)
report "FAIL: unknown filesystem '${FSTYPE}'"
verdict unknown-filesystem
exit 1
;;
esac
[ "$(id -u)" -eq 0 ] || decline "not running as root"
install_packages() {
if command -v apt-get >/dev/null; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@"
elif command -v pacman >/dev/null; then
pacman -Sy --noconfirm --needed "$@"
elif command -v dnf >/dev/null; then
if dnf -y install "$@"; then
return 0
fi
dnf -y install epel-release || return 1
dnf -y install "$@"
else
return 1
fi
}
require_tool() {
local tool="$1" candidate
shift
command -v "${tool}" >/dev/null && return 0
for candidate in "$@"; do
report "installing ${candidate} for ${tool}"
install_packages "${candidate}" >/dev/null 2>&1 || continue
command -v "${tool}" >/dev/null && return 0
done
return 1
}
require_tool losetup util-linux || decline "losetup is unavailable"
require_tool mountpoint util-linux || decline "mountpoint is unavailable"
case "${FSTYPE}" in
ext4)
require_tool mkfs.ext4 e2fsprogs || decline "mkfs.ext4 is unavailable"
;;
btrfs)
require_tool mkfs.btrfs btrfs-progs || decline "btrfs-progs is unavailable"
;;
zfs)
require_tool zpool zfsutils-linux zfs zfs-utils ||
decline "the zfs userland is unavailable on this distribution"
if ! modprobe_out="$(modprobe zfs 2>&1)"; then
report "modprobe zfs failed: ${modprobe_out}"
fi
zfs_device_ready ||
decline "the zfs kernel module is not loaded on the host: ${modprobe_out}"
;;
esac
restore_docker() {
[ "${DOCKER_STOPPED:-false}" = true ] || return 0
systemctl cat docker.service >/dev/null 2>&1 || return 0
systemctl start docker ||
report "WARNING: docker did not come back up after the ${FSTYPE} setup"
}
trap restore_docker EXIT
if mountpoint -q "${MOUNT}"; then
if prepared_matches "${FSTYPE}"; then
if [ "${FSTYPE}" = zfs ] && zpool list -H -o name "${POOL}" >/dev/null 2>&1; then
zfs set acltype=posixacl xattr=sa "${POOL}"
fi
report "${MOUNT} already carries ${FSTYPE}"
verdict already-prepared
exit 0
fi
release_prepared
fi
if command -v zpool >/dev/null 2>&1 && zfs_device_ready; then
reclaim_stale_pool "${POOL}"
fi
report "putting the docker data root on ${FSTYPE}"
systemctl stop docker.socket 2>/dev/null || true
systemctl stop docker 2>/dev/null || true
DOCKER_STOPPED=true
mkdir -p "${MOUNT}" "$(dirname "${IMAGE}")"
rm -f "${IMAGE}"
truncate -s "${SIZE}" "${IMAGE}"
LOOP=""
attempt=0
while [ -z "${LOOP}" ] && [ "${attempt}" -lt 16 ]; do
attempt=$((attempt + 1))
[ "${attempt}" -eq 1 ] || sleep 0.5
candidate=""
candidate="$(losetup -f 2>/dev/null)" || candidate=""
candidate="${candidate%% *}"
if [ -n "${candidate}" ] && [ ! -b "${candidate}" ]; then
mknod "${candidate}" b 7 "${candidate#/dev/loop}" 2>/dev/null || candidate=""
fi
if attached="$(losetup --find --show "${IMAGE}" 2>/dev/null)"; then
LOOP="${attached}"
fi
done
[ -n "${LOOP}" ] || decline "no loop device could be claimed after ${attempt} attempts"
if [ "${FSTYPE}" = zfs ]; then
if ! zpool_out="$(zpool create -f -m "${MOUNT}" -O acltype=posixacl -O xattr=sa "${POOL}" "${LOOP}" 2>&1)"; then
losetup -d "${LOOP}" ||
report "WARNING: ${LOOP} refused the detach and will leak into the next round"
rm -f "${IMAGE}"
decline "zpool create failed: ${zpool_out}"
fi
zfs create -o mountpoint="${MOUNT}/docker" "${POOL}/docker"
else
"mkfs.${FSTYPE}" -q "${LOOP}"
mount -t "${FSTYPE}" "${LOOP}" "${MOUNT}"
arm_autoclear "${LOOP}" "${MOUNT}"
if [ "${FSTYPE}" = btrfs ]; then
btrfs subvolume create "${MOUNT}/docker" >/dev/null
else
mkdir -p "${MOUNT}/docker"
fi
fi
mkdir -p "$(dirname "${DAEMON}")"
python3 - "${DAEMON}" "${MOUNT}/docker" <<'PYTHON'
import json
import pathlib
import sys
path, root = pathlib.Path(sys.argv[1]), sys.argv[2]
config = json.loads(path.read_text()) if path.is_file() and path.read_text().strip() else {}
config["data-root"] = root
path.write_text(json.dumps(config, indent=2) + "\n")
PYTHON
if systemctl cat docker.service >/dev/null 2>&1; then
systemctl start docker
DOCKER_STOPPED=false
verdict applied
else
report "docker.service is not installed yet; the data root takes effect when it is"
verdict applied-pending-docker
fi