Files
gitea-migration/manage_runner.sh
S dc08375ad0 fix: address multiple bugs from code review
- teardown_all.sh: replace `yes |` pipeline with `< <(yes)` process
  substitution to avoid SIGPIPE (exit 141) false failures under pipefail
- phase6_teardown.sh: extract push mirror `.id` instead of `.remote_name`
  to match the DELETE /push_mirrors/{id} API contract
- phase5_migrate_pipelines.sh: expand sed regex from `[a-z_]*` to
  `[a-z_.]*` to handle nested GitHub contexts like
  `github.event.pull_request.number`
- lib/common.sh: render_template now requires explicit variable list to
  prevent envsubst from eating Nginx variables ($host, $proxy_add_...)
- backup scripts: remove MacBook relay, use direct Unraid↔Fedora SCP;
  fix dump path to write to /data/ (mounted volume) instead of /tmp/
  (container-only); add unzip -t integrity verification
- preflight.sh: add --skip-port-checks flag for resuming with
  --start-from (ports already bound by earlier phases)
- run_all.sh: update run_step to pass extra args; use --skip-port-checks
  when --start-from > 1
- post-checks (phase4/7/9): wrap API calls in helper functions with
  >/dev/null redirection instead of passing -o /dev/null as API data
- phase8: replace GitHub archiving with [MIRROR] description marking
  and disable wiki/projects/Pages (archived repos reject push mirrors)
- restore_to_primary.sh: add require_vars for Fedora SSH variables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:18:35 -05:00

