Merge pull request 'Make runner installer safe to resume' (#44) from fix/runner-installer-resume into main
Reviewed-on: #44 Reviewed-by: agent <agent@vakanaut.ru>
This commit was merged in pull request #44.
This commit is contained in:
commit
e6b099cf31
10 files changed
+707
-78
No files matched your search
@@ -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,10 @@ 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
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/runner/secure-runner-image.py').read_text())"
|
||||
- name: Run rollback helper tests
|
||||
run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v
|
||||
- name: Validate production Compose and Caddyfile
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Runner installer защищает image и mountpoint root-owned home-каталогом, оставляет регистрацию и workspace в runner-owned каталоге, безопасно форматирует образ по открытому FD и проверяет `nosuid,nodev`.
|
||||
- Backend `0.11.3`: ключ подписи поисковых снимков читается из Docker secret-файла, а не из аргументов процесса. Добавлена базовая инфраструктура VPS, Caddy HTTPS proxy с разделёнными Compose-сетями и CI-проверка конфигурации; публикация образов и production-деплой остаются выключены до проверки runner, registry, backup/restore и ограниченного deploy-helper.
|
||||
- Frontend `0.1.2`: на планшетной ширине экран поиска переходит в одну колонку, чтобы фильтры, результаты и документ помещались в окно.
|
||||
- Backend `0.11.2`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе.
|
||||
|
||||
@@ -16,7 +16,11 @@ before placing it on the host.
|
||||
|
||||
CI jobs run in `docker.gitea.com/runner-images:ubuntu-24.04` containers with
|
||||
capacity 1, per-job limits of 2 CPU, 3 GiB RAM and 512 processes, and a 15 GiB
|
||||
dedicated ext4 loop-backed Docker data filesystem. The rootless Docker daemon
|
||||
dedicated ext4 loop-backed Docker data filesystem. The runner account's home
|
||||
directory is root-owned mode `0711`, so CI cannot rename the image or mountpoint
|
||||
entries. Docker config remains under `.config`; registration state and the
|
||||
runner workspace live in `.config/act-runner`, which is runner-owned. The
|
||||
rootless Docker daemon
|
||||
and runner user are separate from production Docker; `codex` is not added to
|
||||
sudo or Docker groups. Jobs do not receive the rootless Docker socket, the
|
||||
production Docker socket, production environment/secrets, or arbitrary host
|
||||
@@ -49,16 +53,36 @@ 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. After validating the account, the installer locks
|
||||
its home directory to root ownership and mode `0711`; it refuses to rerun while
|
||||
the runner service is active, so it cannot interrupt a job. 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 with both `nosuid` and
|
||||
`nodev` active. Runner-home directories and user configuration files are
|
||||
created in runner-owned subdirectories. Root creates only the first `.config`
|
||||
entry beneath the already locked home; subsequent directory and config writes
|
||||
run as `akyldash-runner`. The installer rejects static symlinks and validates
|
||||
ownership and mode after setup. This also normalizes the `0755` intermediate
|
||||
directories left by the first installer version. Conflicting files or state
|
||||
stop the installer for manual inspection. A new image requires at least 25 GiB
|
||||
free on `/`. Image creation opens each parent component without following
|
||||
symlinks, creates the image exclusively relative to the opened directory, and
|
||||
passes the open file descriptor to `mkfs.ext4`; validation checks the same inode
|
||||
and rejects path replacement. Systemd mounts the image under the root-owned home
|
||||
entry, which the runner cannot replace after validation.
|
||||
|
||||
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
|
||||
```
|
||||
@@ -75,7 +99,8 @@ not send the token in chat or save it in the checkout. Register interactively
|
||||
in a terminal so it is not placed in shell history or process arguments:
|
||||
|
||||
```bash
|
||||
sudo -u akyldash-runner -H /usr/local/bin/act_runner \
|
||||
sudo -u akyldash-runner env HOME=/var/lib/akyldash-runner/.config/act-runner \
|
||||
/usr/local/bin/act_runner \
|
||||
--config /etc/akyldash-runner/config.yaml register
|
||||
```
|
||||
|
||||
@@ -83,7 +108,7 @@ Enter `https://git.vakanaut.ru/`, the token, runner name `akyldash-ci-vps-01`,
|
||||
and label `akyldash-ci:docker://docker.gitea.com/runner-images:ubuntu-24.04`
|
||||
when prompted. The config sets the label and capacity; verify them in the
|
||||
repository runner settings. The token is saved only in the mode-0700 runner
|
||||
state directory, outside Git. Start/enable the runner after registration:
|
||||
runtime directory, outside Git. Start/enable the runner after registration:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now akyldash-act-runner.service
|
||||
@@ -114,16 +139,23 @@ 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/.config/act-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 +165,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.
|
||||
@@ -86,8 +86,15 @@ 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. The reviewed installer locks the runner home to root ownership
|
||||
(`0711`) so CI cannot replace the image or mountpoint, while writable config
|
||||
and runtime state remain in runner-owned subdirectories. It reuses legacy
|
||||
`0755` config directories, creates the image through a pinned file descriptor,
|
||||
rejects path replacement, and requires mount options `nosuid,nodev`.
|
||||
- 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
log:
|
||||
level: info
|
||||
runner:
|
||||
file: /var/lib/akyldash-runner/.runner
|
||||
file: /var/lib/akyldash-runner/.config/act-runner/.runner
|
||||
capacity: 1
|
||||
labels:
|
||||
- "akyldash-ci:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/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 ]] || { runner_die "existing $user home is missing or has unexpected type"; return 1; }
|
||||
case $(stat -c %U "$home") in
|
||||
"$user") ;;
|
||||
root) [[ $(stat -c '%G:%a' "$home") == root:711 ]] || { runner_die "root-owned $user home must be mode 0711"; return 1; } ;;
|
||||
*) runner_die "existing $user home has unexpected ownership"; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
runner_lock_home() {
|
||||
local home=$1 parent_mode
|
||||
[[ -d $home && ! -L $home && $(stat -c %U /var/lib) == root ]] || { runner_die "runner home or its parent is unsafe: $home"; return 1; }
|
||||
parent_mode=$(stat -c %a /var/lib)
|
||||
(( (8#$parent_mode & 022) == 0 )) || { runner_die '/var/lib must not be group/world-writable'; return 1; }
|
||||
chown root:root "$home" && chmod 0711 "$home" || return 1
|
||||
[[ $(stat -c '%U:%G:%a' "$home") == root:root:711 ]] || { runner_die 'runner home could not be locked from runner writes'; return 1; }
|
||||
}
|
||||
|
||||
runner_ensure_image() {
|
||||
local image=$1 bytes=$2 parent_uid=$3 parent_mode=$4
|
||||
python3 "$(dirname "${BASH_SOURCE[0]}")/secure-runner-image.py" "$image" "$bytes" "$parent_uid" "$parent_mode" || { runner_die "runner image validation or initialization failed: $image"; 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_ensure_private_directory() {
|
||||
local directory=$1 owner=$2 group=$3 current= component index
|
||||
local -a components
|
||||
[[ $directory == /* ]] || { runner_die "runner directory path is not absolute: $directory"; return 1; }
|
||||
IFS=/ read -r -a components <<<"${directory#/}"
|
||||
for index in "${!components[@]}"; do
|
||||
component=${components[index]}
|
||||
[[ -n $component && $component != . && $component != .. ]] || { runner_die "runner directory path has an unsafe component: $directory"; return 1; }
|
||||
current+="/$component"
|
||||
[[ ! -L $current ]] || { runner_die "runner directory path contains a symbolic link: $current"; return 1; }
|
||||
if [[ ! -e $current ]]; then
|
||||
((index == ${#components[@]} - 1)) || { runner_die "runner directory parent is missing: $current"; return 1; }
|
||||
break
|
||||
fi
|
||||
[[ -d $current ]] || { runner_die "runner directory path component is not a directory: $current"; return 1; }
|
||||
if [[ $current == /var/lib/akyldash-runner ]]; then
|
||||
[[ $(stat -c '%U:%G:%a' "$current") == root:root:711 ]] || { runner_die 'runner home must be root-owned mode 0711'; return 1; }
|
||||
elif [[ $current == /var/lib/akyldash-runner/* ]]; then
|
||||
[[ $(stat -c '%U:%G' "$current") == "$owner:$group" ]] || { runner_die "runner directory ancestor has unexpected ownership: $current"; return 1; }
|
||||
fi
|
||||
done
|
||||
# These paths are runner-writable; make changes as that user so a path swap
|
||||
# cannot turn a root operation into a write outside its home.
|
||||
if [[ $directory == /var/lib/akyldash-runner/.config ]]; then
|
||||
install -d -o "$owner" -g "$group" -m 0700 "$directory" || return 1
|
||||
else
|
||||
runuser -u "$owner" -- install -d -m 0700 "$directory" || return 1
|
||||
fi
|
||||
[[ -d $directory && ! -L $directory && $(stat -c '%U:%G:%a' "$directory") == "$owner:$group:700" ]] || { runner_die "runner directory failed validation: $directory"; return 1; }
|
||||
}
|
||||
|
||||
runner_install_user_content() {
|
||||
local destination=$1 owner=$2 group=$3 mode=${4#0} temporary
|
||||
temporary=$(mktemp)
|
||||
cat >"$temporary"
|
||||
if [[ -e $destination || -L $destination ]]; then
|
||||
[[ -f $destination && ! -L $destination ]] && cmp -s "$temporary" "$destination" || { rm -f "$temporary"; runner_die "existing runner file differs from reviewed configuration: $destination"; return 1; }
|
||||
[[ $(stat -c '%U:%G:%a' "$destination") == "$owner:$group:$mode" ]] || { rm -f "$temporary"; runner_die "existing runner file has unexpected ownership or permissions: $destination"; return 1; }
|
||||
rm -f "$temporary"
|
||||
return
|
||||
fi
|
||||
chown "$owner:$group" "$temporary" && chmod "0$mode" "$temporary" || { rm -f "$temporary"; return 1; }
|
||||
local result=0
|
||||
runuser -u "$owner" -- install -m "0$mode" "$temporary" "$destination" || result=$?
|
||||
rm -f "$temporary"
|
||||
((result == 0)) || return 1
|
||||
[[ -f $destination && ! -L $destination && $(stat -c '%U:%G:%a' "$destination") == "$owner:$group:$mode" ]] || { runner_die "runner file failed validation: $destination"; return 1; }
|
||||
}
|
||||
|
||||
runner_validate_mount() {
|
||||
local image=$1 store=$2 target filesystem source options backing mount_info
|
||||
mount_info=$(findmnt --noheadings --output TARGET,FSTYPE,SOURCE,OPTIONS --mountpoint "$store") || { runner_die 'runner store is not mounted'; return 1; }
|
||||
read -r target filesystem source options <<<"$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; }
|
||||
case ",$options," in *,nosuid,*nodev,*|*,nodev,*nosuid,*) ;; *) runner_die 'runner store mount is missing nosuid or nodev'; return 1 ;; esac
|
||||
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" <<EOF
|
||||
[Unit]
|
||||
Description=Bounded storage for the AkylDash CI runner
|
||||
Before=local-fs.target
|
||||
|
||||
[Mount]
|
||||
What=$image
|
||||
Where=$store
|
||||
Type=ext4
|
||||
Options=loop,nosuid,nodev
|
||||
|
||||
[Install]
|
||||
WantedBy=local-fs.target
|
||||
EOF
|
||||
then
|
||||
rm -f "$unit_tmp"
|
||||
runner_die 'failed to prepare runner mount unit'
|
||||
return 1
|
||||
fi
|
||||
if [[ -e $unit_file || -L $unit_file ]]; then
|
||||
if [[ ! -f $unit_file || -L $unit_file ]] || ! cmp -s "$unit_tmp" "$unit_file"; then
|
||||
rm -f "$unit_tmp"
|
||||
runner_die "existing runner mount unit differs from the reviewed configuration: $unit_file"
|
||||
return 1
|
||||
fi
|
||||
[[ $(stat -c '%u:%g:%a' "$unit_file") == "$(id -u):$(id -g):644" ]] || { rm -f "$unit_tmp"; runner_die 'existing runner mount unit has unexpected ownership or permissions'; return 1; }
|
||||
else
|
||||
install -m 0644 "$unit_tmp" "$unit_file" || { rm -f "$unit_tmp"; return 1; }
|
||||
fi
|
||||
rm -f "$unit_tmp"
|
||||
|
||||
systemctl daemon-reload || return 1
|
||||
if ((mounted)); then
|
||||
systemctl enable "$unit_name" || return 1
|
||||
else
|
||||
systemctl enable "$unit_name" || return 1
|
||||
systemctl start "$unit_name" || return 1
|
||||
runner_validate_mount "$image" "$store" || return 1
|
||||
fi
|
||||
}
|
||||
+102
-56
@@ -3,9 +3,12 @@ set -Eeuo pipefail
|
||||
|
||||
readonly runner_user=akyldash-runner
|
||||
readonly runner_home=/var/lib/akyldash-runner
|
||||
readonly runner_runtime_home="$runner_home/.config/act-runner"
|
||||
readonly runner_store="$runner_home/docker-data"
|
||||
readonly runner_image="$runner_home/docker-store.img"
|
||||
readonly runner_mount_unit="/etc/systemd/system/$(systemd-escape --path --suffix=mount /var/lib/akyldash-runner/docker-data)"
|
||||
readonly runner_mount_unit_name="$(systemd-escape --path --suffix=mount /var/lib/akyldash-runner/docker-data)"
|
||||
readonly runner_mount_unit="/etc/systemd/system/$runner_mount_unit_name"
|
||||
readonly runner_image_bytes=16106127360
|
||||
readonly runner_version=0.2.13
|
||||
readonly runner_sha256=3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2
|
||||
readonly runner_url="https://dl.gitea.com/gitea-runner/$runner_version/act_runner-$runner_version-linux-amd64"
|
||||
@@ -28,70 +31,108 @@ done
|
||||
unsafe=$(find "$checkout" -xdev \( ! -user root -o -perm /022 \) -print -quit)
|
||||
[[ -z $unsafe ]] || die 'the repository checkout contains files writable by non-root users'
|
||||
git -C "$checkout" diff --quiet HEAD -- infra/runner || die 'runner files differ from the reviewed Git revision'
|
||||
source "$checkout/infra/runner/install-runner-lib.sh"
|
||||
if systemctl is-active --quiet akyldash-act-runner.service; then
|
||||
die 'stop akyldash-act-runner.service after all jobs finish before rerunning this installer'
|
||||
fi
|
||||
[[ -r /etc/subuid && -r /etc/subgid ]] || die 'subordinate ID allocation files are unavailable'
|
||||
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'
|
||||
if getent passwd "$runner_user" >/dev/null; then
|
||||
runner_ensure_account "$runner_user" "$runner_home" || die 'runner account validation failed'
|
||||
runner_lock_home "$runner_home" || die 'runner home protection 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" "$runner_image_bytes" 0 711 || 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'
|
||||
runner_lock_home "$runner_home" || die 'runner home protection 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"
|
||||
runner_ensure_private_directory "$runner_home/.config" "$runner_user" "$runner_user" || die 'runner config directory failed validation'
|
||||
runner_ensure_private_directory "$runner_home/.config/docker" "$runner_user" "$runner_user" || die 'runner Docker config directory failed validation'
|
||||
runner_ensure_private_directory "$runner_home/.config/systemd" "$runner_user" "$runner_user" || die 'runner systemd config directory failed validation'
|
||||
runner_ensure_private_directory "$runner_home/.config/systemd/user" "$runner_user" "$runner_user" || die 'runner user systemd directory failed validation'
|
||||
runner_ensure_private_directory "$runner_home/.config/systemd/user/docker.service.d" "$runner_user" "$runner_user" || die 'runner Docker override directory failed validation'
|
||||
runner_ensure_private_directory "$runner_runtime_home" "$runner_user" "$runner_user" || die 'runner runtime home failed directory validation'
|
||||
if [[ -e $runner_home/.runner || -L $runner_home/.runner ]]; then
|
||||
[[ -f $runner_home/.runner && ! -L $runner_home/.runner ]] || die 'legacy runner registration state is not a regular file'
|
||||
if [[ ! -e $runner_runtime_home/.runner && ! -L $runner_runtime_home/.runner ]]; then
|
||||
runner_install_user_content "$runner_runtime_home/.runner" "$runner_user" "$runner_user" 0600 <"$runner_home/.runner" || die 'legacy runner registration state could not be preserved'
|
||||
fi
|
||||
fi
|
||||
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" <<EOF
|
||||
[Unit]
|
||||
Description=Bounded storage for the AkylDash CI runner
|
||||
Before=local-fs.target
|
||||
|
||||
[Mount]
|
||||
What=$runner_image
|
||||
Where=$runner_store
|
||||
Type=ext4
|
||||
Options=loop,nosuid,nodev
|
||||
|
||||
[Install]
|
||||
WantedBy=local-fs.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "$(basename "$runner_mount_unit" .mount)"
|
||||
runner_ensure_image "$runner_image" "$runner_image_bytes" 0 711 || die 'runner image initialization failed'
|
||||
runner_ensure_mount "$runner_image" "$runner_store" "$runner_mount_unit_name" "$runner_mount_unit" || die 'runner mount activation failed'
|
||||
chown "$runner_user:$runner_user" "$runner_store"
|
||||
chmod 0700 "$runner_store"
|
||||
|
||||
install -o root -g root -m 0755 "$(dirname "$0")/act-runner-healthcheck.sh" /usr/local/sbin/akyldash-runner-healthcheck
|
||||
install -o root -g "$runner_user" -m 0640 "$(dirname "$0")/config.yaml" /etc/akyldash-runner/config.yaml
|
||||
tmp_binary=$(mktemp)
|
||||
trap 'rm -f "$tmp_binary"' EXIT
|
||||
curl --fail --location --silent --show-error "$runner_url" -o "$tmp_binary"
|
||||
printf '%s %s\n' "$runner_sha256" "$tmp_binary" | sha256sum --check --status || die 'official act_runner checksum mismatch'
|
||||
install -o root -g root -m 0755 "$tmp_binary" /usr/local/bin/act_runner
|
||||
runner_install_file "$(dirname "$0")/act-runner-healthcheck.sh" /usr/local/sbin/akyldash-runner-healthcheck root root 0755 || die 'healthcheck command differs from reviewed configuration'
|
||||
runner_install_file "$(dirname "$0")/config.yaml" /etc/akyldash-runner/config.yaml root "$runner_user" 0640 || die 'runner config differs from reviewed configuration'
|
||||
if [[ -e /usr/local/bin/act_runner || -L /usr/local/bin/act_runner ]]; then
|
||||
[[ -f /usr/local/bin/act_runner && ! -L /usr/local/bin/act_runner ]] || die 'existing act_runner path is not a regular file'
|
||||
printf '%s %s\n' "$runner_sha256" /usr/local/bin/act_runner | sha256sum --check --status || die 'existing act_runner binary does not match the pinned release'
|
||||
else
|
||||
tmp_binary=$(mktemp)
|
||||
trap 'rm -f "$tmp_binary"' EXIT
|
||||
curl --fail --location --silent --show-error "$runner_url" -o "$tmp_binary"
|
||||
printf '%s %s\n' "$runner_sha256" "$tmp_binary" | sha256sum --check --status || die 'official act_runner checksum mismatch'
|
||||
install -o root -g root -m 0755 "$tmp_binary" /usr/local/bin/act_runner
|
||||
rm -f "$tmp_binary"
|
||||
trap - EXIT
|
||||
fi
|
||||
|
||||
cat >"$runner_home/.config/docker/daemon.json" <<EOF
|
||||
runner_install_user_content "$runner_home/.config/docker/daemon.json" "$runner_user" "$runner_user" 0600 <<EOF
|
||||
{
|
||||
"data-root": "$runner_store",
|
||||
"log-driver": "local",
|
||||
"log-opts": {"max-size": "10m", "max-file": "2"}
|
||||
}
|
||||
EOF
|
||||
chown "$runner_user:$runner_user" "$runner_home/.config/docker/daemon.json"
|
||||
chmod 0600 "$runner_home/.config/docker/daemon.json"
|
||||
cat >"$runner_home/.config/systemd/user/docker.service.d/limits.conf" <<'EOF'
|
||||
runner_install_user_content "$runner_home/.config/systemd/user/docker.service.d/limits.conf" "$runner_user" "$runner_user" 0644 <<'EOF'
|
||||
[Service]
|
||||
Delegate=yes
|
||||
CPUQuota=200%
|
||||
@@ -100,9 +141,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 <<EOF
|
||||
runner_install_content /etc/systemd/system/akyldash-act-runner.service root root 0644 <<EOF
|
||||
[Unit]
|
||||
Description=AkylDash repository Gitea Actions runner
|
||||
After=network-online.target local-fs.target
|
||||
@@ -114,7 +153,8 @@ Type=simple
|
||||
Slice=user-$runner_uid.slice
|
||||
User=$runner_user
|
||||
Group=$runner_user
|
||||
Environment=HOME=$runner_home
|
||||
Environment=HOME=$runner_runtime_home
|
||||
WorkingDirectory=$runner_runtime_home
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/$runner_uid
|
||||
Environment=DOCKER_HOST=unix:///run/user/$runner_uid/docker.sock
|
||||
ExecStartPre=/bin/sh -c 'for i in \$(seq 1 60); do /usr/bin/docker info >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1'
|
||||
@@ -132,7 +172,7 @@ ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
RestrictSUIDSGID=yes
|
||||
ReadWritePaths=$runner_home /var/log/akyldash-runner /run/user/$runner_uid
|
||||
ReadWritePaths=$runner_runtime_home /var/log/akyldash-runner /run/user/$runner_uid
|
||||
StandardOutput=append:/var/log/akyldash-runner/act_runner.log
|
||||
StandardError=append:/var/log/akyldash-runner/act_runner.log
|
||||
|
||||
@@ -140,14 +180,14 @@ StandardError=append:/var/log/akyldash-runner/act_runner.log
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
mkdir -p "/etc/systemd/system/user-$runner_uid.slice.d"
|
||||
cat >"/etc/systemd/system/user-$runner_uid.slice.d/limits.conf" <<EOF
|
||||
install -d -o root -g root -m 0755 "/etc/systemd/system/user-$runner_uid.slice.d"
|
||||
runner_install_content "/etc/systemd/system/user-$runner_uid.slice.d/limits.conf" root root 0644 <<EOF
|
||||
[Slice]
|
||||
CPUQuota=250%
|
||||
MemoryMax=4G
|
||||
TasksMax=2048
|
||||
EOF
|
||||
cat >/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 +198,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
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create or validate the runner image without following replaceable paths."""
|
||||
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def open_directory(path: str) -> int:
|
||||
current = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
for component in path.strip("/").split("/"):
|
||||
if not component:
|
||||
continue
|
||||
next_fd = os.open(
|
||||
component,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
dir_fd=current,
|
||||
)
|
||||
os.close(current)
|
||||
current = next_fd
|
||||
return current
|
||||
except BaseException:
|
||||
os.close(current)
|
||||
raise
|
||||
|
||||
|
||||
def filesystem_type(fd: int) -> str:
|
||||
result = subprocess.run(
|
||||
["blkid", "-p", "-s", "TYPE", "-o", "value", f"/proc/self/fd/{fd}"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
pass_fds=(fd,),
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
image, bytes_text, parent_uid_text, parent_mode_text = sys.argv[1:]
|
||||
image = os.path.abspath(image)
|
||||
parent_path, name = os.path.split(image)
|
||||
if not name or name in {".", ".."}:
|
||||
raise ValueError("invalid image filename")
|
||||
|
||||
parent_fd = open_directory(parent_path)
|
||||
image_fd = -1
|
||||
try:
|
||||
parent_stat = os.fstat(parent_fd)
|
||||
if parent_stat.st_uid != int(parent_uid_text) or stat.S_IMODE(parent_stat.st_mode) != int(parent_mode_text, 8):
|
||||
raise RuntimeError("image parent has unexpected owner or permissions")
|
||||
|
||||
created = False
|
||||
try:
|
||||
image_fd = os.open(
|
||||
name,
|
||||
os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC,
|
||||
dir_fd=parent_fd,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
image_fd = os.open(
|
||||
name,
|
||||
os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
|
||||
0o600,
|
||||
dir_fd=parent_fd,
|
||||
)
|
||||
created = True
|
||||
|
||||
image_stat = os.fstat(image_fd)
|
||||
if not stat.S_ISREG(image_stat.st_mode) or image_stat.st_uid != os.geteuid() or image_stat.st_nlink != 1:
|
||||
raise RuntimeError("image must be a singly-linked regular file owned by the installer")
|
||||
if not created and image_stat.st_mode & 0o022:
|
||||
raise RuntimeError("existing image is writable by group or other users")
|
||||
|
||||
if created:
|
||||
os.posix_fallocate(image_fd, 0, int(bytes_text))
|
||||
subprocess.run(
|
||||
["mkfs.ext4", "-F", "-q", f"/proc/self/fd/{image_fd}"],
|
||||
check=True,
|
||||
pass_fds=(image_fd,),
|
||||
)
|
||||
image_stat = os.fstat(image_fd)
|
||||
|
||||
if image_stat.st_size != int(bytes_text):
|
||||
raise RuntimeError(f"image size is {image_stat.st_size} bytes, expected {bytes_text}; refusing to resize or format")
|
||||
if filesystem_type(image_fd) != "ext4":
|
||||
raise RuntimeError("image does not contain ext4; refusing to format")
|
||||
path_stat = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
||||
if not stat.S_ISREG(path_stat.st_mode) or (path_stat.st_dev, path_stat.st_ino) != (image_stat.st_dev, image_stat.st_ino):
|
||||
raise RuntimeError("image path changed during validation")
|
||||
return 0
|
||||
finally:
|
||||
if image_fd >= 0:
|
||||
os.close(image_fd)
|
||||
os.close(parent_fd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/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/mkfs.ext4" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf 'mkfs\n' >>"$RUNNER_TEST_LOG"
|
||||
if [[ -n ${RUNNER_TEST_REPLACE_IMAGE:-} ]]; then
|
||||
rm -f "$RUNNER_TEST_IMAGE"
|
||||
ln -s "$RUNNER_TEST_REPLACE_IMAGE" "$RUNNER_TEST_IMAGE"
|
||||
fi
|
||||
EOF
|
||||
cat >"$tmp/bin/blkid" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "${RUNNER_TEST_FS:-ext4}"
|
||||
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 %s\n' "$RUNNER_TEST_STORE" "${RUNNER_TEST_OPTIONS:-rw,nosuid,nodev,relatime}"
|
||||
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"
|
||||
runuser() {
|
||||
shift 2
|
||||
[[ $1 == -- ]] || return 2
|
||||
shift
|
||||
"$@"
|
||||
}
|
||||
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}"
|
||||
elif [[ $1 == -c && $2 == %G:%a && $3 == ${RUNNER_TEST_HOME:-} ]]; then
|
||||
printf '%s:%s\n' "${RUNNER_TEST_HOME_GROUP:-root}" "${RUNNER_TEST_HOME_MODE:-711}"
|
||||
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"
|
||||
RUNNER_TEST_HOME_OWNER=root runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME"
|
||||
if RUNNER_TEST_HOME_OWNER=root RUNNER_TEST_HOME_MODE=755 runner_ensure_account akyldash-runner "$RUNNER_TEST_HOME" 2>/dev/null; then
|
||||
echo 'Writable root-owned runner home was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
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 ]]
|
||||
|
||||
private_root=$tmp/private
|
||||
mkdir -m 0700 "$private_root"
|
||||
runner_owner=$(command id -un)
|
||||
runner_group=$(command id -gn)
|
||||
runner_ensure_private_directory "$private_root/config" "$runner_owner" "$runner_group"
|
||||
runner_ensure_private_directory "$private_root/config" "$runner_owner" "$runner_group"
|
||||
mkdir -m 0700 "$tmp/outside"
|
||||
ln -s "$tmp/outside" "$private_root/linked"
|
||||
if runner_ensure_private_directory "$private_root/linked/docker" "$runner_owner" "$runner_group" 2>/dev/null; then
|
||||
echo 'Runner config symlink was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ ! -e $tmp/outside/docker ]]
|
||||
|
||||
legacy_root=$tmp/legacy
|
||||
mkdir -p "$legacy_root/.config/systemd/user/docker.service.d"
|
||||
chmod 0755 "$legacy_root" "$legacy_root/.config" "$legacy_root/.config/systemd" "$legacy_root/.config/systemd/user" "$legacy_root/.config/systemd/user/docker.service.d"
|
||||
for legacy_dir in "$legacy_root" "$legacy_root/.config" "$legacy_root/.config/systemd" "$legacy_root/.config/systemd/user" "$legacy_root/.config/systemd/user/docker.service.d"; do
|
||||
runner_ensure_private_directory "$legacy_dir" "$runner_owner" "$runner_group"
|
||||
[[ $(stat -c %a "$legacy_dir") == 700 ]]
|
||||
done
|
||||
user_config=$private_root/daemon.json
|
||||
runner_install_user_content "$user_config" "$runner_owner" "$runner_group" 0600 <<'EOF'
|
||||
{"data-root":"/private/runner"}
|
||||
EOF
|
||||
runner_install_user_content "$user_config" "$runner_owner" "$runner_group" 0600 <<'EOF'
|
||||
{"data-root":"/private/runner"}
|
||||
EOF
|
||||
if runner_install_user_content "$user_config" "$runner_owner" "$runner_group" 0600 <<'EOF' 2>/dev/null
|
||||
{"data-root":"/unexpected"}
|
||||
EOF
|
||||
then
|
||||
echo 'Conflicting runner user config was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
image=$tmp/docker-store.img
|
||||
runner_ensure_image "$image" 1048576 "$(command id -u)" 700
|
||||
runner_ensure_image "$image" 1048576 "$(command id -u)" 700
|
||||
[[ $(grep -c '^mkfs$' "$log") == 1 ]]
|
||||
|
||||
truncate -s 1048575 "$image"
|
||||
if runner_ensure_image "$image" 1048576 "$(command id -u)" 700 2>/dev/null; then
|
||||
echo 'Mismatched image size was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $(grep -c '^mkfs$' "$log") == 1 ]]
|
||||
truncate -s 1048576 "$image"
|
||||
if RUNNER_TEST_FS=ext3 runner_ensure_image "$image" 1048576 "$(command id -u)" 700 2>/dev/null; then
|
||||
echo 'Existing non-ext4 image was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $(grep -c '^mkfs$' "$log") == 1 ]]
|
||||
external=$tmp/external-file
|
||||
printf 'preserve this file\n' >"$external"
|
||||
race_image=$tmp/race.img
|
||||
ln -s "$external" "$race_image"
|
||||
if runner_ensure_image "$race_image" 1048576 "$(command id -u)" 700 2>/dev/null; then
|
||||
echo 'Symlink runner image was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $(cat "$external") == 'preserve this file' ]]
|
||||
rm -f "$race_image"
|
||||
race_image=$tmp/race-during-format.img
|
||||
export RUNNER_TEST_IMAGE="$race_image" RUNNER_TEST_REPLACE_IMAGE="$external"
|
||||
if runner_ensure_image "$race_image" 1048576 "$(command id -u)" 700 2>/dev/null; then
|
||||
echo 'Image path replacement during formatting was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $(cat "$external") == 'preserve this file' ]]
|
||||
rm -f "$race_image"
|
||||
unset RUNNER_TEST_IMAGE RUNNER_TEST_REPLACE_IMAGE
|
||||
[[ $(grep -c '^mkfs$' "$log") == 2 ]]
|
||||
|
||||
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" 1048576 "$(command id -u)" 700
|
||||
runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$unit_file"
|
||||
[[ $(grep -c '^mkfs$' "$log") == 2 ]]
|
||||
[[ $(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" 1048576 "$(command id -u)" 700
|
||||
runner_ensure_mount "$image" "$RUNNER_TEST_STORE" "$unit_name" "$tmp/systemd/partial.mount"
|
||||
[[ $(grep -c '^mkfs$' "$log") == 2 ]]
|
||||
[[ $(grep -c '^start ' "$log") == 3 ]]
|
||||
export RUNNER_TEST_STORE="$tmp/store" RUNNER_TEST_MOUNTED="$tmp/mounted" RUNNER_TEST_OPTIONS=rw,relatime
|
||||
if runner_validate_mount "$image" "$RUNNER_TEST_STORE" 2>/dev/null; then
|
||||
echo 'Runner mount missing nosuid/nodev was accepted' >&2
|
||||
exit 1
|
||||
fi
|
||||
unset RUNNER_TEST_OPTIONS
|
||||
echo 'runner installer idempotency checks passed'
|
||||
@@ -17,10 +17,25 @@ 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" "$runner_image_bytes" 0 711' "$installer"
|
||||
grep -Fq 'runner_lock_home "$runner_home"' "$installer"
|
||||
grep -Fq 'die '\''stop akyldash-act-runner.service after all jobs finish before rerunning this installer' "$installer"
|
||||
grep -Fq 'ReadWritePaths=$runner_runtime_home /var/log/akyldash-runner /run/user/$runner_uid' "$installer"
|
||||
grep -Fq 'os.O_NOFOLLOW | os.O_CLOEXEC' infra/runner/secure-runner-image.py
|
||||
grep -Fq 'os.posix_fallocate(image_fd, 0, int(bytes_text))' infra/runner/secure-runner-image.py
|
||||
grep -Fq 'pass_fds=(image_fd,)' infra/runner/secure-runner-image.py
|
||||
grep -Fq 'systemctl enable "$unit_name"' infra/runner/install-runner-lib.sh
|
||||
grep -Fq 'systemctl start "$unit_name"' infra/runner/install-runner-lib.sh
|
||||
grep -Fq 'runuser -u "$owner" -- install -d -m 0700 "$directory"' infra/runner/install-runner-lib.sh
|
||||
grep -Fq 'runner_install_user_content "$runner_home/.config/docker/daemon.json"' "$installer"
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user