~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
195 lines · 7.9 KB Raw History
#!/usr/bin/env bash
set -euo pipefail

usage() {
  cat >&2 <<'EOF'
usage: scripts/ci-runner-install.sh [--remove] [--account NAME] [--token-file FILE]
  Installs the CI runner of socialweb-computer-kcp on this host: a dedicated unprivileged system account
  (no docker group, no sudo; kvm for the VM guests), one gitlab-runner with the shell executor as a systemd
  user unit of that account, registered to this project only, tagged socialweb-computer-kcp, one job at a
  time, 90 minutes at most. The host needs /usr/bin/google-chrome and /usr/local/bin/kubectl (the web app
  sign-in and kubeconfig tests).
  --remove      stop the unit, delete the runner on the GitLab server, remove the account and its home
  --account     the runner account (default socialweb-ci)
  --token-file  a 0600 file holding a GitLab token with the create_runner scope (default: the password of
                this checkout's origin remote URL); the token is read in-process and never printed
EOF
  exit 2
}

account=socialweb-ci
token_file=""
remove=0
while [ $# -gt 0 ]; do
  case "$1" in
    --remove) remove=1 ;;
    --account) account="$2"; shift ;;
    --token-file) token_file="$2"; shift ;;
    *) usage ;;
  esac
  shift
done

die() { echo "ci-runner-install: $*" >&2; exit 1; }
say() { echo "ci-runner-install: $*" >&2; }

for tool in sudo jq curl xz unshare git useradd loginctl zstd /usr/bin/gitlab-runner /usr/bin/google-chrome /usr/local/bin/kubectl; do
  command -v "$tool" >/dev/null 2>&1 || die "$tool is required"
done
sudo -n true 2>/dev/null || die "needs sudo -n (run as an administrator of this host)"

root="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
remote="$(git -C "$root" remote get-url origin)"
case "$remote" in https://*) ;; *) die "origin must be an https URL of the GitLab server" ;; esac
hostpart="${remote#https://}"
creds=""
case "$hostpart" in *@*) creds="${hostpart%%@*}"; hostpart="${hostpart#*@}" ;; esac
server_host="${hostpart%%/*}"
project_path="${hostpart#*/}"
project_path="${project_path%.git}"
server_url="https://$server_host/"
api="https://$server_host/api/v4"
project_enc="$(printf '%s' "$project_path" | jq -sRr @uri)"

gl_token=""
if [ -n "$token_file" ]; then
  [ "$(stat -c %a "$token_file")" = 600 ] || die "$token_file must be mode 0600"
  gl_token="$(tr -d '[:space:]' <"$token_file")"
else
  case "$creds" in *:*) gl_token="${creds#*:}" ;; esac
fi
[ -n "$gl_token" ] || die "no GitLab token: pass --token-file or use an origin URL that holds one"

gl() {
  local method="$1" path="$2"
  shift 2
  printf 'header = "PRIVATE-TOKEN: %s"\n' "$gl_token" |
    curl -sS --max-time 30 --fail-with-body -K - -X "$method" "$api$path" "$@"
}

home="/var/lib/$account"
config_dir="$home/.config/gitlab-runner"
config="$config_dir/config.toml"
unit_dir="$home/.config/systemd/user"
unit=gitlab-runner-socialweb-computer-kcp.service
tag=socialweb-computer-kcp

as_account() { sudo -u "$account" -- "$@"; }
user_systemctl() { sudo systemctl --user -M "$account@" "$@"; }

if [ "$remove" = 1 ]; then
  if id "$account" >/dev/null 2>&1; then
    user_systemctl disable --now "$unit" >/dev/null 2>&1 || true
    runner_id="$(sudo sed -n 's/^ *id *= *\([0-9][0-9]*\)$/\1/p' "$config" 2>/dev/null | awk 'NR==1')"
    if [ -n "$runner_id" ]; then
      gl DELETE "/runners/$runner_id" >/dev/null
      say "deleted runner $runner_id on $server_host"
    fi
    sudo loginctl disable-linger "$account"
    sudo userdel --remove "$account" 2>/dev/null || sudo userdel "$account"
    sudo rm -rf "$home"
    say "removed account $account and $home"
  else
    say "no account $account; nothing to remove"
  fi
  exit 0
fi

if ! id "$account" >/dev/null 2>&1; then
  sudo useradd --system --user-group --create-home --home-dir "$home" --shell /usr/sbin/nologin \
    --comment "gitlab-runner for socialweb-computer-kcp" "$account"
  say "created account $account (home $home)"