419 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# manage_runner.sh — Add, remove, or list Gitea Actions runners
# Reads runner definitions from runners.conf (pipe-delimited format).
# Supports two runner types:
# docker — Linux hosts, deployed as Docker container via docker-compose
# native — macOS, deployed as binary + launchd service
# Can be used standalone or called by phase3_runners.sh / phase3_teardown.sh.
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
load_env
require_vars GITEA_INTERNAL_URL GITEA_ADMIN_TOKEN ACT_RUNNER_VERSION
RUNNERS_CONF="${SCRIPT_DIR}/runners.conf"
# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") <command> [options]
Commands:
add --name <runner_name> Deploy and register a runner
remove --name <runner_name> Stop and deregister a runner
list Show all runners with status
Options:
--name <name> Runner name as defined in runners.conf
--help Show this help
EOF
exit 1
}
# ---------------------------------------------------------------------------
# Parse a runner entry from runners.conf by name
# Sets: RUNNER_NAME, RUNNER_SSH_HOST, RUNNER_SSH_USER, RUNNER_SSH_PORT,
# RUNNER_DATA_PATH, RUNNER_LABELS, RUNNER_TYPE
# Returns 1 if not found.
# ---------------------------------------------------------------------------
parse_runner_entry() {
local target_name="$1"
if [[ ! -f "$RUNNERS_CONF" ]]; then
log_error "runners.conf not found at $RUNNERS_CONF"
return 1
fi
while IFS='|' read -r name host user port path labels type; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
# Trim whitespace from fields
name=$(echo "$name" | xargs)
if [[ "$name" == "$target_name" ]]; then
RUNNER_NAME="$name"
RUNNER_SSH_HOST=$(echo "$host" | xargs)
RUNNER_SSH_USER=$(echo "$user" | xargs)
RUNNER_SSH_PORT=$(echo "$port" | xargs)
RUNNER_DATA_PATH=$(echo "$path" | xargs)
RUNNER_LABELS=$(echo "$labels" | xargs)
RUNNER_TYPE=$(echo "$type" | xargs)
return 0
fi
done < "$RUNNERS_CONF"
log_error "Runner '$target_name' not found in runners.conf"
return 1
}
# ---------------------------------------------------------------------------
# List all runner entries from runners.conf (without looking up a name)
# Outputs lines to stdout: name|host|user|port|path|labels|type
# ---------------------------------------------------------------------------
all_runner_entries() {
if [[ ! -f "$RUNNERS_CONF" ]]; then
log_error "runners.conf not found at $RUNNERS_CONF"
return 1
fi
while IFS= read -r line; do
# Skip comments and blank lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "$line" ]] && continue
echo "$line"
done < "$RUNNERS_CONF"
}
# ---------------------------------------------------------------------------
# Execute a command on the runner's host.
# For "local" hosts (macOS), runs the command directly.
# For remote hosts, SSHs into them — uses direct ssh (not ssh_exec from
# common.sh) because runner hosts have their own SSH creds defined in
# runners.conf, not from the standard *_IP/*_SSH_USER env vars.
# ---------------------------------------------------------------------------
runner_ssh() {
local cmd="$*"
if [[ "$RUNNER_SSH_HOST" == "local" ]]; then
# macOS runner — execute directly on this machine
eval "$cmd"
else
ssh -o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \
-p "${RUNNER_SSH_PORT}" \
"${RUNNER_SSH_USER}@${RUNNER_SSH_HOST}" \
"$cmd"
fi
}
# ---------------------------------------------------------------------------
# SCP a file to the runner's host.
# For "local" hosts, just copies the file.
# ---------------------------------------------------------------------------
runner_scp() {
local src="$1" dst="$2"
if [[ "$RUNNER_SSH_HOST" == "local" ]]; then
cp "$src" "$dst"
else
scp -o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \
-P "${RUNNER_SSH_PORT}" \
"$src" "${RUNNER_SSH_USER}@${RUNNER_SSH_HOST}:${dst}"
fi
}
# ---------------------------------------------------------------------------
# add_docker_runner — Deploy a runner as a Docker container on a Linux host
# Steps:
# 1. Create data directory
# 2. Render + SCP docker-compose.yml
# 3. Render + SCP runner-config.yaml
# 4. Start container
# The act_runner Docker image auto-registers with Gitea using env vars
# (GITEA_INSTANCE_URL + GITEA_RUNNER_REGISTRATION_TOKEN) on first boot.
# ---------------------------------------------------------------------------
add_docker_runner() {
log_info "Deploying Docker runner '${RUNNER_NAME}' on ${RUNNER_SSH_HOST}..."
# Check if container is already running
local status
status=$(runner_ssh "docker ps --filter name=gitea-runner-${RUNNER_NAME} --format '{{.Status}}'" 2>/dev/null || true)
if [[ "$status" == *"Up"* ]]; then
log_info "Runner '${RUNNER_NAME}' already running — skipping"
return 0
fi
# Create data directory on remote host
runner_ssh "mkdir -p '${RUNNER_DATA_PATH}'"
# Render docker-compose template with runner-specific vars
local tmpfile
tmpfile=$(mktemp)
export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH
export GITEA_RUNNER_REGISTRATION_TOKEN="${GITEA_RUNNER_REGISTRATION_TOKEN:-}"
render_template "${SCRIPT_DIR}/templates/docker-compose-runner.yml.tpl" "$tmpfile" \
'${ACT_RUNNER_VERSION} ${RUNNER_NAME} ${GITEA_INTERNAL_URL} ${GITEA_RUNNER_REGISTRATION_TOKEN} ${RUNNER_LABELS} ${RUNNER_DATA_PATH}'
runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/docker-compose.yml"
rm -f "$tmpfile"
# Render runner config
tmpfile=$(mktemp)
render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \
'${RUNNER_NAME} ${RUNNER_LABELS}'
runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml"
rm -f "$tmpfile"
# Start the container
runner_ssh "cd '${RUNNER_DATA_PATH}' && docker compose up -d 2>/dev/null || docker-compose up -d"
log_success "Docker runner '${RUNNER_NAME}' started on ${RUNNER_SSH_HOST}"
}
# ---------------------------------------------------------------------------
# add_native_runner — Deploy a runner as a native binary on macOS
# Steps:
# 1. Download act_runner binary for macOS (arm64 or amd64)
# 2. Register with Gitea (--no-interactive)
# 3. Render launchd plist
# 4. Load plist via launchctl
# Native runners are used on macOS because Docker Desktop is heavyweight
# and unreliable for long-running background services.
# ---------------------------------------------------------------------------
add_native_runner() {
# Native runners use launchctl + macOS-specific paths — must be macOS
require_local_os "Darwin" "Native runner deployment requires macOS (uses launchctl)"
log_info "Deploying native runner '${RUNNER_NAME}' on local machine..."
# Resolve ~ to actual home directory for local execution
RUNNER_DATA_PATH="${RUNNER_DATA_PATH/#\~/$HOME}"
export RUNNER_DATA_PATH
local plist_name="com.gitea.runner.${RUNNER_NAME}.plist"
local plist_path="$HOME/Library/LaunchAgents/${plist_name}"
# Check if launchd service is already loaded
if launchctl list 2>/dev/null | grep -q "com.gitea.runner.${RUNNER_NAME}"; then
log_info "Runner '${RUNNER_NAME}' already loaded in launchd — skipping"
return 0
fi
# Create data directory
mkdir -p "${RUNNER_DATA_PATH}"
# Download act_runner binary if not present
if [[ ! -x "${RUNNER_DATA_PATH}/act_runner" ]]; then
# Detect architecture — Apple Silicon (arm64) vs Intel (x86_64)
local arch
arch=$(uname -m)
case "$arch" in
arm64) arch="arm64" ;;
x86_64) arch="amd64" ;;
*) log_error "Unsupported architecture: $arch"; return 1 ;;
esac
local download_url="https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-darwin-${arch}"
log_info "Downloading act_runner v${ACT_RUNNER_VERSION} for darwin-${arch}..."
curl -sfL -o "${RUNNER_DATA_PATH}/act_runner" "$download_url"
chmod +x "${RUNNER_DATA_PATH}/act_runner"
log_success "act_runner binary downloaded"
fi
# Register the runner with Gitea (generates .runner file in data dir)
# --no-interactive skips prompts, --config generates default config if missing
if [[ ! -f "${RUNNER_DATA_PATH}/.runner" ]]; then
log_info "Registering runner with Gitea..."
"${RUNNER_DATA_PATH}/act_runner" register \
--no-interactive \
--instance "${GITEA_INTERNAL_URL}" \
--token "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" \
--name "${RUNNER_NAME}" \
--labels "${RUNNER_LABELS}" \
--config "${RUNNER_DATA_PATH}/config.yaml"
log_success "Runner registered"
fi
# Render runner config (overwrites any default generated by register)
local tmpfile
tmpfile=$(mktemp)
export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH
render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \
'${RUNNER_NAME} ${RUNNER_LABELS}'
cp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml"
rm -f "$tmpfile"
# Render launchd plist
tmpfile=$(mktemp)
render_template "${SCRIPT_DIR}/templates/com.gitea.runner.plist.tpl" "$tmpfile" \
'${RUNNER_NAME} ${RUNNER_DATA_PATH}'
mkdir -p "$HOME/Library/LaunchAgents"
cp "$tmpfile" "$plist_path"
rm -f "$tmpfile"
# Load the launchd service
launchctl load "$plist_path"
log_success "Native runner '${RUNNER_NAME}' loaded via launchd"
}
# ---------------------------------------------------------------------------
# remove_docker_runner — Stop + remove Docker runner container
# ---------------------------------------------------------------------------
remove_docker_runner() {
log_info "Removing Docker runner '${RUNNER_NAME}' from ${RUNNER_SSH_HOST}..."
# Check if docker-compose file exists
if runner_ssh "test -f '${RUNNER_DATA_PATH}/docker-compose.yml'" 2>/dev/null; then
runner_ssh "cd '${RUNNER_DATA_PATH}' && docker compose down 2>/dev/null || docker-compose down" || true
log_success "Docker runner '${RUNNER_NAME}' stopped"
else
log_info "No docker-compose.yml found for runner '${RUNNER_NAME}' — already removed"
fi
}
# ---------------------------------------------------------------------------
# remove_native_runner — Unload launchd service + remove binary + plist
# ---------------------------------------------------------------------------
remove_native_runner() {
# Native runners use launchctl + macOS-specific paths — must be macOS
require_local_os "Darwin" "Native runner removal requires macOS (uses launchctl)"
log_info "Removing native runner '${RUNNER_NAME}' from local machine..."
# Resolve ~ to actual home directory
RUNNER_DATA_PATH="${RUNNER_DATA_PATH/#\~/$HOME}"
local plist_name="com.gitea.runner.${RUNNER_NAME}.plist"
local plist_path="$HOME/Library/LaunchAgents/${plist_name}"
# Unload launchd service if loaded
if launchctl list 2>/dev/null | grep -q "com.gitea.runner.${RUNNER_NAME}"; then
launchctl unload "$plist_path" 2>/dev/null || true
log_success "Launchd service unloaded"
fi
# Remove plist file
if [[ -f "$plist_path" ]]; then
rm -f "$plist_path"
log_success "Plist removed: $plist_path"
fi
# Remove runner data directory (binary, config, registration)
if [[ -d "${RUNNER_DATA_PATH}" ]]; then
printf 'Remove runner data at %s? [y/N] ' "${RUNNER_DATA_PATH}"
read -r confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
rm -rf "${RUNNER_DATA_PATH}"
log_success "Runner data removed"
else
log_info "Runner data preserved"
fi
fi
}
# ---------------------------------------------------------------------------
# list_runners — Print table of all runners with their Gitea status
# Queries the Gitea admin API for registered runners and cross-references
# with runners.conf to show which are online/offline/unregistered.
# ---------------------------------------------------------------------------
list_runners() {
log_info "Listing runners..."
# Fetch registered runners from Gitea admin API
local api_runners
api_runners=$(gitea_api GET "/admin/runners" 2>/dev/null || echo "[]")
# Print header
printf '%-20s %-16s %-10s %-8s %-10s\n' "NAME" "HOST" "LABELS" "TYPE" "STATUS"
printf '%-20s %-16s %-10s %-8s %-10s\n' "----" "----" "------" "----" "------"
# For each entry in runners.conf, look up status in API response
while IFS='|' read -r name host user port path labels type; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
name=$(echo "$name" | xargs)
host=$(echo "$host" | xargs)
labels=$(echo "$labels" | xargs)
type=$(echo "$type" | xargs)
# Search for this runner in the API response by name
local status
status=$(printf '%s' "$api_runners" | jq -r --arg n "$name" '.[] | select(.name == $n) | .status' 2>/dev/null || true)
if [[ -z "$status" ]]; then
status="not-found"
fi
printf '%-20s %-16s %-10s %-8s %-10s\n' "$name" "$host" "$labels" "$type" "$status"
done < "$RUNNERS_CONF"
}
# ---------------------------------------------------------------------------
# Main — parse command and dispatch
# ---------------------------------------------------------------------------
COMMAND=""
RUNNER_ARG_NAME=""
while [[ $# -gt 0 ]]; do
case "$1" in
add|remove|list) COMMAND="$1"; shift ;;
--name)
if [[ $# -lt 2 ]]; then log_error "--name requires a value"; usage; fi
RUNNER_ARG_NAME="$2"; shift 2 ;;
--help|-h) usage ;;
*) log_error "Unknown argument: $1"; usage ;;
esac
done
if [[ -z "$COMMAND" ]]; then
log_error "No command specified"
usage
fi
case "$COMMAND" in
add)
if [[ -z "$RUNNER_ARG_NAME" ]]; then
log_error "add requires --name <runner_name>"
usage
fi
require_vars GITEA_RUNNER_REGISTRATION_TOKEN
parse_runner_entry "$RUNNER_ARG_NAME"
case "$RUNNER_TYPE" in
docker) add_docker_runner ;;
native) add_native_runner ;;
*) log_error "Unknown runner type: $RUNNER_TYPE (must be 'docker' or 'native')"; exit 1 ;;
esac
;;
remove)
if [[ -z "$RUNNER_ARG_NAME" ]]; then
log_error "remove requires --name <runner_name>"
usage
fi
parse_runner_entry "$RUNNER_ARG_NAME"
case "$RUNNER_TYPE" in
docker) remove_docker_runner ;;
native) remove_native_runner ;;
*) log_error "Unknown runner type: $RUNNER_TYPE"; exit 1 ;;
esac
;;
list)
list_runners
;;
*)
log_error "Unknown command: $COMMAND"
usage
;;
esac