Files
computer-playbook/scripts/tests/deploy/utils/filesystem/docker_dataroot.sh
kevinveenbirkenbachandClaude Opus 5.5 2d7bf740b0 fix(ci): put containerd, Docker and swap on the runner's empty spare disk
The small GitHub runner shape (AWS, 72G root) ran out of disk in swarm and compose rows (#1329: WordPress#2 of run 41261531965, Nextcloud#6 and #7 of run 41262286962) while an empty instance-store disk nvme1n1 sat unused. mount_spare_disk.sh formats and mounts it at /mnt, but only on a GitHub-hosted runner outside act, and only a whole unpartitioned disk without any signature, mount or swap use of at least 75G; every decline leaves a notice. relocate_docker_dataroot.sh then bind-mounts /var/lib/containerd from the spare disk and moves the Docker data-root to /mnt/docker-host (not /mnt/docker, which is the nested daemon's volume that dedicated.sh wipes). It refuses before stopping anything when images, volumes, build cache, foreign containerd namespaces or shims exist or daemon.json is unreadable, and rolls back the bind mount and daemon.json on any later failure or cancel. Swap goes on / once containerd lives on /mnt, dump_state.sh logs disk size, model and free space per storage root, and the swarm watchdog samples / and /var/lib/containerd every 10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:31:02 +01:00

327 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
if mountpoint -q /mnt; then
IMAGE=/mnt/docker-fs.img
else
IMAGE=/var/tmp/docker-fs.img
fi
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