fi
sudo rm -f "$home/.bash_logout"
id -nG "$account" | tr ' ' '\n' | grep -qx kvm || sudo usermod -aG kvm "$account"
for group in $(id -nG "$account"); do
  case "$group" in
    "$account"|kvm) ;;
    *) die "$account is in group $group; the runner account must be in no group but its own and kvm" ;;
  esac
done
sudo chmod 0700 "$home"
uid="$(id -u "$account")"
# Integration tests run market networks as this account: VM slots (a subuid/subgid range, as
# install bidder reserves), a short run root, and the account's user bus and runtime dir.
if ! grep -q "^$account:" /etc/subuid; then
  sudo usermod --add-subuids 19005440-20054015 --add-subgids 19005440-20054015 "$account"
fi
sudo install -d -o "$account" -g "$account" -m 0755 /var/tmp/swcci
sudo loginctl enable-linger "$account"
for _ in $(seq 1 50); do
  sudo test -S "/run/user/$uid/systemd/private" && break
  sleep 0.2
done
sudo test -S "/run/user/$uid/systemd/private" || die "the user manager of $account did not start"
manager_pid="$(systemctl show -p MainPID --value "user@$uid.service")"
sudo sed -n 's/^Groups://p' "/proc/$manager_pid/status" | tr ' \t' '\n\n' | grep -qx "$(getent group kvm | cut -d: -f3)" ||
  die "user@$uid started before $account joined kvm; no job may run then: sudo systemctl restart user@$uid.service, then run this again"

as_account mkdir -p "$config_dir" "$unit_dir" "$home/.tmp"
as_account chmod 0700 "$home/.config" "$config_dir" "$home/.tmp"

if sudo test -s "$config"; then
  say "kept the existing registration in $config"
else
  project_id="$(gl GET "/projects/$project_enc" | jq -r .id)"
  [ -n "$project_id" ] && [ "$project_id" != null ] || die "project $project_path not found on $server_host"
  resp="$(gl POST /user/runners \
    --data-urlencode runner_type=project_type \
    --data-urlencode "project_id=$project_id" \
    --data-urlencode "description=socialweb-computer-kcp gate on $(hostname -s) ($account, shell)" \
    --data-urlencode "tag_list=$tag" \
    --data-urlencode run_untagged=false \
    --data-urlencode locked=true \
    --data-urlencode maximum_timeout=5400)"
  runner_id="$(printf '%s' "$resp" | jq -r .id)"
  runner_token="$(printf '%s' "$resp" | jq -r .token)"
  case "$runner_token" in glrt-*) ;; *) die "the GitLab server did not return a runner token" ;; esac
  printf '%s\n' \
    'concurrent = 1' \
    'check_interval = 3' \
    'shutdown_timeout = 30' \
    '' \
    '[[runners]]' \
    "  name = \"socialweb-computer-kcp gate on $(hostname -s)\"" \
    "  url = \"$server_url\"" \
    "  id = $runner_id" \
    "  token = \"$runner_token\"" \
    '  executor = "shell"' \
    '  shell = "bash"' \
    "  builds_dir = \"$home/builds\"" \
    "  cache_dir = \"$home/cache\"" \
    '  output_limit = 65536' |
    as_account bash -c 'umask 077; cat >"$1"' _ "$config"
  unset runner_token resp
  say "registered runner $runner_id to $project_path (tag $tag, locked, shell executor)"
fi
sudo chmod 0600 "$config"

# No sandboxing settings in this user unit: in a user manager ProtectSystem/ProtectHome/PrivateTmp put
# the service in its own user namespace with only this uid mapped, so rootless podman (builder images,
# swc-userns-stage) cannot use the account's subuid/subgid range through newuidmap. The integration
# tests start market networks through this account's user manager, which runs outside any unit sandbox
# anyway. Isolation is the account: its own uid, home mode 0700, no sudo, no docker group.
printf '%s\n' \
  '[Unit]' \
  'Description=gitlab-runner for socialweb-computer-kcp (shell executor, one job)' \
  '' \
  '[Service]' \
  "ExecStart=/usr/bin/gitlab-runner run --config $config --working-directory $home" \
  "Environment=TMPDIR=$home/.tmp" \
  'Restart=on-failure' \
  'RestartSec=5' \
  'Nice=10' \
  'KillMode=mixed' \
  'TimeoutStopSec=40' \
  '' \
  '[Install]' \
  'WantedBy=default.target' |
  as_account bash -c 'cat >"$1"' _ "$unit_dir/$unit"

user_systemctl daemon-reload
user_systemctl enable "$unit" >/dev/null 2>&1
user_systemctl restart "$unit"
user_systemctl is-active --quiet "$unit" || die "$unit is not active; read: sudo journalctl _UID=$uid"
say "$unit is active for $account; logs: sudo journalctl _UID=$uid"