#!/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