diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5352f72..9f85dd4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -32,6 +32,7 @@ jobs: - name: Check runner config and isolation guards run: | bash infra/runner/test-runner-config.sh + bash infra/runner/test-install-runner-idempotency.sh bash infra/runner/test-install-runner-guard.sh - name: Check infrastructure shell syntax run: | @@ -40,8 +41,9 @@ jobs: infra/deploy/install-helper.sh infra/deploy/compose-helper.sh infra/deploy/test-compose-helper.sh \ infra/deploy/prepare-production-config.sh infra/deploy/test-production-config-guard.sh \ infra/bootstrap/test-audit-host.sh infra/bootstrap/test-verify-helper.sh \ - infra/runner/install-runner.sh infra/runner/act-runner-healthcheck.sh \ - infra/runner/test-runner-config.sh infra/runner/test-install-runner-guard.sh + infra/runner/install-runner.sh infra/runner/install-runner-lib.sh \ + infra/runner/act-runner-healthcheck.sh infra/runner/test-runner-config.sh \ + infra/runner/test-install-runner-idempotency.sh infra/runner/test-install-runner-guard.sh - name: Run rollback helper tests run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v - name: Validate production Compose and Caddyfile diff --git a/docs/operations/CI_RUNNER.md b/docs/operations/CI_RUNNER.md index 410335c..89242a0 100644 --- a/docs/operations/CI_RUNNER.md +++ b/docs/operations/CI_RUNNER.md @@ -49,16 +49,24 @@ for runner images/workspaces; and configures systemd services. It starts only the separate rootless Docker daemon; the runner and health timer stay disabled until repository registration. It does not change SSH/UFW, production Docker configuration, application data, or production -secrets. It refuses to overwrite existing runner paths and requires at least -25 GiB free on `/` before reserving storage. +secrets. It can be rerun after partial setup: it validates and reuses an +existing runner account, subordinate ID mappings, image, and mount unit. An +existing image must be exactly 15 GiB and contain ext4; a mismatch stops the +installer without resizing or formatting it. The runner account must retain +a non-root system UID below `UID_MIN`, its expected home, primary group, and +sole supplementary group. The mount +is started only when it is not already active, and an active mount must be the +expected ext4 loop device backed by the runner image. Conflicting files or +state stop the installer for manual inspection. A new image requires at least +25 GiB free on `/`. If the reviewed checkout is not present at the inventoried path, first verify -that the target is absent and clone the reviewed PR branch there. This path is -currently absent per the read-only audit; do not overwrite it if that changes. +that the target is absent and clone the reviewed `main` there. Do not overwrite +an existing checkout. ```bash sudo test ! -e /opt/akyldash/current -sudo git clone --branch feature/infrastructure-deployment-phase-2 \ +sudo git clone --branch main \ --single-branch https://git.vakanaut.ru/admin/akyldash.git \ /opt/akyldash/current ``` @@ -114,16 +122,22 @@ that token; do not put it in command-line arguments or environment variables. Update `runner_version` and its expected official SHA-256 together in `infra/runner/install-runner.sh`, review upstream release compatibility with -the installed Gitea, and open a PR. From the reviewed checkout, re-run the -installer only after resolving its existing-path guards. Do not replace the -binary while a job is active. Stop the runner, install/verify the new binary, -then start it and confirm Online plus a smoke run. Preserve `/var/lib/akyldash-runner/.runner`. +the installed Gitea, and open a PR. Do not replace the binary while a job is +active. Stop the runner, install/verify the new binary, then start it and +confirm Online plus a smoke run. Preserve `/var/lib/akyldash-runner/.runner`. ## Recovery and rollback The runner is disposable: production data and secrets are not on its filesystem. For a runner process failure, inspect `systemctl status` and `journalctl -u akyldash-act-runner.service`; restart the fixed runner service. +If installation stops after creating or enabling the mount unit, inspect the +image and mount with `stat`, `blkid`, `findmnt`, and `losetup`, then rerun the +reviewed installer only when the image is 15 GiB ext4 and the active mount is +backed by it. The installer recognizes that state and does not format or +restart the mounted filesystem. If size, filesystem, unit contents, or backing +device differ, stop and resolve the state manually; never format the existing +image as a retry. For lost/corrupt registration state, stop the service and obtain a fresh repository-level token from Settings → Actions → Runners. Preserve the 15 GiB data image and state for diagnosis; re-register only after the repository @@ -133,6 +147,9 @@ automatic recovery step. ## Status -The configuration and scripts are prepared in Git. They have not been -installed on the VPS, a repository-level token is not available to this agent, -and no runner Online state or workflow execution is claimed yet. +The root installer and registration are incomplete. The owner reports that the +15 GiB ext4 loop-backed mount is active on `/dev/loop0` after manually running +`systemctl daemon-reload`, `enable`, and `start` when the first installer run +stopped at mount activation. This PR has not changed the VPS and does not claim +that remaining runner components are installed, a repository token is +available, the runner is Online, or a workflow has run. diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index a3cbb8d..0516dad 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -86,8 +86,11 @@ UFW rule details, and protected file metadata remain unverified. 3 GiB RAM, 512 processes), account slice limits (2.5 CPU, 4 GiB RAM, 2048 tasks), rotated file logs, and a systemd health timer. Jobs use the `akyldash-ci` label and do not receive the rootless or production Docker - socket. This is repository code only: no runner package, account, store, - service, token, or registration is installed on the VPS. + socket. The owner reports a partial runner installation: the 15 GiB ext4 + loop-backed store is active on `/dev/loop0` after manually enabling and + starting its mount unit when the installer stopped. Remaining components, + repository registration, and Online state are unverified; this PR makes no + VPS changes. - Gitea repository runner inventory could not be read by the current API principal (403). The owner has not supplied runner state, and no runner Online status or runner-hosted workflow is claimed. The manual diff --git a/infra/runner/install-runner-lib.sh b/infra/runner/install-runner-lib.sh new file mode 100644 index 0000000..42b058f --- /dev/null +++ b/infra/runner/install-runner-lib.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +runner_die() { printf 'ERROR: %s\n' "$*" >&2; return 1; } + +runner_ensure_account() { + local user=$1 home=$2 entry uid passwd_home shell groups uid_min + if ! entry=$(getent passwd "$user"); then + [[ ! -e $home && ! -L $home ]] || { runner_die "runner home already exists without its account: $home"; return 1; } + useradd --system --create-home --home-dir "$home" --shell /usr/sbin/nologin "$user" || return 1 + return + fi + IFS=: read -r _ _ uid _ _ passwd_home shell <<<"$entry" + uid_min=$(awk '$1 == "UID_MIN" && $2 ~ /^[0-9]+$/ {print $2; exit}' /etc/login.defs) + [[ $uid =~ ^[0-9]+$ && $uid -gt 0 && $uid_min =~ ^[0-9]+$ && $uid -lt $uid_min && $passwd_home == "$home" && $shell == /usr/sbin/nologin ]] || { runner_die "existing $user account has an unexpected UID, home, or shell"; return 1; } + [[ $(id -gn "$user") == "$user" ]] || { runner_die "existing $user account has an unexpected primary group"; return 1; } + groups=$(id -nG "$user") + [[ $groups == "$user" ]] || { runner_die "existing $user account has unexpected supplementary groups: $groups"; return 1; } + [[ -d $home && ! -L $home && $(stat -c %U "$home") == "$user" ]] || { runner_die "existing $user home is missing or has unexpected ownership"; return 1; } +} + +runner_ensure_image() { + local image=$1 size=$2 bytes=$3 actual filesystem + if [[ -e $image || -L $image ]]; then + [[ -f $image && ! -L $image ]] || { runner_die "existing runner image is not a regular file: $image"; return 1; } + actual=$(stat -c %s "$image") || return + [[ $actual == "$bytes" ]] || { runner_die "existing runner image has size $actual bytes; expected $bytes; refusing to resize or format"; return 1; } + filesystem=$(blkid -p -s TYPE -o value "$image" 2>/dev/null || true) + [[ $filesystem == ext4 ]] || { runner_die "existing runner image does not contain ext4; refusing to format"; return 1; } + return + fi + fallocate -l "$size" "$image" || return 1 + mkfs.ext4 -F -q "$image" || return 1 + actual=$(stat -c %s "$image") || return + filesystem=$(blkid -p -s TYPE -o value "$image" 2>/dev/null || true) + [[ $actual == "$bytes" && $filesystem == ext4 ]] || { runner_die 'new runner image failed size or ext4 verification'; return 1; } +} + +runner_install_file() { + local source=$1 destination=$2 owner=$3 group=$4 mode=${5#0} + if [[ -e $destination || -L $destination ]]; then + [[ -f $destination && ! -L $destination ]] && cmp -s "$source" "$destination" || { runner_die "existing runner file differs from reviewed configuration: $destination"; return 1; } + [[ $(stat -c '%U:%G:%a' "$destination") == "$owner:$group:$mode" ]] || { runner_die "existing runner file has unexpected ownership or permissions: $destination"; return 1; } + return + fi + install -o "$owner" -g "$group" -m "0$mode" "$source" "$destination" +} + +runner_install_content() { + local destination=$1 owner=$2 group=$3 mode=$4 temporary + temporary=$(mktemp) + cat >"$temporary" + if ! runner_install_file "$temporary" "$destination" "$owner" "$group" "$mode"; then + rm -f "$temporary" + return 1 + fi + rm -f "$temporary" +} + +runner_validate_mount() { + local image=$1 store=$2 target filesystem source backing mount_info + mount_info=$(findmnt --noheadings --output TARGET,FSTYPE,SOURCE --mountpoint "$store") || { runner_die 'runner store is not mounted'; return 1; } + read -r target filesystem source <<<"$mount_info" || { runner_die 'runner store mount information is empty'; return 1; } + [[ $target == "$store" && $filesystem == ext4 && $source == /dev/loop* ]] || { runner_die 'runner store is mounted from an unexpected target, filesystem, or device'; return 1; } + backing=$(losetup --noheadings --output BACK-FILE "$source") || { runner_die 'cannot identify the runner loop backing file'; return 1; } + [[ $(realpath -e "$backing") == "$(realpath -e "$image")" ]] || { runner_die 'runner store is backed by a different image'; return 1; } +} + +runner_ensure_mount() { + local image=$1 store=$2 unit_name=$3 unit_file=$4 unit_tmp mounted=0 + [[ -d $store && ! -L $store ]] || { runner_die "runner mount point is missing or is not a directory: $store"; return 1; } + if mountpoint -q "$store"; then + runner_validate_mount "$image" "$store" || return 1 + mounted=1 + fi + unit_tmp=$(mktemp) + if ! cat >"$unit_tmp" </dev/null; then + runner_ensure_account "$runner_user" "$runner_home" || die 'runner account validation failed' +else + [[ ! -e $runner_home && ! -L $runner_home ]] || die 'runner home exists without its account; inspect it before continuing' +fi +if [[ -e $runner_image || -L $runner_image ]]; then + runner_ensure_image "$runner_image" 15G "$runner_image_bytes" || die 'runner image validation failed' +else + [[ $(df -Pk / | awk 'NR==2 {print $4}') -ge 25000000 ]] || die 'need at least 25 GiB free before reserving 15 GiB for runner data' +fi -for path in "$runner_home" "$runner_image" "$runner_store" /etc/akyldash-runner; do - [[ ! -e $path ]] || die "$path already exists; inspect it and follow the documented recovery procedure" +for file in /etc/subuid /etc/subgid; do + awk -F: '!/^[[:space:]]*#/ && !/^[[:space:]]*$/ && (NF != 3 || $2 !~ /^[0-9]+$/ || $3 !~ /^[0-9]+$/ || $3 < 1) { bad=1 } END { exit bad }' "$file" || die "$file contains an invalid subordinate ID entry" +done +subuid_pair=$(awk -F: -v user="$runner_user" '$1 == user { count++; pair=$2 ":" $3 } END { if (count > 1) exit 2; if (count == 1) print pair }' /etc/subuid) || die 'runner has multiple subuid allocations; inspect them before continuing' +subgid_pair=$(awk -F: -v user="$runner_user" '$1 == user { count++; pair=$2 ":" $3 } END { if (count > 1) exit 2; if (count == 1) print pair }' /etc/subgid) || die 'runner has multiple subgid allocations; inspect them before continuing' +for pair in "$subuid_pair" "$subgid_pair"; do + [[ -z $pair || $pair =~ ^[0-9]+:65536$ ]] || die 'existing runner subordinate ID allocation is not a single 65536-ID range' +done +if [[ -n $subuid_pair && -n $subgid_pair && $subuid_pair != "$subgid_pair" ]]; then + die 'runner subuid and subgid allocations differ; inspect them before continuing' +fi +existing_pair=${subuid_pair:-$subgid_pair} +if [[ -n $existing_pair ]]; then + subid_start=${existing_pair%%:*} + [[ $subid_start =~ ^[1-9][0-9]*$ && $subid_start -ge 100000 && $subid_start -le 4294901760 ]] || die 'existing runner subordinate ID range is outside the supported range' + subid_end=$((subid_start + 65535)) + subid_range="$subid_start-$subid_end" +else + subid_range=$(awk -f "$(dirname "$0")/find-free-subid-range.awk" /etc/subuid /etc/subgid) || die 'could not find a free 65536-ID range shared by /etc/subuid and /etc/subgid' + subid_start=${subid_range%-*} + subid_end=${subid_range#*-} +fi +for file in /etc/subuid /etc/subgid; do + awk -F: -v user="$runner_user" -v start="$subid_start" -v end="$subid_end" '$1 != user && $2 <= end && $2 + $3 - 1 >= start { conflict=1 } END { exit conflict }' "$file" || die "runner subordinate ID range conflicts with another entry in $file" done -! id "$runner_user" >/dev/null 2>&1 || die "user $runner_user already exists; inspect it before continuing" -[[ ! -e /usr/local/bin/act_runner ]] || die '/usr/local/bin/act_runner already exists' -[[ ! -e /etc/systemd/system/akyldash-act-runner.service ]] || die 'runner service already exists' -[[ ! -e /etc/systemd/system/akyldash-runner-healthcheck.service ]] || die 'runner health monitor already exists' -[[ ! -e /etc/logrotate.d/akyldash-runner ]] || die 'runner logrotate policy already exists' -[[ ! -e $runner_mount_unit ]] || die 'runner data mount unit already exists' -[[ $(df -Pk / | awk 'NR==2 {print $4}') -ge 25000000 ]] || die 'need at least 25 GiB free before reserving 15 GiB for runner data' - apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y uidmap dbus-user-session curl logrotate docker-ce-rootless-extras -useradd --system --create-home --home-dir "$runner_home" --shell /usr/sbin/nologin "$runner_user" -usermod --add-subuids "$subid_range" --add-subgids "$subid_range" "$runner_user" +runner_ensure_account "$runner_user" "$runner_home" || die 'runner account creation failed' +subid_args=() +[[ -n $subuid_pair ]] || subid_args+=(--add-subuids "$subid_range") +[[ -n $subgid_pair ]] || subid_args+=(--add-subgids "$subid_range") +if ((${#subid_args[@]})); then + usermod "${subid_args[@]}" "$runner_user" +fi runner_uid=$(id -u "$runner_user") -install -d -o "$runner_user" -g "$runner_user" -m 0700 "$runner_home" "$runner_store" "$runner_home/.config/docker" "$runner_home/.config/systemd/user/docker.service.d" +install -d -o "$runner_user" -g "$runner_user" -m 0700 "$runner_home" "$runner_home/.config/docker" "$runner_home/.config/systemd/user/docker.service.d" +if [[ ! -e $runner_store && ! -L $runner_store ]]; then + install -d -o root -g root -m 0755 "$runner_store" +fi +[[ -d $runner_store && ! -L $runner_store ]] || die 'runner mount path is not a directory' install -d -o root -g "$runner_user" -m 0750 /etc/akyldash-runner install -d -o "$runner_user" -g "$runner_user" -m 0750 /var/log/akyldash-runner -fallocate -l 15G "$runner_image" -mkfs.ext4 -F -q "$runner_image" -cat >"$runner_mount_unit" <"$runner_home/.config/docker/daemon.json" <"$runner_home/.config/systemd/user/docker.service.d/limits.conf" <<'EOF' +runner_install_content "$runner_home/.config/systemd/user/docker.service.d/limits.conf" "$runner_user" "$runner_user" 0644 <<'EOF' [Service] Delegate=yes CPUQuota=200% @@ -100,9 +124,7 @@ TasksMax=1024 Restart=always RestartSec=5 EOF -chown -R "$runner_user:$runner_user" "$runner_home/.config" - -install -o root -g root -m 0644 /dev/stdin /etc/systemd/system/akyldash-act-runner.service <"/etc/systemd/system/user-$runner_uid.slice.d/limits.conf" </etc/logrotate.d/akyldash-runner <<'EOF' +runner_install_content /etc/logrotate.d/akyldash-runner root root 0644 <<'EOF' /var/log/akyldash-runner/act_runner.log { daily rotate 7 @@ -158,14 +180,20 @@ cat >/etc/logrotate.d/akyldash-runner <<'EOF' copytruncate } EOF -install -o root -g root -m 0644 "$(dirname "$0")/akyldash-runner-healthcheck.service" /etc/systemd/system/akyldash-runner-healthcheck.service -install -o root -g root -m 0644 "$(dirname "$0")/akyldash-runner-healthcheck.timer" /etc/systemd/system/akyldash-runner-healthcheck.timer -install -o root -g root -m 0644 "$(dirname "$0")/akyldash-runner-restart.service" /etc/systemd/system/akyldash-runner-restart.service +runner_install_file "$(dirname "$0")/akyldash-runner-healthcheck.service" /etc/systemd/system/akyldash-runner-healthcheck.service root root 0644 +runner_install_file "$(dirname "$0")/akyldash-runner-healthcheck.timer" /etc/systemd/system/akyldash-runner-healthcheck.timer root root 0644 +runner_install_file "$(dirname "$0")/akyldash-runner-restart.service" /etc/systemd/system/akyldash-runner-restart.service root root 0644 loginctl enable-linger "$runner_user" systemctl daemon-reload systemctl start "user@$runner_uid.service" -runuser -u "$runner_user" -- env HOME="$runner_home" XDG_RUNTIME_DIR="/run/user/$runner_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$runner_uid/bus" dockerd-rootless-setuptool.sh install --force +rootless_docker_unit="$runner_home/.config/systemd/user/docker.service" +if [[ -e $rootless_docker_unit || -L $rootless_docker_unit ]]; then + [[ -f $rootless_docker_unit && ! -L $rootless_docker_unit && $(stat -c %U "$rootless_docker_unit") == "$runner_user" ]] || die 'existing rootless Docker unit has an unexpected type or owner' + grep -Fq 'dockerd-rootless.sh' "$rootless_docker_unit" || die 'existing rootless Docker unit differs from the expected service' +else + runuser -u "$runner_user" -- env HOME="$runner_home" XDG_RUNTIME_DIR="/run/user/$runner_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$runner_uid/bus" dockerd-rootless-setuptool.sh install +fi runuser -u "$runner_user" -- env HOME="$runner_home" XDG_RUNTIME_DIR="/run/user/$runner_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$runner_uid/bus" systemctl --user daemon-reload runuser -u "$runner_user" -- env HOME="$runner_home" XDG_RUNTIME_DIR="/run/user/$runner_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$runner_uid/bus" systemctl --user enable --now docker.service diff --git a/infra/runner/test-install-runner-idempotency.sh b/infra/runner/test-install-runner-idempotency.sh new file mode 100644 index 0000000..51700f5 --- /dev/null +++ b/infra/runner/test-install-runner-idempotency.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +runner_dir=$(cd "$(dirname "$0")" && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" "$tmp/store" "$tmp/partial-store" +log=$tmp/commands.log +export RUNNER_TEST_LOG=$log + +cat >"$tmp/bin/fallocate" <<'EOF' +#!/usr/bin/env bash +printf 'fallocate\n' >>"$RUNNER_TEST_LOG" +truncate -s 1048576 "$3" +EOF +cat >"$tmp/bin/mkfs.ext4" <<'EOF' +#!/usr/bin/env bash +printf 'mkfs\n' >>"$RUNNER_TEST_LOG" +printf 'ext4\n' >"$3.fs" +EOF +cat >"$tmp/bin/blkid" <<'EOF' +#!/usr/bin/env bash +cat "${@: -1}.fs" +EOF +cat >"$tmp/bin/systemctl" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$RUNNER_TEST_LOG" +if [[ $1 == start ]]; then + [[ ${RUNNER_TEST_FAIL_START:-0} == 0 ]] || exit 1 + : >"$RUNNER_TEST_MOUNTED" +fi +EOF +cat >"$tmp/bin/mountpoint" <<'EOF' +#!/usr/bin/env bash +[[ -e $RUNNER_TEST_MOUNTED ]] +EOF +cat >"$tmp/bin/findmnt" <<'EOF' +#!/usr/bin/env bash +printf '%s ext4 /dev/loop0\n' "$RUNNER_TEST_STORE" +EOF +cat >"$tmp/bin/losetup" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$RUNNER_TEST_BACKING" +EOF +chmod +x "$tmp/bin/"* +PATH="$tmp/bin:$PATH" +export PATH +source "$runner_dir/install-runner-lib.sh" +getent() { + [[ $1 == passwd ]] || return 2 + if [[ -n ${RUNNER_TEST_PASSWD:-} ]]; then + printf '%s\n' "$RUNNER_TEST_PASSWD" + elif [[ -r ${RUNNER_TEST_ACCOUNT_STATE:-} ]]; then + cat "$RUNNER_TEST_ACCOUNT_STATE" + else + return 2 + fi +} +id() { + case $1 in + -gn) printf '%s\n' "${RUNNER_TEST_PRIMARY_GROUP:-akyldash-runner}" ;; + -nG) [[ ${TEST_EXTRA_GROUP:-0} == 1 ]] && printf 'akyldash-runner docker\n' || printf 'akyldash-runner\n' ;; + *) command id "$@" ;; + esac +} +stat() { + if [[ $1 == -c && $2 == %U && $3 == ${RUNNER_TEST_HOME:-} ]]; then + printf '%s\n' "${RUNNER_TEST_HOME_OWNER:-akyldash-runner}" + else + command stat "$@" + fi +} +useradd() { + printf 'useradd\n' >>"$RUNNER_TEST_LOG" + local user=${@: -1} home= + while (($#)); do + if [[ $1 == --home-dir ]]; then home=$2; shift 2; else shift; fi + done + mkdir -p "$home" + printf '%s:x:990:990::%s:/usr/sbin/nologin\n' "$user" "$home" >"$RUNNER_TEST_ACCOUNT_STATE" +} + +export RUNNER_TEST_HOME="$tmp/account-home" +export RUNNER_TEST_ACCOUNT_STATE="$tmp/account.passwd" +mkdir -p "$RUNNER_TEST_HOME" +export RUNNER_TEST_PASSWD="akyldash-runner:x:990:990::${RUNNER_TEST_HOME}:/usr/sbin/nologin" +runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" +TEST_EXTRA_GROUP=1 +if runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with extra group membership was accepted' >&2 + exit 1 +fi +unset TEST_EXTRA_GROUP +if RUNNER_TEST_PASSWD="akyldash-runner:x:990:990::${tmp}/wrong-home:/usr/sbin/nologin" runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with a different home was accepted' >&2 + exit 1 +fi +if RUNNER_TEST_PASSWD="akyldash-runner:x:0:990::${RUNNER_TEST_HOME}:/usr/sbin/nologin" runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with UID 0 was accepted' >&2 + exit 1 +fi +if RUNNER_TEST_PASSWD="akyldash-runner:x:1000:990::${RUNNER_TEST_HOME}:/usr/sbin/nologin" runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with a regular-user UID was accepted' >&2 + exit 1 +fi +RUNNER_TEST_PRIMARY_GROUP=unexpected +if runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then + echo 'Runner account with an unexpected primary group was accepted' >&2 + exit 1 +fi +unset RUNNER_TEST_PRIMARY_GROUP +unset RUNNER_TEST_PASSWD +export RUNNER_TEST_HOME="$tmp/new-account-home" +runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" +runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" +[[ $(grep -c '^useradd$' "$log") == 1 ]] + +image=$tmp/docker-store.img +runner_ensure_image "$image" 1M 1048576 +runner_ensure_image "$image" 1M 1048576 +[[ $(grep -c '^fallocate$' "$log") == 1 ]] +[[ $(grep -c '^mkfs$' "$log") == 1 ]] + +printf 'ext4\n' >"$image.fs" +truncate -s 1048575 "$image" +if runner_ensure_image "$image" 1M 1048576 2>/dev/null; then + echo 'Mismatched image size was accepted' >&2 + exit 1 +fi +[[ $(grep -c '^mkfs$' "$log") == 1 ]] +truncate -s 1048576 "$image" +printf 'ext3\n' >"$image.fs" +if runner_ensure_image "$image" 1M 1048576 2>/dev/null; then + echo 'Existing non-ext4 image was accepted' >&2 + exit 1 +fi +[[ $(grep -c '^mkfs$' "$log") == 1 ]] +printf 'ext4\n' >"$image.fs" + +unit_name='var-lib-akyldash\x2drunner-docker\x2ddata.mount' +unit_file=$tmp/systemd/$unit_name +mkdir -p "$(dirname "$unit_file")" +export RUNNER_TEST_STORE="$tmp/store" RUNNER_TEST_BACKING="$image" +export RUNNER_TEST_MOUNTED="$tmp/mounted" +runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" +[[ $(grep -c '^start ' "$log") == 1 ]] +grep -Fq "enable $unit_name" "$log" +grep -Fq "start $unit_name" "$log" + +# A second run with the image mounted must reuse it and avoid another start. +runner_ensure_image "$image" 1M 1048576 +runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" +[[ $(grep -c '^mkfs$' "$log") == 1 ]] +[[ $(grep -c '^start ' "$log") == 1 ]] +chmod 0664 "$unit_file" +if runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" 2>/dev/null; then + echo 'Writable systemd mount unit was accepted' >&2 + exit 1 +fi +chmod 0644 "$unit_file" +export RUNNER_TEST_BACKING="$tmp/unrelated.img" +touch "$RUNNER_TEST_BACKING" +if runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file" 2>/dev/null; then + echo 'Active mount backed by a different image was accepted' >&2 + exit 1 +fi +[[ $(grep -c '^start ' "$log") == 1 ]] +export RUNNER_TEST_BACKING="$image" + +# Simulate interruption after unit installation/enable, before mount activation. +export RUNNER_TEST_STORE="$tmp/partial-store" RUNNER_TEST_MOUNTED="$tmp/partial-mounted" +export RUNNER_TEST_FAIL_START=1 +if runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/partial.mount" 2>/dev/null; then + echo 'Injected mount interruption was not observed' >&2 + exit 1 +fi +export RUNNER_TEST_FAIL_START=0 +runner_ensure_image "$image" 1M 1048576 +runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/partial.mount" +[[ $(grep -c '^mkfs$' "$log") == 1 ]] +[[ $(grep -c '^start ' "$log") == 3 ]] +echo 'runner installer idempotency checks passed' diff --git a/infra/runner/test-runner-config.sh b/infra/runner/test-runner-config.sh index 24f2cb8..f7d9f51 100644 --- a/infra/runner/test-runner-config.sh +++ b/infra/runner/test-runner-config.sh @@ -17,10 +17,19 @@ grep -Eq '^ valid_volumes: \[\]$' "$config" grep -Fq -- '--cpus=2 --memory=3g --pids-limit=512' "$config" assert_absent 'var/run/docker.sock|metrics:|health_check:|production.env|review_secret' "$config" grep -Fq 'docker-ce-rootless-extras' "$installer" -grep -Fq 'fallocate -l 15G' "$installer" +grep -Fq 'runner_ensure_image "$runner_image" 15G "$runner_image_bytes"' "$installer" +grep -Fq 'fallocate -l "$size" "$image"' infra/runner/install-runner-lib.sh +grep -Fq 'mkfs.ext4 -F -q "$image"' infra/runner/install-runner-lib.sh +grep -Fq 'systemctl enable "$unit_name"' infra/runner/install-runner-lib.sh +grep -Fq 'systemctl start "$unit_name"' infra/runner/install-runner-lib.sh +assert_absent 'basename.*runner_mount_unit|enable --now.*runner_mount_unit' "$installer" +grep -Fq 'groups=$(id -nG "$user")' infra/runner/install-runner-lib.sh +grep -Fq 'id -gn "$user"' infra/runner/install-runner-lib.sh +assert_absent 'dockerd-rootless-setuptool.sh install --force' "$installer" grep -Fq 'runner_sha256=3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2' "$installer" grep -Fq 'find-free-subid-range.awk' "$installer" -grep -Fq 'usermod --add-subuids "$subid_range" --add-subgids "$subid_range"' "$installer" +grep -Fq 'usermod "${subid_args[@]}" "$runner_user"' "$installer" +grep -Fq 'subid_args+=(--add-subuids "$subid_range")' "$installer" assert_absent 'usermod.*(sudo|docker)|/var/run/docker.sock|/etc/sudoers' "$installer" grep -Fq 'runs-on: akyldash-ci' .gitea/workflows/runner-smoke.yaml grep -Fq 'test ! -S /var/run/docker.sock' .gitea/workflows/runner-smoke.yaml