Merge pull request #1330 from infinito-nexus/fix/ci/runner-disk-nvme/1329

fix(ci): put containerd, Docker and swap on the runner's empty spare disk
This commit is contained in:
kevinveenbirkenbach authored and GitHub committed 2026-11-02 14:46:11 +01:00
commit 562052a032
13 files changed
+1585 -89

No files matched your search

+10 -5
View File
@@ -298,16 +298,21 @@ jobs:
shell: bash
run: grep '^INFINITO_RESCUE_DIAGNOSTICS_BASE=' default.env >> "$GITHUB_ENV"
- name: 💽 Mount the empty spare disk at /mnt
if: ${{ !env.ACT }}
shell: bash
run: ./scripts/github/runner/mount_spare_disk.sh /mnt 75
- name: 📦 Relocate containerd and the Docker data-root onto /mnt when roomier
if: ${{ !env.ACT }}
shell: bash
run: ./scripts/github/runner/relocate_docker_dataroot.sh 75
- name: 💾 Enlarge swap space
if: ${{ !env.ACT }}
shell: bash
run: ./scripts/github/runner/enlarge_swap.sh
- name: 📦 Relocate Docker data-root onto /mnt when roomier
if: ${{ !env.ACT }}
shell: bash
run: ./scripts/github/runner/relocate_docker_dataroot.sh
- name: 🛡️ Tear down host AppArmor profiles (dev-host parity)
if: ${{ !env.ACT }}
shell: bash
@@ -78,11 +78,39 @@ Deploy test workflows use the `jlumbroso/free-disk-space` action to reclaim runn
Set an option back to `false` only when a new host-side step in the same workflow genuinely needs the removed payload.
## Spare disk 💽
Some GitHub-hosted runners have a 72 GB root disk plus a second, unformatted NVMe disk. [mount_spare_disk.sh](../../../../../scripts/github/runner/mount_spare_disk.sh) formats that disk as ext4 and mounts it at `/mnt`. The nested Docker volume (`/mnt/docker`) and the loop image of [docker_dataroot.sh](../../../../../scripts/tests/deploy/utils/filesystem/docker_dataroot.sh) (`/mnt/docker-fs.img`) then land on it.
When `/mnt` has at least `MIN_FREE_GB` free and more than `/`, [relocate_docker_dataroot.sh](../../../../../scripts/github/runner/relocate_docker_dataroot.sh) stops Docker and containerd, bind-mounts `/mnt/containerd` over `/var/lib/containerd` (images, container layers, the swarm node containers), sets the Docker data-root to `/mnt/docker-host` (named volumes), restarts both and verifies that containerd's metadata and the Docker root sit on `/mnt`. Before the stop it fails if Docker already holds containers, images, volumes or build cache, if another containerd namespace holds containers or tasks, if containerd shims run, or if `daemon.json` cannot be read as JSON. Any failure after the stop, and a cancel, unmounts the bind, restores `daemon.json`, restarts both daemons and fails the step. It refuses under act and changes nothing on a runner that is not GitHub-hosted. Every decline prints a `::notice::` annotation. [run.py](../../../../../utils/tests/swarm/run.py) then watches the free space of both `/` and `/var/lib/containerd`.
Step order: spare disk, relocation, swap. With containerd on `/mnt`, the swapfile goes to `/`.
The script formats a disk only when all of these hold; otherwise the runner stays as it is:
- `ACT` is unset (the script fails under act) and `RUNNER_ENVIRONMENT` is `github-hosted`.
- `/mnt` is an existing, empty directory and no mount.
- The disk is a whole disk other than the root disk, with no partitions or holders, no filesystem or partition-table signature (`lsblk`, `blkid -p` and `wipefs`), no mount or swap use, not read-only, and at least `MIN_SIZE_GB` large.
Workflow step invocations, in this order and gated alike:
```yaml
- name: Mount the empty spare disk at /mnt
if: ${{ !env.ACT }}
shell: bash
run: ./scripts/github/runner/mount_spare_disk.sh /mnt 75
- name: Relocate containerd and the Docker data-root onto /mnt when roomier
if: ${{ !env.ACT }}
shell: bash
run: ./scripts/github/runner/relocate_docker_dataroot.sh 75
```
## Swap 💾
Deploy test workflows enlarge host swap via [enlarge_swap.sh](../../../../../scripts/github/runner/enlarge_swap.sh) to absorb transient memory spikes (e.g. PeerTube plugin install [#162](https://github.com/infinito-nexus/core/issues/162)) that would otherwise trip the host OOM-killer on the 16 GB GitHub-hosted runner.
The script **prefers `/mnt`** when it is a separate partition (classic `ubuntu-latest` layout with `/dev/sdb` mounted at `/mnt`) and falls back to `/` otherwise. Current public runners no longer expose a separate `/mnt`, so the swapfile lands on `/` and must not starve the root filesystem.
The script puts the swapfile on `/` when containerd already lives on `/mnt`, **prefers `/mnt`** otherwise when it is a separate partition (classic `ubuntu-latest` layout with `/dev/sdb` mounted at `/mnt`), and falls back to `/`. Current public runners no longer expose a separate `/mnt`, so the swapfile lands on `/` and must not starve the root filesystem.
Swap size is a **fixed constant**, not a "free space minus buffer" calculation. An earlier version took ~99 GB on a 145 GB root partition, leaving so little headroom that later Docker layer writes (Playwright test image pull) failed with `no space left on device`. A fixed cap is large enough to absorb the peertube spike and small enough that Docker layer writes keep succeeding throughout the 30+ min deploy.
+14 -4
View File
@@ -18,14 +18,24 @@ date -u +'%Y-%m-%dT%H:%M:%SZ'
echo "--- disk (bytes) ---"
df -h / /mnt 2>/dev/null || df -h /
echo "--- disk per storage root (/, /mnt, containerd root, Docker data root) ---"
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null)" || docker_root=""
df -h --output=source,size,used,avail,target / /mnt /var/lib/containerd ${docker_root:+"${docker_root}"} ||
echo "df could not read every storage root"
containerd --version 2>/dev/null || echo "containerd not on PATH"
echo "--- disk (inodes) ---"
df -hi / /mnt 2>/dev/null || df -hi /
echo "--- block devices ---"
lsblk -f || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
echo "--- disks (bytes, model: Amazon EC2 NVMe Instance Storage versus Amazon Elastic Block Store) ---"
lsblk -d -b -o NAME,SIZE,MODEL,ROTA,TYPE || echo "lsblk could not list the disks"
echo "--- mounts (/, /mnt, /var/lib/docker, /mnt/docker) ---"
mount | grep -E ' / | /mnt | /var/lib/docker | /mnt/docker ' || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
echo "--- block devices ---"
lsblk -o NAME,TYPE,SIZE,FSTYPE,FSVER,LABEL,UUID,FSAVAIL,FSUSE%,MOUNTPOINTS || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
echo "--- mounts (/, /mnt, /var/lib/docker, /var/lib/containerd, /mnt/docker) ---"
mount | grep -E ' / | /mnt | /var/lib/docker | /var/lib/containerd | /mnt/docker ' || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
echo "--- docker ---"
docker info 2>/dev/null |
+12 -2
View File
@@ -4,7 +4,8 @@
# spikes (e.g. peertube plugin install, issue #162) don't trip the host
# OOM-killer, WITHOUT starving the rest of the job of disk space.
#
# Prefers /mnt when it is a separate partition (classic ubuntu-latest
# Uses / when containerd already lives on /mnt (relocate_docker_dataroot.sh
# runs first), otherwise prefers /mnt when it is a separate partition (classic ubuntu-latest
# layout with /dev/sdb mounted at /mnt). On current public runners
# there is no separate /mnt partition — everything lives on /, so we
# place the swapfile there. The swap size is a hard constant, never a
@@ -46,7 +47,16 @@ root_free=$(df --output=avail -B1G / | tail -n1 | tr -d ' ')
echo "Target swap: ${SWAP_GB}G. Free: / ${root_free}G, /mnt ${mnt_free}G (separate mount: $(mountpoint -q /mnt 2>/dev/null && echo yes || echo no))"
if [ "$mnt_free" -ge "$MIN_FREE_GB" ]; then
storage_on_mnt=no
if mountpoint -q /mnt 2>/dev/null && containerd_dev="$(stat -c %d /var/lib/containerd 2>/dev/null)" &&
[ "$containerd_dev" = "$(stat -c %d /mnt)" ]; then
storage_on_mnt=yes
fi
if [ "$storage_on_mnt" = yes ] && [ "$root_free" -ge "$MIN_FREE_GB" ]; then
target=/swapfile
chosen="/ (containerd and Docker live on /mnt)"
elif [ "$mnt_free" -ge "$MIN_FREE_GB" ]; then
target=/mnt/swapfile
chosen="/mnt (separate partition)"
elif [ "$root_free" -ge "$MIN_FREE_GB" ]; then
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Usage: mount_spare_disk.sh MOUNT_POINT MIN_SIZE_GB
# MOUNT_POINT where the spare disk lands; enlarge_swap.sh and
# relocate_docker_dataroot.sh pick it up when it is /mnt
# MIN_SIZE_GB smallest spare disk worth mounting; smaller disks leave the runner as it is
set -euo pipefail
MOUNT_POINT="${1:?MOUNT_POINT is required - state where the spare disk is mounted at the call site}"
MIN_SIZE_GB="${2:?MIN_SIZE_GB is required - state the smallest spare disk in GB worth mounting at the call site}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DUMP="${HERE}/dump_state.sh"
if [ -n "${ACT:-}" ]; then
echo "::error::mount_spare_disk.sh refuses to run under act (ACT=${ACT}): it formats block devices and must never touch the operator's machine" >&2
exit 1
fi
if ! [[ "${MIN_SIZE_GB}" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::MIN_SIZE_GB must be a positive integer, got '${MIN_SIZE_GB}'" >&2
exit 1
fi
if [ "${RUNNER_ENVIRONMENT:-}" != github-hosted ]; then
echo "::notice::mount_spare_disk.sh mounted no spare disk: not a GitHub-hosted runner (RUNNER_ENVIRONMENT='${RUNNER_ENVIRONMENT:-}'), every block device stays untouched"
exit 0
fi
if mountpoint -q "${MOUNT_POINT}"; then
echo "::notice::mount_spare_disk.sh mounted no spare disk: ${MOUNT_POINT} is already a separate mount"
exit 0
fi
if [ ! -d "${MOUNT_POINT}" ]; then
echo "::error::mount point ${MOUNT_POINT} does not exist" >&2
exit 1
fi
occupant="$(find "${MOUNT_POINT}" -mindepth 1 -maxdepth 1 -print -quit)"
if [ -n "${occupant}" ]; then
echo "::warning::${MOUNT_POINT} is not empty; mounting over it would hide its content, so no spare disk is mounted"
exit 0
fi
min_bytes=$((MIN_SIZE_GB * 1024 * 1024 * 1024))
tree="$(lsblk -J -b -o NAME,TYPE,SIZE,RO,FSTYPE,PTTYPE,MOUNTPOINTS)"
if ! jq -e '
[.. | objects | select(has("name"))]
| all(
((.mountpoints | type) == "array")
and ((.ro | type) == "boolean" or .ro == "0" or .ro == "1")
)' <<<"${tree}" >/dev/null; then
echo "::error::lsblk did not report mountpoints and ro for every device; refusing to pick a spare disk" >&2
exit 1
fi
root_disks="$(jq -c '[
.blockdevices[]
| select([.. | objects | .mountpoints[] | select(. == "/")] | length > 0)
| .name
]' <<<"${tree}")"
if [ "${root_disks}" = "[]" ]; then
echo "::error::no block device carries / according to lsblk; refusing to pick a spare disk" >&2
exit 1
fi
disk="$(jq -r --argjson roots "${root_disks}" --argjson min "${min_bytes}" '
[
.blockdevices[]
| select(.type == "disk")
| select(.name | IN($roots[]) | not)
| select(.name | test("^(nvme[0-9]+n[0-9]+|sd[a-z]+|vd[a-z]+|xvd[a-z]+)$"))
| select(.ro == false or .ro == "0")
| select((.children // []) | length == 0)
| select((.fstype // "") == "" and (.pttype // "") == "")
| select([.mountpoints[] | select(. != null)] | length == 0)
| select((.size | tonumber) >= $min)
]
| max_by(.size | tonumber)
| .name // empty' <<<"${tree}")"
if [ -z "${disk}" ]; then
echo "::notice::mount_spare_disk.sh mounted no spare disk: No empty, unpartitioned, unmounted disk of at least ${MIN_SIZE_GB}G besides the root disk ${root_disks}"
exit 0
fi
probe_rc=0
probe="$(sudo blkid -p "/dev/${disk}" 2>&1)" || probe_rc=$?
if [ "${probe_rc}" = 2 ] && [ -n "${probe}" ]; then
echo "::error::blkid -p /dev/${disk} found nothing but complained: ${probe}" >&2
exit 1
fi
case "${probe_rc}" in
2) ;;
0)
echo "::notice::mount_spare_disk.sh mounted no spare disk: /dev/${disk} carries a signature (${probe}), leaving it untouched"
exit 0
;;
*)
echo "::error::blkid -p /dev/${disk} could not tell whether the disk is empty (rc=${probe_rc}): ${probe}" >&2
exit 1
;;
esac
signatures="$(sudo wipefs --no-act --noheadings "/dev/${disk}")"
if [ -n "${signatures}" ]; then
echo "::notice::mount_spare_disk.sh mounted no spare disk: /dev/${disk} carries signatures blkid missed (${signatures}), leaving it untouched"
exit 0
fi
echo "Formatting the empty spare disk /dev/${disk} and mounting it at ${MOUNT_POINT}"
sudo mkfs.ext4 -q -m 0 -L infinito-spare "/dev/${disk}"
sudo mount "/dev/${disk}" "${MOUNT_POINT}"
source_dev="$(findmnt -n -o SOURCE "${MOUNT_POINT}")"
if [ "${source_dev}" != "/dev/${disk}" ]; then
echo "::error::${MOUNT_POINT} is backed by '${source_dev}' after mounting /dev/${disk}" >&2
exit 1
fi
"$DUMP" "after spare-disk mount -> /dev/${disk} at ${MOUNT_POINT}"
+146 -71
View File
@@ -1,98 +1,173 @@
#!/usr/bin/env bash
# Move the host Docker data-root onto /mnt when it is a separate, roomier
# partition. Image/volume storage (swarm's nested DinD-node daemons, the
# DR-drill LUKS loop image, per-node registry pulls) otherwise piles onto
# the ~50 GB root fs and trips the <6 GB abort guard in
# utils/tests/swarm/matrix.py. On public runners without a separate /mnt
# this is a no-op and Docker stays on /.
#
# Best-effort: relocation is an optimization, never a requirement. Any
# failure rolls Docker back onto / and the step still exits 0, so a hiccup
# here can never turn an otherwise-green deploy red. Runs BEFORE any Docker
# usage; the free-disk-space step already pruned the preinstalled images, so
# /var/lib/docker is ~empty and nothing is copied.
#
# Usage: relocate_docker_dataroot.sh [min-free-gb]
# Default floor: 40 GB (swarm peak footprint; below it /mnt buys nothing).
#
# Debug toggle: SKIP_DOCKER_RELOCATE=1 leaves the data-root on /.
# Usage: relocate_docker_dataroot.sh MIN_FREE_GB
# MIN_FREE_GB smallest free space on /mnt that makes the move worth it
# SKIP_DOCKER_RELOCATE=1 leave Docker and containerd on /
set -euo pipefail
MIN_FREE_GB="${1:?MIN_FREE_GB is required - state the smallest free GB on /mnt worth relocating to at the call site}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DUMP="${HERE}/dump_state.sh"
DATA_ROOT=/mnt/docker-host
CONTAINERD_ROOT=/var/lib/containerd
CONTAINERD_TARGET=/mnt/containerd
DAEMON_JSON=/etc/docker/daemon.json
# Param: $1 why Docker and containerd stay on /
decline() {
echo "::notice::relocate_docker_dataroot.sh left Docker and containerd on /: $1"
"$DUMP" "docker-relocate skipped ($1)"
exit 0
}
# Param: $1 what was found that the relocation would lose or orphan
refuse() {
echo "::error::relocate_docker_dataroot.sh refuses to move Docker and containerd: $1" >&2
exit 1
}
if [ "${SKIP_DOCKER_RELOCATE:-0}" = "1" ]; then
echo "SKIP_DOCKER_RELOCATE=1 — leaving Docker data-root on /."
"$DUMP" "docker-relocate skipped (SKIP_DOCKER_RELOCATE=1)"
decline "SKIP_DOCKER_RELOCATE=1"
fi
if [ -n "${ACT:-}" ]; then
echo "::error::relocate_docker_dataroot.sh refuses to run under act (ACT=${ACT}): it restarts Docker and containerd and must never touch the operator's machine" >&2
exit 1
fi
if ! [[ "${MIN_FREE_GB}" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::MIN_FREE_GB must be a positive integer, got '${MIN_FREE_GB}'" >&2
exit 1
fi
if [ "${RUNNER_ENVIRONMENT:-}" != github-hosted ]; then
echo "::notice::relocate_docker_dataroot.sh left Docker and containerd where they are: not a GitHub-hosted runner (RUNNER_ENVIRONMENT='${RUNNER_ENVIRONMENT:-}')"
exit 0
fi
TARGET_DIR=/mnt/docker
MIN_FREE_GB="${1:-40}"
if ! mountpoint -q /mnt 2>/dev/null; then
echo "/mnt is not a separate mount — leaving Docker data-root on /."
"$DUMP" "docker-relocate skipped (no separate /mnt)"
exit 0
if ! mountpoint -q /mnt; then
decline "/mnt is not a separate mount"
fi
mnt_free=$(df --output=avail -B1G /mnt | tail -n1 | tr -d ' ')
root_free=$(df --output=avail -B1G / | tail -n1 | tr -d ' ')
echo "Free: / ${root_free}G, /mnt ${mnt_free}G; need >= ${MIN_FREE_GB}G on /mnt and roomier than /"
if [ "$mnt_free" -lt "$MIN_FREE_GB" ] || [ "$mnt_free" -le "$root_free" ]; then
echo "/mnt below floor or not roomier than / — leaving Docker data-root on /."
"$DUMP" "docker-relocate skipped (/mnt not roomier)"
exit 0
if [ "$mnt_free" -lt "$MIN_FREE_GB" ]; then
decline "/mnt has ${mnt_free}G free, below ${MIN_FREE_GB}G"
fi
if [ "$mnt_free" -le "$root_free" ]; then
decline "/mnt has ${mnt_free}G free, not more than the ${root_free}G on /"
fi
backup=""
if [ -s /etc/docker/daemon.json ]; then
backup="$(mktemp)"
cp /etc/docker/daemon.json "$backup"
if ! systemctl cat containerd.service >/dev/null; then
refuse "containerd.service is missing"
fi
restore_and_continue() {
echo "WARN: Docker data-root relocation failed — keeping Docker on /." >&2
if [ -n "$backup" ]; then
sudo install -m 0644 "$backup" /etc/docker/daemon.json || true # nocheck: shell-or-true -- rollback must reach the docker restart even if the restore fails
else
sudo rm -f /etc/docker/daemon.json || true # nocheck: shell-or-true -- rollback must reach the docker restart even if the cleanup fails
fi
sudo systemctl start docker.service || sudo systemctl restart docker.service || true # nocheck: shell-or-true -- last-ditch daemon start; rollback still exits 0 by design
"$DUMP" "docker-relocate rolled back (kept on /)"
exit 0
}
echo "Relocating Docker data-root to ${TARGET_DIR}"
sudo systemctl stop docker.service docker.socket || true # nocheck: shell-or-true -- inactive/missing units may exit non-zero; a wedged daemon is caught by the post-start root-dir check
sudo mkdir -p "$TARGET_DIR" /etc/docker || restore_and_continue
tmp="$(mktemp)"
if [ -n "$backup" ]; then
jq --arg dr "$TARGET_DIR" '. + {"data-root": $dr}' "$backup" >"$tmp" || restore_and_continue
else
printf '{"data-root": "%s"}\n' "$TARGET_DIR" >"$tmp"
configured_root="$(sudo containerd config dump | sed -nE "s/^root = ['\"](.*)['\"]\$/\1/p")"
if [ "${configured_root}" != "${CONTAINERD_ROOT}" ]; then
refuse "containerd root is '${configured_root}', expected ${CONTAINERD_ROOT}"
fi
sudo install -m 0644 "$tmp" /etc/docker/daemon.json || restore_and_continue
rm -f "$tmp"
sudo systemctl start docker.service || restore_and_continue
containers="$(docker ps -aq)"
[ -z "${containers}" ] || refuse "Docker containers exist: ${containers//$'\n'/ }"
images="$(docker images -q)"
[ -z "${images}" ] || refuse "Docker images exist: ${images//$'\n'/ }"
volumes="$(docker volume ls -q)"
[ -z "${volumes}" ] || refuse "Docker volumes exist: ${volumes//$'\n'/ }"
build_cache="$(docker system df --format '{{.Type}}={{.TotalCount}}' | sed -n 's/^Build Cache=//p')"
[ "${build_cache}" = 0 ] || refuse "the build cache holds '${build_cache}' records"
root_dir=""
attempt=0
while [ "$attempt" -lt 30 ]; do
root_dir="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
[ -n "$root_dir" ] && break
attempt=$((attempt + 1))
sleep 1
namespaces="$(sudo ctr namespaces ls -q)"
for namespace in ${namespaces}; do
[ "${namespace}" != moby ] || continue
foreign="$(sudo ctr -n "${namespace}" containers ls -q)"
[ -z "${foreign}" ] || refuse "containerd namespace ${namespace} holds containers: ${foreign//$'\n'/ }"
tasks="$(sudo ctr -n "${namespace}" tasks ls -q)"
[ -z "${tasks}" ] || refuse "containerd namespace ${namespace} runs tasks: ${tasks//$'\n'/ }"
done
if [ "$root_dir" != "$TARGET_DIR" ]; then
echo "Docker came up with root '${root_dir}', expected '${TARGET_DIR}'." >&2
restore_and_continue
shim_rc=0
shims="$(pgrep -a containerd-shim)" || shim_rc=$?
case "${shim_rc}" in
0) refuse "containerd shims are running: ${shims//$'\n'/; }" ;;
1) ;;
*) refuse "pgrep could not list containerd shims (rc=${shim_rc})" ;;
esac
sudo true
daemon_backup="$(mktemp)"
daemon_new="$(mktemp)"
if sudo test -e "${DAEMON_JSON}"; then
daemon_existed=yes
# shellcheck disable=SC2024
sudo cat "${DAEMON_JSON}" >"${daemon_backup}"
if [ -s "${daemon_backup}" ] && ! jq empty "${daemon_backup}"; then
refuse "${DAEMON_JSON} is not valid JSON"
fi
else
daemon_existed=no
fi
if [ -s "${daemon_backup}" ]; then
jq --arg dr "${DATA_ROOT}" '. + {"data-root": $dr}' "${daemon_backup}" >"${daemon_new}"
else
jq -n --arg dr "${DATA_ROOT}" '{"data-root": $dr}' >"${daemon_new}"
fi
echo "Docker Root Dir: ${root_dir}"
[ -n "$backup" ] && rm -f "$backup"
"$DUMP" "after docker-relocate -> ${TARGET_DIR}"
left_behind="$(sudo du -sxh "${CONTAINERD_ROOT}")"
echo "Left behind on /: ${left_behind%%[[:space:]]*} in ${CONTAINERD_ROOT}"
echo "Relocating the Docker data-root to ${DATA_ROOT} and the containerd root to ${CONTAINERD_TARGET}"
# Param: $1 what failed
rollback() {
trap - ERR INT TERM
set +e
echo "::error::relocation failed ($1); rolling Docker and containerd back onto /" >&2
if ! sudo systemctl stop docker.socket docker.service containerd.service; then
echo "::error::could not stop Docker and containerd for the rollback" >&2
fi
if mountpoint -q "${CONTAINERD_ROOT}" && ! sudo umount "${CONTAINERD_ROOT}"; then
echo "::error::could not unmount the bind over ${CONTAINERD_ROOT}" >&2
fi
if [ "${daemon_existed}" = yes ]; then
if ! sudo install -m 0644 "${daemon_backup}" "${DAEMON_JSON}"; then
echo "::error::could not restore ${DAEMON_JSON}" >&2
fi
elif ! sudo rm -f "${DAEMON_JSON}"; then
echo "::error::could not remove the ${DAEMON_JSON} this script wrote" >&2
fi
if ! sudo systemctl start containerd.service docker.socket docker.service; then
echo "::error::Docker or containerd did not come back up" >&2
fi
sudo systemctl status --no-pager containerd.service docker.service >&2
sudo journalctl --no-pager -n 80 -u containerd.service -u docker.service >&2
exit 1
}
trap 'rollback "command failed: ${BASH_COMMAND}"' ERR
trap 'rollback "interrupted"' INT TERM
sudo systemctl stop docker.socket docker.service containerd.service
sudo mkdir -p "${DATA_ROOT}" "${CONTAINERD_TARGET}" "$(dirname "${DAEMON_JSON}")"
sudo mount --bind "${CONTAINERD_TARGET}" "${CONTAINERD_ROOT}"
sudo install -m 0644 "${daemon_new}" "${DAEMON_JSON}"
sudo systemctl start containerd.service
sudo systemctl start docker.socket docker.service
sudo ctr version >/dev/null
if ! sudo test -f "${CONTAINERD_TARGET}/io.containerd.metadata.v1.bolt/meta.db"; then
rollback "containerd came up without its metadata in ${CONTAINERD_TARGET}"
fi
root_dir="$(docker info --format '{{.DockerRootDir}}')"
if [ "${root_dir}" != "${DATA_ROOT}" ]; then
rollback "Docker came up with root '${root_dir}', expected '${DATA_ROOT}'"
fi
mnt_dev="$(stat -c %d /mnt)"
if [ "$(stat -c %d "${root_dir}")" != "${mnt_dev}" ]; then
rollback "Docker root ${root_dir} is not on the /mnt filesystem"
fi
trap - ERR INT TERM
rm -f "${daemon_backup}" "${daemon_new}"
echo "Docker Root Dir: ${root_dir}; containerd root ${CONTAINERD_ROOT} bound from ${CONTAINERD_TARGET}"
"$DUMP" "after docker-relocate -> ${DATA_ROOT}, containerd -> ${CONTAINERD_TARGET}"
@@ -25,7 +25,11 @@ SIZE="${3:?SIZE is required - state the loop image size (e.g. 30G) at the call s
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
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
Whitespace-only changes.
@@ -0,0 +1,135 @@
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from utils.cache.files import PROJECT_ROOT
SCRIPT = PROJECT_ROOT / "scripts" / "github" / "runner" / "enlarge_swap.sh"
SUDO_STUB = '#!/usr/bin/env bash\nprintf "%s\\n" "$*" >>"${STUB_LOG}"\n'
DF_STUB = (
"#!/usr/bin/env bash\n"
'echo "Avail"\n'
'if [ "${*: -1}" = /mnt ]; then echo "${STUB_MNT_FREE}"; else echo "${STUB_ROOT_FREE}"; fi\n'
)
MOUNTPOINT_STUB = '#!/usr/bin/env bash\n[ "${STUB_MNT_MOUNTED}" = 1 ]\n'
STAT_STUB = (
"#!/usr/bin/env bash\n"
'case "${*: -1}" in\n'
"/mnt) echo 2049 ;;\n"
'/var/lib/containerd) if [ "${STUB_CONTAINERD_DEV}" = missing ]; then exit 1; fi\n'
' echo "${STUB_CONTAINERD_DEV}" ;;\n'
"*) exit 1 ;;\n"
"esac\n"
)
DUMP_STUB = "#!/usr/bin/env bash\nexit 0\n"
def enlarge(
*, mounted: bool, mnt_free: int, root_free: int, containerd_dev: str
) -> tuple[subprocess.CompletedProcess, list[str]]:
"""Run enlarge_swap.sh against stubbed disks.
Args:
mounted: whether /mnt is a separate mount.
mnt_free: GB df reports free on /mnt.
root_free: GB df reports free on /.
containerd_dev: device id stat reports for /var/lib/containerd
(2049 is the /mnt device).
Returns:
The finished process and every sudo call in order.
"""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runner_dir = root / "runner"
runner_dir.mkdir()
(runner_dir / "enlarge_swap.sh").symlink_to(SCRIPT)
bin_dir = root / "bin"
bin_dir.mkdir()
for path, body in (
(runner_dir / "dump_state.sh", DUMP_STUB),
(bin_dir / "sudo", SUDO_STUB),
(bin_dir / "df", DF_STUB),
(bin_dir / "mountpoint", MOUNTPOINT_STUB),
(bin_dir / "stat", STAT_STUB),
):
path.write_text(body)
path.chmod(0o755)
log = root / "sudo.log"
log.touch()
env = {
k: v for k, v in os.environ.items() if k not in ("BASH_ENV", "SKIP_SWAP")
}
env.update(
PATH=f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
STUB_LOG=str(log),
STUB_MNT_MOUNTED="1" if mounted else "0",
STUB_MNT_FREE=str(mnt_free),
STUB_ROOT_FREE=str(root_free),
STUB_CONTAINERD_DEV=containerd_dev,
)
done = subprocess.run(
["bash", str(runner_dir / "enlarge_swap.sh")],
env=env,
capture_output=True,
text=True,
check=False,
)
return done, log.read_text().splitlines() # nocheck: cache-read stub call log
def swapfile(calls: list[str]) -> list[str]:
"""Return the fallocate calls among the recorded sudo calls.
Args:
calls: every recorded sudo call.
Returns:
The calls that create the swapfile.
"""
return [call for call in calls if call.startswith("fallocate ")]
class TestEnlargeSwapPlacement(unittest.TestCase):
def test_swap_goes_to_root_when_containerd_lives_on_the_spare_disk(self) -> None:
done, calls = enlarge(
mounted=True, mnt_free=200, root_free=54, containerd_dev="2049"
)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertEqual(swapfile(calls), ["fallocate -l 16G /swapfile"])
def test_swap_goes_to_mnt_when_containerd_stayed_on_root(self) -> None:
done, calls = enlarge(
mounted=True, mnt_free=60, root_free=54, containerd_dev="66305"
)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertEqual(swapfile(calls), ["fallocate -l 16G /mnt/swapfile"])
def test_large_shape_without_mnt_keeps_swap_on_root(self) -> None:
done, calls = enlarge(
mounted=False, mnt_free=0, root_free=123, containerd_dev="66305"
)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertEqual(swapfile(calls), ["fallocate -l 16G /swapfile"])
def test_missing_containerd_root_keeps_the_mnt_preference(self) -> None:
done, calls = enlarge(
mounted=True, mnt_free=60, root_free=54, containerd_dev="missing"
)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertEqual(swapfile(calls), ["fallocate -l 16G /mnt/swapfile"])
def test_full_root_still_uses_the_spare_disk(self) -> None:
done, calls = enlarge(
mounted=True, mnt_free=200, root_free=10, containerd_dev="2049"
)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertEqual(swapfile(calls), ["fallocate -l 16G /mnt/swapfile"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,457 @@
from __future__ import annotations
import copy
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from utils.cache.files import PROJECT_ROOT
from utils.cache.yaml import load_yaml
SCRIPT = PROJECT_ROOT / "scripts" / "github" / "runner" / "mount_spare_disk.sh"
WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "call-test-deploy.yml"
MIN_SIZE_GB = "75"
GIB = 1024**3
SPARE = "nvme9n9"
SPARE_INDEX = 2
DESTRUCTIVE = ("mkfs.ext4", "blkid", "wipefs", "mount", "mkswap")
SUDO_STUB = (
"#!/usr/bin/env bash\n"
'printf "%s\\n" "$*" >>"${STUB_LOG}"\n'
'if [ -x "${STUB_BIN}/$1" ]; then exec "${STUB_BIN}/$1" "${@:2}"; fi\n'
'echo "unstubbed sudo $1" >&2\n'
"exit 97\n"
)
UNPRIVILEGED_STUB = (
'#!/usr/bin/env bash\necho "$(basename "$0") ran without sudo" >&2\nexit 98\n'
)
LSBLK_STUB = '#!/usr/bin/env bash\ncat "${STUB_LSBLK_JSON}"\n'
MOUNTPOINT_STUB = '#!/usr/bin/env bash\n[ "${STUB_MOUNTED}" = 1 ]\n'
BLKID_STUB = (
'#!/usr/bin/env bash\nprintf "%s" "${STUB_BLKID_OUT}"\nexit "${STUB_BLKID_RC}"\n'
)
WIPEFS_STUB = '#!/usr/bin/env bash\nprintf "%s" "${STUB_WIPEFS_OUT}"\n'
MKFS_STUB = "#!/usr/bin/env bash\nexit 0\n"
MOUNT_STUB = (
'#!/usr/bin/env bash\nprintf "%s" "${STUB_MOUNT_AS:-$1}" >"${STUB_STATE}"\n'
)
FINDMNT_STUB = '#!/usr/bin/env bash\ncat "${STUB_STATE}"\n'
DUMP_STUB = "#!/usr/bin/env bash\nexit 0\n"
def part(name: str, size_gib: float, fstype: str | None, mount: str | None) -> dict:
"""Build one lsblk partition entry.
Args:
name: kernel name of the partition.
size_gib: size in GiB.
fstype: filesystem type, None when unformatted.
mount: mount point, None when unmounted.
Returns:
The entry as lsblk -J -b prints it.
"""
return {
"name": name,
"type": "part",
"size": int(size_gib * GIB),
"ro": False,
"fstype": fstype,
"pttype": "gpt",
"mountpoints": [mount],
}
def snap_loops() -> list[dict]:
"""Return the two snap squashfs loop devices every runner shape carries.
Returns:
The loop entries as lsblk -J -b prints them.
"""
return [
{
"name": f"loop{index}",
"type": "loop",
"size": 77594624,
"ro": True,
"fstype": "squashfs",
"pttype": None,
"mountpoints": [mount],
}
for index, mount in enumerate(("/snap/core22/2955", "/snap/snapd/28254"))
]
def root_disk(name: str, prefix: str, size_gib: int) -> dict:
"""Build the partitioned root disk of a GitHub runner image.
Args:
name: kernel name of the disk.
prefix: partition name prefix (``sda`` or ``nvme0n1p``).
size_gib: size in GiB.
Returns:
The disk entry with its four partitions.
"""
return {
"name": name,
"type": "disk",
"size": size_gib * GIB,
"ro": False,
"fstype": None,
"pttype": "gpt",
"mountpoints": [None],
"children": [
part(f"{prefix}1", size_gib - 1.2, "ext4", "/"),
part(f"{prefix}13", 1, "ext4", "/boot"),
part(f"{prefix}14", 0.004, None, None),
part(f"{prefix}15", 0.1, "vfat", "/boot/efi"),
],
}
def spare_disk(size_gib: int) -> dict:
"""Build the empty second NVMe disk of the small runner shape.
Args:
size_gib: size in GiB.
Returns:
The disk entry without filesystem, partition table or mount.
"""
return {
"name": SPARE,
"type": "disk",
"size": size_gib * GIB,
"ro": False,
"fstype": None,
"pttype": None,
"mountpoints": [None],
}
LARGE_SHAPE = {"blockdevices": [*snap_loops(), root_disk("sda", "sda", 145)]}
SMALL_SHAPE = {
"blockdevices": [
*snap_loops(),
spare_disk(128),
root_disk("nvme0n1", "nvme0n1p", 72),
]
}
def with_spare(**changes: object) -> dict:
"""Return the small shape with fields of the spare disk replaced.
Args:
**changes: lsblk fields to overwrite on the spare disk.
Returns:
A modified deep copy of SMALL_SHAPE.
"""
tree = copy.deepcopy(SMALL_SHAPE)
tree["blockdevices"][SPARE_INDEX].update(changes)
return tree
def run(
tree: dict,
*,
runner_environment: str | None = "github-hosted",
act: str | None = None,
mounted: bool = False,
blkid_rc: int = 2,
blkid_out: str = "",
wipefs_out: str = "",
mount_as: str | None = None,
occupied: bool = False,
min_size_gb: str = MIN_SIZE_GB,
) -> tuple[subprocess.CompletedProcess, list[str]]:
"""Run mount_spare_disk.sh against a faked block-device layout.
Args:
tree: what ``lsblk -J -b`` reports.
runner_environment: RUNNER_ENVIRONMENT, None to leave it unset.
act: ACT, None to leave it unset.
mounted: whether the mount point already is a mount.
blkid_rc: exit code of ``blkid -p`` (2 means no signature).
blkid_out: what ``blkid -p`` prints.
wipefs_out: what ``wipefs --no-act`` prints.
mount_as: device findmnt reports after the mount, None for the mounted one.
occupied: whether the mount point already holds a file.
min_size_gb: the MIN_SIZE_GB argument.
Returns:
The finished process and every privileged call in order.
"""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runner_dir = root / "runner"
runner_dir.mkdir()
(runner_dir / "mount_spare_disk.sh").symlink_to(SCRIPT)
bin_dir = root / "bin"
bin_dir.mkdir()
stub_bin = root / "privileged"
stub_bin.mkdir()
stubs = [
(runner_dir / "dump_state.sh", DUMP_STUB),
(bin_dir / "sudo", SUDO_STUB),
(bin_dir / "lsblk", LSBLK_STUB),
(bin_dir / "mountpoint", MOUNTPOINT_STUB),
(bin_dir / "findmnt", FINDMNT_STUB),
(stub_bin / "blkid", BLKID_STUB),
(stub_bin / "wipefs", WIPEFS_STUB),
(stub_bin / "mkfs.ext4", MKFS_STUB),
(stub_bin / "mount", MOUNT_STUB),
]
stubs += [(bin_dir / name, UNPRIVILEGED_STUB) for name in DESTRUCTIVE]
for path, body in stubs:
path.write_text(body)
path.chmod(0o755)
mount_point = root / "mnt"
mount_point.mkdir()
if occupied:
(mount_point / "keep.txt").write_text("data\n")
lsblk_json = root / "lsblk.json"
lsblk_json.write_text(json.dumps(tree))
log = root / "sudo.log"
log.touch()
env = {
k: v
for k, v in os.environ.items()
if k not in ("BASH_ENV", "ACT", "RUNNER_ENVIRONMENT")
}
env.update(
PATH=f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
STUB_LOG=str(log),
STUB_BIN=str(stub_bin),
STUB_LSBLK_JSON=str(lsblk_json),
STUB_MOUNTED="1" if mounted else "0",
STUB_BLKID_RC=str(blkid_rc),
STUB_BLKID_OUT=blkid_out,
STUB_WIPEFS_OUT=wipefs_out,
STUB_STATE=str(root / "mounted-source"),
)
if mount_as is not None:
env["STUB_MOUNT_AS"] = mount_as
if runner_environment is not None:
env["RUNNER_ENVIRONMENT"] = runner_environment
if act is not None:
env["ACT"] = act
done = subprocess.run(
[
"bash",
str(runner_dir / "mount_spare_disk.sh"),
str(mount_point),
min_size_gb,
],
env=env,
capture_output=True,
text=True,
check=False,
)
return done, log.read_text().splitlines() # nocheck: cache-read stub call log
def formatted(calls: list[str]) -> bool:
"""Tell whether any privileged call formatted or mounted a disk.
Args:
calls: every recorded sudo call.
Returns:
True when mkfs or mount ran.
"""
return any(call.startswith(("mkfs", "mount ")) for call in calls)
class TestMountSpareDisk(unittest.TestCase):
def assert_untouched(self, done: subprocess.CompletedProcess, reason: str) -> None:
"""Assert a declined run: exit 0, no privileged call, the stated reason.
Args:
done: the finished script.
reason: text the script must print for this decline.
"""
self.assertEqual(done.returncode, 0, done.stderr)
self.assertIn(reason, done.stdout + done.stderr)
self.assertRegex(done.stdout, r"::(notice|warning)::")
def test_small_shape_formats_and_mounts_the_empty_nvme_disk(self) -> None:
done, calls = run(SMALL_SHAPE)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertEqual(
calls[:3],
[
f"blkid -p /dev/{SPARE}",
f"wipefs --no-act --noheadings /dev/{SPARE}",
f"mkfs.ext4 -q -m 0 -L infinito-spare /dev/{SPARE}",
],
)
self.assertTrue(calls[3].startswith(f"mount /dev/{SPARE} "), calls)
self.assertEqual(len(calls), 4, calls)
def test_large_shape_without_a_spare_disk_stays_as_it_is(self) -> None:
done, calls = run(LARGE_SHAPE)
self.assert_untouched(done, '["sda"]')
self.assertEqual(calls, [])
def test_self_hosted_runner_is_never_formatted(self) -> None:
done, calls = run(SMALL_SHAPE, runner_environment="self-hosted")
self.assert_untouched(done, "not a GitHub-hosted runner")
self.assertEqual(calls, [])
def test_unknown_runner_environment_is_never_formatted(self) -> None:
done, calls = run(SMALL_SHAPE, runner_environment=None)
self.assert_untouched(done, "not a GitHub-hosted runner")
self.assertEqual(calls, [])
def test_act_refuses_loudly_even_with_a_faked_github_runner(self) -> None:
for act in ("true", "1", "TRUE"):
with self.subTest(act=act):
done, calls = run(SMALL_SHAPE, act=act)
self.assertEqual(done.returncode, 1)
self.assertEqual(calls, [])
self.assertIn("refuses to run under act", done.stderr)
def test_disk_with_a_filesystem_is_never_formatted(self) -> None:
done, calls = run(with_spare(fstype="ext4"))
self.assert_untouched(done, "No empty, unpartitioned, unmounted disk")
self.assertEqual(calls, [])
def test_disk_with_partitions_is_never_formatted(self) -> None:
done, calls = run(with_spare(children=[part(f"{SPARE}p1", 127, None, None)]))
self.assert_untouched(done, "No empty, unpartitioned, unmounted disk")
self.assertEqual(calls, [])
def test_disk_held_by_lvm_is_never_formatted(self) -> None:
holder = {
"name": "vg-data",
"type": "lvm",
"size": 127 * GIB,
"ro": False,
"fstype": None,
"pttype": None,
"mountpoints": [None],
}
done, calls = run(with_spare(children=[holder]))
self.assert_untouched(done, "No empty, unpartitioned, unmounted disk")
self.assertEqual(calls, [])
def test_disk_with_a_bare_partition_table_is_never_formatted(self) -> None:
done, calls = run(with_spare(pttype="dos"))
self.assert_untouched(done, "No empty, unpartitioned, unmounted disk")
self.assertEqual(calls, [])
def test_mounted_disk_is_never_formatted(self) -> None:
done, calls = run(with_spare(mountpoints=["/data"]))
self.assert_untouched(done, "No empty, unpartitioned, unmounted disk")
self.assertEqual(calls, [])
def test_disk_used_as_swap_is_never_formatted(self) -> None:
done, calls = run(with_spare(mountpoints=["[SWAP]"]))
self.assert_untouched(done, "No empty, unpartitioned, unmounted disk")
self.assertEqual(calls, [])
def test_read_only_disk_is_never_formatted(self) -> None:
done, calls = run(with_spare(ro=True))
self.assert_untouched(done, "No empty, unpartitioned, unmounted disk")
self.assertEqual(calls, [])
def test_disk_below_the_size_floor_stays_as_it_is(self) -> None:
done, calls = run(with_spare(size=32 * GIB))
self.assert_untouched(done, "No empty, unpartitioned, unmounted disk")
self.assertEqual(calls, [])
def test_signature_lsblk_missed_is_never_formatted(self) -> None:
done, calls = run(SMALL_SHAPE, blkid_rc=0, blkid_out='TYPE="xfs"')
self.assert_untouched(done, "carries a signature")
self.assertFalse(formatted(calls), calls)
def test_signature_only_wipefs_sees_is_never_formatted(self) -> None:
done, calls = run(SMALL_SHAPE, wipefs_out="0x218 LVM2_member")
self.assert_untouched(done, "carries signatures blkid missed")
self.assertFalse(formatted(calls), calls)
def test_inconclusive_probe_fails_loudly_without_formatting(self) -> None:
done, calls = run(SMALL_SHAPE, blkid_rc=8, blkid_out="ambivalent result")
self.assertEqual(done.returncode, 1)
self.assertFalse(formatted(calls), calls)
def test_probe_that_found_nothing_but_complained_fails_loudly(self) -> None:
done, calls = run(SMALL_SHAPE, blkid_rc=2, blkid_out="Input/output error")
self.assertEqual(done.returncode, 1)
self.assertFalse(formatted(calls), calls)
def test_lsblk_without_mountpoints_fails_loudly(self) -> None:
tree = copy.deepcopy(SMALL_SHAPE)
spare = tree["blockdevices"][SPARE_INDEX]
spare["mountpoint"] = spare.pop("mountpoints")[0]
done, calls = run(tree)
self.assertEqual(done.returncode, 1)
self.assertEqual(calls, [])
def test_layout_without_a_root_device_fails_loudly(self) -> None:
done, calls = run({"blockdevices": [spare_disk(128)]})
self.assertEqual(done.returncode, 1)
self.assertEqual(calls, [])
def test_non_numeric_size_floor_fails_loudly(self) -> None:
done, calls = run(SMALL_SHAPE, min_size_gb="RANDOM")
self.assertEqual(done.returncode, 1)
self.assertEqual(calls, [])
def test_existing_mount_stays_as_it_is(self) -> None:
done, calls = run(SMALL_SHAPE, mounted=True)
self.assert_untouched(done, "already a separate mount")
self.assertEqual(calls, [])
def test_occupied_mount_point_stays_as_it_is(self) -> None:
done, calls = run(SMALL_SHAPE, occupied=True)
self.assert_untouched(done, "is not empty")
self.assertEqual(calls, [])
def test_mount_landing_on_another_device_fails_loudly(self) -> None:
done, _ = run(SMALL_SHAPE, mount_as="/dev/nvme0n1p1")
self.assertEqual(done.returncode, 1)
self.assertIn("backed by '/dev/nvme0n1p1'", done.stderr)
class TestWorkflowWiring(unittest.TestCase):
def test_spare_disk_step_is_act_gated_and_runs_before_swap_and_relocation(
self,
) -> None:
workflow = load_yaml(str(WORKFLOW))
steps = next(
job["steps"]
for job in workflow["jobs"].values()
if any(
"mount_spare_disk.sh" in str(step.get("run", ""))
for step in job.get("steps", [])
)
)
runs = [str(step.get("run", "")) for step in steps]
index = next(i for i, cmd in enumerate(runs) if "mount_spare_disk.sh" in cmd)
self.assertEqual(
runs[index],
f"./scripts/github/runner/mount_spare_disk.sh /mnt {MIN_SIZE_GB}",
)
self.assertEqual(steps[index].get("if"), "${{ !env.ACT }}")
swap = next(i for i, cmd in enumerate(runs) if "enlarge_swap.sh" in cmd)
relocate = next(
i for i, cmd in enumerate(runs) if "relocate_docker_dataroot.sh" in cmd
)
self.assertLess(index, relocate)
self.assertLess(relocate, swap)
self.assertEqual(
runs[relocate], "./scripts/github/runner/relocate_docker_dataroot.sh 75"
)
self.assertEqual(steps[relocate].get("if"), "${{ !env.ACT }}")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,524 @@
from __future__ import annotations
import json
import os
import subprocess
import tempfile
import unittest
from dataclasses import dataclass, field
from pathlib import Path
from utils.cache.files import PROJECT_ROOT
SCRIPT = PROJECT_ROOT / "scripts" / "github" / "runner" / "relocate_docker_dataroot.sh"
MIN_FREE_GB = "75"
DUMP_V2 = "version = 3\nroot = '/var/lib/containerd'\nstate = '/run/containerd'\n"
DUMP_V1 = 'version = 2\nroot = "/var/lib/containerd"\nstate = "/run/containerd"\n'
STOP = "systemctl stop docker.socket docker.service containerd.service"
BIND = "mount --bind /mnt/containerd /var/lib/containerd"
UNBIND = "umount /var/lib/containerd"
RESTART = "systemctl start containerd.service docker.socket docker.service"
SUDO_STUB = (
"#!/usr/bin/env bash\n"
'printf "%s\\n" "$*" >>"${STUB_LOG}"\n'
'if [ -x "${STUB_BIN}/$1" ]; then exec "${STUB_BIN}/$1" "${@:2}"; fi\n'
'echo "unstubbed sudo $1" >&2\n'
"exit 97\n"
)
UNPRIVILEGED_STUB = (
'#!/usr/bin/env bash\necho "$(basename "$0") ran without sudo" >&2\nexit 98\n'
)
TERM_HOOK = (
'if [ "$(basename "$0")" = "${STUB_TERM_ON}" ]; then kill -TERM "${PPID}"; fi\n'
)
UNPRIVILEGED = {
"mountpoint": (
"#!/usr/bin/env bash\n"
'case "${*: -1}" in\n'
'/mnt) [ "${STUB_MNT_MOUNTED}" = 1 ] ;;\n'
'/var/lib/containerd) [ -e "${STUB_STATE}/bound" ] ;;\n'
"*) exit 1 ;;\n"
"esac\n"
),
"df": (
"#!/usr/bin/env bash\n"
'echo "Avail"\n'
'if [ "${*: -1}" = /mnt ]; then echo "${STUB_MNT_FREE}"; '
'else echo "${STUB_ROOT_FREE}"; fi\n'
),
"systemctl": '#!/usr/bin/env bash\nexit "${STUB_CONTAINERD_UNIT_RC}"\n',
"docker": (
"#!/usr/bin/env bash\n"
'case "$1" in\n'
'ps) printf "%s" "${STUB_CONTAINERS}" ;;\n'
'images) printf "%s" "${STUB_IMAGES}" ;;\n'
'volume) printf "%s" "${STUB_VOLUMES}" ;;\n'
'system) printf "Images=0\\nContainers=0\\nLocal Volumes=0\\n'
'Build Cache=%s\\n" "${STUB_BUILD_CACHE}" ;;\n'
'info) echo "${STUB_DOCKER_ROOT}" ;;\n'
"*) exit 96 ;;\n"
"esac\n"
),
"stat": (
"#!/usr/bin/env bash\n"
'case "${*: -1}" in\n'
"/mnt) echo 2049 ;;\n"
'/mnt/docker-host) echo "${STUB_DOCKER_ROOT_DEV}" ;;\n'
"*) echo 66305 ;;\n"
"esac\n"
),
"pgrep": '#!/usr/bin/env bash\nprintf "%s" "${STUB_SHIMS}"\nexit "${STUB_SHIM_RC}"\n',
}
PRIVILEGED = {
"systemctl": (
"#!/usr/bin/env bash\n"
'if [ "$1" = status ]; then exit "${STUB_STATUS_RC}"; fi\n'
'if [ "$*" = "${STUB_FAIL_SYSTEMCTL}" ]; then exit 1; fi\n'
),
"containerd": '#!/usr/bin/env bash\nprintf "%s" "${STUB_CONTAINERD_DUMP}"\n',
"ctr": (
"#!/usr/bin/env bash\n"
'if [ "$1" = namespaces ]; then printf "%s" "${STUB_NAMESPACES}"; exit; fi\n'
'if [ "$1" = -n ] && [ "$2" = "${STUB_FOREIGN_NS}" ]; then\n'
' if [ "$3" = containers ]; then printf "%s" "${STUB_FOREIGN_CONTAINERS}"; fi\n'
' if [ "$3" = tasks ]; then printf "%s" "${STUB_FOREIGN_TASKS}"; fi\n'
"fi\n"
),
"du": '#!/usr/bin/env bash\nprintf "1.2G\\t%s\\n" "${*: -1}"\n',
"true": '#!/usr/bin/env bash\nexit "${STUB_SUDO_RC}"\n',
"test": (
"#!/usr/bin/env bash\n"
'if [ "$1" = -e ]; then [ "${STUB_DAEMON_EXISTS}" = 1 ]; exit; fi\n'
'[ "${STUB_METADATA}" = 1 ]\n'
),
"cat": (
"#!/usr/bin/env bash\n"
'if [ "${STUB_DAEMON_CAT_RC}" != 0 ]; then exit "${STUB_DAEMON_CAT_RC}"; fi\n'
'printf "%s" "${STUB_DAEMON_IN}"\n'
),
"mkdir": "#!/usr/bin/env bash\n" + TERM_HOOK,
"mount": (
"#!/usr/bin/env bash\n"
'if [ "${STUB_MOUNT_RC}" != 0 ]; then exit "${STUB_MOUNT_RC}"; fi\n'
'touch "${STUB_STATE}/bound"\n'
),
"umount": '#!/usr/bin/env bash\nrm -f "${STUB_STATE}/bound"\n',
"install": '#!/usr/bin/env bash\ncp "${*: -2:1}" "${STUB_DAEMON_OUT}"\n',
"rm": '#!/usr/bin/env bash\n/bin/rm -f "${STUB_DAEMON_OUT}"\n',
"journalctl": "#!/usr/bin/env bash\nexit 0\n",
}
DESTRUCTIVE = ("mount", "umount", "install", "ctr", "containerd", "journalctl", "mkdir")
@dataclass
class Runner:
"""The faked runner state relocate_docker_dataroot.sh meets.
Args:
runner_environment: RUNNER_ENVIRONMENT, None to leave it unset.
act: ACT, None to leave it unset.
skip: SKIP_DOCKER_RELOCATE, None to leave it unset.
mounted: whether /mnt is a separate mount.
mnt_free: GB df reports free on /mnt.
root_free: GB df reports free on /.
dump: what ``containerd config dump`` prints.
containerd_unit_rc: exit code of ``systemctl cat containerd.service``.
containers: what ``docker ps -aq`` prints.
images: what ``docker images -q`` prints.
volumes: what ``docker volume ls -q`` prints.
build_cache: Build Cache record count in ``docker system df``.
namespaces: what ``ctr namespaces ls -q`` prints.
foreign_ns: namespace whose containers and tasks are faked.
foreign_containers: containers in foreign_ns.
foreign_tasks: tasks in foreign_ns.
shims: what ``pgrep -a containerd-shim`` prints.
shim_rc: exit code of pgrep (1 means no shim).
sudo_rc: exit code of ``sudo true``.
daemon_exists: whether daemon.json exists.
daemon_in: content of the existing daemon.json.
daemon_cat_rc: exit code of ``sudo cat`` on daemon.json.
mount_rc: exit code of the bind mount.
fail_systemctl: the one systemctl argument string that fails.
status_rc: exit code of ``systemctl status``.
term_on: privileged stub that sends SIGTERM to the script.
metadata: whether containerd's meta.db shows up under /mnt/containerd.
docker_root: what ``docker info`` reports as DockerRootDir.
docker_root_dev: device id stat reports for /mnt/docker-host.
min_free_gb: the MIN_FREE_GB argument.
"""
runner_environment: str | None = "github-hosted"
act: str | None = None
skip: str | None = None
mounted: bool = True
mnt_free: int = 137
root_free: int = 51
dump: str = DUMP_V2
containerd_unit_rc: int = 0
containers: str = ""
images: str = ""
volumes: str = ""
build_cache: str = "0"
namespaces: str = "moby\n"
foreign_ns: str = "k8s.io"
foreign_containers: str = ""
foreign_tasks: str = ""
shims: str = ""
shim_rc: int = 1
sudo_rc: int = 0
daemon_exists: bool = False
daemon_in: str = ""
daemon_cat_rc: int = 0
mount_rc: int = 0
fail_systemctl: str = ""
status_rc: int = 0
term_on: str = ""
metadata: bool = True
docker_root: str = "/mnt/docker-host"
docker_root_dev: str = "2049"
min_free_gb: str = MIN_FREE_GB
extra: dict[str, str] = field(default_factory=dict)
@dataclass
class Outcome:
"""What a relocation run left behind.
Args:
done: the finished script.
calls: every privileged call in order.
daemon: the daemon.json on disk afterwards, None when there is none
or it is no valid JSON.
daemon_raw: the daemon.json bytes on disk afterwards, None when there is none.
bound: whether the containerd bind mount is still in place.
"""
done: subprocess.CompletedProcess
calls: list[str]
daemon: dict | None
daemon_raw: str | None
bound: bool
def relocate(runner: Runner) -> Outcome:
"""Run relocate_docker_dataroot.sh against a faked runner.
Args:
runner: the faked services, disks and files.
Returns:
The outcome of the run.
"""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runner_dir = root / "runner"
bin_dir = root / "bin"
stub_bin = root / "privileged"
state = root / "state"
for directory in (runner_dir, bin_dir, stub_bin, state):
directory.mkdir()
(runner_dir / "relocate_docker_dataroot.sh").symlink_to(SCRIPT)
stubs = [(runner_dir / "dump_state.sh", "#!/usr/bin/env bash\nexit 0\n")]
stubs += [(bin_dir / "sudo", SUDO_STUB)]
stubs += [(bin_dir / name, UNPRIVILEGED_STUB) for name in DESTRUCTIVE]
stubs += [(bin_dir / name, body) for name, body in UNPRIVILEGED.items()]
stubs += [(stub_bin / name, body) for name, body in PRIVILEGED.items()]
for path, body in stubs:
path.write_text(body)
path.chmod(0o755)
log = root / "calls.log"
log.touch()
daemon_out = root / "daemon.json"
if runner.daemon_exists:
daemon_out.write_text(runner.daemon_in)
env = {
k: v
for k, v in os.environ.items()
if k
not in ("BASH_ENV", "ACT", "RUNNER_ENVIRONMENT", "SKIP_DOCKER_RELOCATE")
}
env.update(
PATH=f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
STUB_LOG=str(log),
STUB_BIN=str(stub_bin),
STUB_STATE=str(state),
STUB_MNT_MOUNTED="1" if runner.mounted else "0",
STUB_MNT_FREE=str(runner.mnt_free),
STUB_ROOT_FREE=str(runner.root_free),
STUB_CONTAINERD_DUMP=runner.dump,
STUB_CONTAINERD_UNIT_RC=str(runner.containerd_unit_rc),
STUB_CONTAINERS=runner.containers,
STUB_IMAGES=runner.images,
STUB_VOLUMES=runner.volumes,
STUB_BUILD_CACHE=runner.build_cache,
STUB_NAMESPACES=runner.namespaces,
STUB_FOREIGN_NS=runner.foreign_ns,
STUB_FOREIGN_CONTAINERS=runner.foreign_containers,
STUB_FOREIGN_TASKS=runner.foreign_tasks,
STUB_SHIMS=runner.shims,
STUB_SHIM_RC=str(runner.shim_rc),
STUB_SUDO_RC=str(runner.sudo_rc),
STUB_DAEMON_EXISTS="1" if runner.daemon_exists else "0",
STUB_DAEMON_IN=runner.daemon_in,
STUB_DAEMON_CAT_RC=str(runner.daemon_cat_rc),
STUB_DAEMON_OUT=str(daemon_out),
STUB_MOUNT_RC=str(runner.mount_rc),
STUB_FAIL_SYSTEMCTL=runner.fail_systemctl,
STUB_STATUS_RC=str(runner.status_rc),
STUB_TERM_ON=runner.term_on,
STUB_METADATA="1" if runner.metadata else "0",
STUB_DOCKER_ROOT=runner.docker_root,
STUB_DOCKER_ROOT_DEV=runner.docker_root_dev,
**runner.extra,
)
optional = {
"RUNNER_ENVIRONMENT": runner.runner_environment,
"ACT": runner.act,
"SKIP_DOCKER_RELOCATE": runner.skip,
}
env.update({k: v for k, v in optional.items() if v is not None})
done = subprocess.run(
[
"bash",
str(runner_dir / "relocate_docker_dataroot.sh"),
runner.min_free_gb,
],
env=env,
capture_output=True,
text=True,
check=False,
)
calls = log.read_text().splitlines() # nocheck: cache-read stub call log
content = (
daemon_out.read_text() # nocheck: cache-read stub output
if daemon_out.exists()
else None
)
try:
daemon = json.loads(content) if content else None
except json.JSONDecodeError:
daemon = None
return Outcome(
done=done,
calls=calls,
daemon=daemon,
daemon_raw=content,
bound=(state / "bound").exists(),
)
class TestRelocation(unittest.TestCase):
def test_small_shape_moves_containerd_and_docker_onto_the_spare_disk(self) -> None:
out = relocate(Runner())
self.assertEqual(out.done.returncode, 0, out.done.stderr)
stop, bind = out.calls.index(STOP), out.calls.index(BIND)
start = out.calls.index("systemctl start containerd.service")
self.assertLess(stop, bind)
self.assertLess(bind, start)
self.assertIn("systemctl start docker.socket docker.service", out.calls)
self.assertIn(
"test -f /mnt/containerd/io.containerd.metadata.v1.bolt/meta.db", out.calls
)
self.assertEqual(out.daemon, {"data-root": "/mnt/docker-host"})
self.assertTrue(out.bound)
def test_containerd_1x_dump_with_double_quotes_is_understood(self) -> None:
out = relocate(Runner(dump=DUMP_V1))
self.assertEqual(out.done.returncode, 0, out.done.stderr)
self.assertIn(BIND, out.calls)
def test_existing_daemon_json_keeps_its_settings(self) -> None:
out = relocate(Runner(daemon_exists=True, daemon_in='{"debug": true}'))
self.assertEqual(out.done.returncode, 0, out.done.stderr)
self.assertEqual(out.daemon, {"debug": True, "data-root": "/mnt/docker-host"})
class TestDeclines(unittest.TestCase):
def assert_declined(self, out: Outcome, reason: str) -> None:
"""Assert a visible no-op: exit 0, a notice with the reason, no call.
Args:
out: the outcome of the run.
reason: text the notice must carry.
"""
self.assertEqual(out.done.returncode, 0, out.done.stderr)
self.assertIn("::notice::", out.done.stdout)
self.assertIn(reason, out.done.stdout)
self.assertEqual(out.calls, [])
def test_large_shape_without_a_mnt_mount(self) -> None:
self.assert_declined(
relocate(Runner(mounted=False)), "/mnt is not a separate mount"
)
def test_self_hosted_runner(self) -> None:
self.assert_declined(
relocate(Runner(runner_environment="self-hosted")),
"not a GitHub-hosted runner",
)
def test_unknown_runner_environment(self) -> None:
self.assert_declined(
relocate(Runner(runner_environment=None)), "not a GitHub-hosted runner"
)
def test_mnt_below_the_floor(self) -> None:
self.assert_declined(relocate(Runner(mnt_free=60, root_free=20)), "below 75G")
def test_mnt_not_roomier_than_root(self) -> None:
self.assert_declined(
relocate(Runner(mnt_free=80, root_free=90)), "not more than"
)
def test_skip_toggle(self) -> None:
self.assert_declined(relocate(Runner(skip="1")), "SKIP_DOCKER_RELOCATE=1")
class TestRefusalsBeforeTheStop(unittest.TestCase):
def assert_refused(self, out: Outcome, reason: str) -> None:
"""Assert a loud refusal before anything was stopped or written.
Args:
out: the outcome of the run.
reason: text the error must carry.
"""
self.assertEqual(out.done.returncode, 1)
self.assertIn(reason, out.done.stderr)
self.assertNotIn(STOP, out.calls)
self.assertFalse([c for c in out.calls if c.startswith(("mount ", "install "))])
self.assertFalse(out.bound)
def test_act_even_with_a_faked_github_runner(self) -> None:
for act in ("true", "1"):
with self.subTest(act=act):
self.assert_refused(
relocate(Runner(act=act)), "refuses to run under act"
)
def test_non_numeric_floor(self) -> None:
self.assert_refused(relocate(Runner(min_free_gb="forty")), "positive integer")
def test_missing_containerd_unit(self) -> None:
self.assert_refused(
relocate(Runner(containerd_unit_rc=1)), "containerd.service is missing"
)
def test_containerd_root_elsewhere(self) -> None:
self.assert_refused(
relocate(Runner(dump="version = 3\nroot = '/data/containerd'\n")),
"/data/containerd",
)
def test_existing_containers(self) -> None:
self.assert_refused(relocate(Runner(containers="abc123\n")), "abc123")
def test_existing_images(self) -> None:
self.assert_refused(relocate(Runner(images="sha256feed\n")), "sha256feed")
def test_existing_volumes(self) -> None:
self.assert_refused(
relocate(Runner(volumes="core_infinito\n")), "core_infinito"
)
def test_existing_build_cache(self) -> None:
self.assert_refused(relocate(Runner(build_cache="3")), "build cache")
def test_containers_in_another_namespace(self) -> None:
out = relocate(
Runner(namespaces="moby\nk8s.io\n", foreign_containers="pause1\n")
)
self.assert_refused(out, "k8s.io holds containers: pause1")
def test_tasks_in_another_namespace(self) -> None:
out = relocate(Runner(namespaces="moby\nk8s.io\n", foreign_tasks="task7\n"))
self.assert_refused(out, "k8s.io runs tasks: task7")
def test_running_shims(self) -> None:
out = relocate(Runner(shims="4242 containerd-shim-runc-v2 -id x\n", shim_rc=0))
self.assert_refused(out, "containerd shims are running")
def test_shim_listing_error(self) -> None:
self.assert_refused(
relocate(Runner(shim_rc=2)), "could not list containerd shims"
)
def test_broken_sudo(self) -> None:
out = relocate(Runner(sudo_rc=1))
self.assertEqual(out.done.returncode, 1)
self.assertNotIn(STOP, out.calls)
def test_unreadable_daemon_json_is_never_overwritten(self) -> None:
out = relocate(Runner(daemon_exists=True, daemon_in="{}", daemon_cat_rc=1))
self.assertEqual(out.done.returncode, 1)
self.assertNotIn(STOP, out.calls)
self.assertFalse([c for c in out.calls if c.startswith("install ")])
self.assertEqual(out.daemon, {})
def test_invalid_daemon_json_is_never_overwritten(self) -> None:
out = relocate(Runner(daemon_exists=True, daemon_in="{not json"))
self.assertEqual(out.done.returncode, 1)
self.assertIn("is not valid JSON", out.done.stderr)
self.assertNotIn(STOP, out.calls)
self.assertFalse([c for c in out.calls if c.startswith("install ")])
self.assertEqual(out.daemon_raw, "{not json")
class TestRollback(unittest.TestCase):
def assert_rolled_back(self, out: Outcome, reason: str) -> None:
"""Assert the rollback: no bind left, daemons restarted last, exit 1.
Args:
out: the outcome of the run.
reason: text the rollback error must carry.
"""
self.assertEqual(out.done.returncode, 1, out.done.stderr)
self.assertIn("rolling Docker and containerd back onto /", out.done.stderr)
self.assertIn(reason, out.done.stderr)
self.assertFalse(out.bound)
self.assertIn(RESTART, out.calls)
self.assertTrue([c for c in out.calls if c.startswith("journalctl ")])
def test_failed_bind_mount_removes_the_daemon_json_it_never_had(self) -> None:
out = relocate(Runner(mount_rc=32))
self.assert_rolled_back(out, "mount --bind")
self.assertIsNone(out.daemon)
def test_failed_containerd_start_unbinds_before_the_restart(self) -> None:
out = relocate(
Runner(
daemon_exists=True,
daemon_in='{"debug": true}',
fail_systemctl="start containerd.service",
)
)
self.assert_rolled_back(out, "systemctl start containerd.service")
self.assertLess(out.calls.index(UNBIND), len(out.calls) - 1)
self.assertLess(out.calls.index(UNBIND), out.calls.index(RESTART))
self.assertEqual(out.daemon, {"debug": True})
def test_cancel_during_the_move_rolls_back(self) -> None:
out = relocate(Runner(term_on="mkdir"))
self.assert_rolled_back(out, "interrupted")
def test_down_units_still_yield_the_journal(self) -> None:
out = relocate(Runner(mount_rc=32, status_rc=3))
self.assert_rolled_back(out, "mount --bind")
def test_containerd_without_metadata_on_the_spare_disk(self) -> None:
out = relocate(Runner(metadata=False))
self.assert_rolled_back(out, "without its metadata")
self.assertIn(UNBIND, out.calls)
self.assertIsNone(out.daemon)
def test_docker_root_elsewhere(self) -> None:
out = relocate(Runner(docker_root="/var/lib/docker"))
self.assert_rolled_back(out, "expected '/mnt/docker-host'")
def test_docker_root_off_the_spare_disk(self) -> None:
out = relocate(Runner(docker_root_dev="66305"))
self.assert_rolled_back(out, "not on the /mnt filesystem")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,110 @@
from __future__ import annotations
import shutil
import unittest
import unittest.mock as mock
from pathlib import Path
from typing import NamedTuple
from utils.tests.swarm import run
class Usage(NamedTuple):
total: int
used: int
free: int
def lowest(free_gib: dict[str, int], existing: set[str]) -> tuple[int, str]:
"""Call lowest_free_mb with faked disks.
Args:
free_gib: free GiB per storage root.
existing: storage roots that exist as directories.
Returns:
What lowest_free_mb reports.
"""
with (
mock.patch.object(
shutil,
"disk_usage",
side_effect=lambda root: Usage(0, 0, free_gib[root] * 2**30),
),
mock.patch.object(
Path, "is_dir", autospec=True, side_effect=lambda p: str(p) in existing
),
):
return run.lowest_free_mb()
class TestLowestFreeMb(unittest.TestCase):
def test_a_full_spare_disk_under_containerd_is_seen(self) -> None:
free, root = lowest(
{"/": 40, "/var/lib/containerd": 5}, {"/", "/var/lib/containerd"}
)
self.assertEqual((free, root), (5 * 1024, "/var/lib/containerd"))
self.assertLess(free, run.DISK_FLOOR_MB)
def test_a_full_root_is_still_seen(self) -> None:
free, root = lowest(
{"/": 5, "/var/lib/containerd": 150}, {"/", "/var/lib/containerd"}
)
self.assertEqual((free, root), (5 * 1024, "/"))
def test_a_host_without_containerd_measures_root_only(self) -> None:
free, root = lowest({"/": 30}, {"/"})
self.assertEqual((free, root), (30 * 1024, "/"))
def watch(
waits: list[object], free: tuple[int, str]
) -> tuple[int, mock.MagicMock, str]:
"""Run run_watched over a fake step.
Args:
waits: successive outcomes of the step's wait(): an exception to raise
or an exit code to return.
free: what lowest_free_mb reports on every sample.
Returns:
The exit code, the fake process, and everything printed.
"""
proc = mock.MagicMock()
proc.wait.side_effect = waits
printed = mock.MagicMock()
with (
mock.patch.object(run.subprocess, "Popen", return_value=proc),
mock.patch.object(run.subprocess, "run"),
mock.patch.object(run, "lowest_free_mb", return_value=free),
mock.patch.object(run, "mem_available_mb", return_value=12000),
mock.patch.object(run, "mem_total_mb", return_value=16000),
mock.patch.object(run, "mem_stall_pct", return_value=0.0),
mock.patch("builtins.print", printed),
):
rc = run.run_watched(["step"], env={})
text = " ".join(str(call.args[0]) for call in printed.call_args_list)
return rc, proc, text
class TestRunWatchedSampling(unittest.TestCase):
def test_samples_every_ten_seconds(self) -> None:
timeout = run.subprocess.TimeoutExpired("step", run.SAMPLE_INTERVAL_S)
rc, proc, _ = watch([timeout, timeout, 0], (50_000, "/"))
self.assertEqual(rc, 0)
self.assertEqual(run.SAMPLE_INTERVAL_S, 10)
self.assertEqual(
[call.kwargs["timeout"] for call in proc.wait.call_args_list],
[10, 10, 10],
)
def test_a_full_containerd_root_aborts_the_step(self) -> None:
timeout = run.subprocess.TimeoutExpired("step", run.SAMPLE_INTERVAL_S)
rc, proc, text = watch([timeout, 0], (1_000, "/var/lib/containerd"))
self.assertEqual(rc, 75)
proc.terminate.assert_called_once_with()
self.assertIn("free on /var/lib/containerd", text)
if __name__ == "__main__":
unittest.main()
+21 -5
View File
@@ -3,12 +3,28 @@ from __future__ import annotations
import shutil
import subprocess
import time
from pathlib import Path
from utils import PROJECT_ROOT
from utils.env.runtime import mem_available_mb, mem_stall_pct, mem_total_mb
DISK_FLOOR_MB = 6 * 2**10
MEM_FLOOR_RATIO = 0.06
STORAGE_ROOTS = ("/", "/var/lib/containerd")
SAMPLE_INTERVAL_S = 10
def lowest_free_mb() -> tuple[int, str]:
"""Measure the storage root closest to running full.
Returns:
Free MB on the fullest existing root in STORAGE_ROOTS, and that root.
"""
return min(
(shutil.disk_usage(root).free // 2**20, root)
for root in STORAGE_ROOTS
if Path(root).is_dir()
)
def _abort(proc: subprocess.Popen[bytes], banner: str, probe: list[str]) -> int:
@@ -61,22 +77,22 @@ def run_watched(cmd: list[str], *, env: dict[str, str]) -> int:
proc = subprocess.Popen(cmd, cwd=str(PROJECT_ROOT), env=env)
while True:
try:
return int(proc.wait(timeout=30))
return int(proc.wait(timeout=SAMPLE_INTERVAL_S))
except subprocess.TimeoutExpired:
free_mb = shutil.disk_usage("/").free // 2**20
free_mb, root = lowest_free_mb()
if free_mb < DISK_FLOOR_MB:
return _abort(
proc,
"=== swarm-matrix: DISK EXHAUSTION IMMINENT "
f"(<{DISK_FLOOR_MB}M free on /) - aborting step ===",
["df", "-h", "/"],
f"(<{DISK_FLOOR_MB}M free on {root}) - aborting step ===",
["df", "-h", *STORAGE_ROOTS],
)
avail = mem_available_mb()
total = mem_total_mb()
stall = mem_stall_pct()
print(
f"=== swarm-matrix: host mem {avail}M/{total}M available, "
f"disk {free_mb}M free on /, stall60 {stall:.1f}% ===",
f"disk {free_mb}M free on {root}, stall60 {stall:.1f}% ===",
flush=True,
)
if total and avail < total * MEM_FLOOR_RATIO: