Files
computer-playbook/scripts/tests/deploy/utils/filesystem/docker_dataroot.sh
T
kevinveenbirkenbachandClaude Opus 5 83c2e040dc fix(ci): give apt installs more room than apt updates
0d8ed0ba8 bounded these call sites with one ceiling for both operations.
The two are not equally safe to cut. A SIGTERM landing mid-postinst
leaves dpkg interrupted, and in docker_dataroot.sh that fails every later
filesystem candidate and sends the run through decline() - one truncated
package install becomes a dead job. An interrupted update has no such
cascade; it just repeats.

Measured on ubuntu:latest with the exact commands: update 10s, install
zfsutils-linux 20s. 1200s is a 60x margin on the operation that carries
the cascade, and update keeps the 600s it cannot plausibly need more than.

This breaks the single-number invariant with scripts/install/apt.sh:19,
which is already broken in fact - that call site carries a different
Acquire option set than these three do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 14:23:30 +02:00

323 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 # nocheck: shell-or-true -- the socket unit is absent on distros that ship docker without it
systemctl stop docker 2>/dev/null || true # nocheck: shell-or-true -- docker is not running yet on a runner that never started it
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
APT_TIMEOUT=10m
APT_INSTALL_TIMEOUT=20m
APT_OPTS=(-o Acquire::Retries=5 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30)
DEBIAN_FRONTEND=noninteractive timeout -k 30 "${APT_TIMEOUT}" apt-get "${APT_OPTS[@]}" update -qq
DEBIAN_FRONTEND=noninteractive timeout -k 30 "${APT_INSTALL_TIMEOUT}" apt-get "${APT_OPTS[@]}" 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
if [ ! -c /dev/zfs ] && zfs_major="$(awk '$2 == "zfs" {print $1}' /proc/devices | head -n1)" && [ -n "${zfs_major}" ]; then
mknod /dev/zfs c "${zfs_major}" 0
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 # nocheck: shell-or-true -- the socket unit is absent on distros that ship docker without it
systemctl stop docker 2>/dev/null || true # nocheck: shell-or-true -- docker is not running yet on a runner that never started it
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