#!/usr/bin/env bash set -euo pipefail # ============================================================================= # manage_runner.sh — Add, remove, or list Gitea Actions runners # Reads runner definitions from runners.conf (INI 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 < [options] Commands: add --name Deploy and register a runner remove --name Stop and deregister a runner list Show all runners with status Options: --name Runner name (= section name in runners.conf) --help Show this help EOF exit 1 } # --------------------------------------------------------------------------- # Parse a runner entry from runners.conf (INI format) by section name. # Sets globals: RUNNER_NAME, RUNNER_HOST, RUNNER_TYPE, RUNNER_DATA_PATH, # RUNNER_LABELS, RUNNER_DEFAULT_IMAGE, RUNNER_REPOS, RUNNER_CAPACITY, # RUNNER_CPU, RUNNER_MEMORY # Also resolves: RUNNER_SSH_HOST, RUNNER_SSH_USER, RUNNER_SSH_PORT, # RUNNER_SSH_KEY (from .env or custom section keys) # 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 # Check section exists if ! ini_list_sections "$RUNNERS_CONF" | grep -qx "$target_name"; then log_error "Runner '$target_name' not found in runners.conf" return 1 fi RUNNER_NAME="$target_name" RUNNER_HOST=$(ini_get "$RUNNERS_CONF" "$target_name" "host" "") RUNNER_TYPE=$(ini_get "$RUNNERS_CONF" "$target_name" "type" "") RUNNER_DATA_PATH=$(ini_get "$RUNNERS_CONF" "$target_name" "data_path" "") RUNNER_LABELS=$(ini_get "$RUNNERS_CONF" "$target_name" "labels" "") RUNNER_DEFAULT_IMAGE=$(ini_get "$RUNNERS_CONF" "$target_name" "default_image" "") RUNNER_REPOS=$(ini_get "$RUNNERS_CONF" "$target_name" "repos" "all") RUNNER_CAPACITY=$(ini_get "$RUNNERS_CONF" "$target_name" "capacity" "${RUNNER_DEFAULT_CAPACITY:-1}") RUNNER_CPU=$(ini_get "$RUNNERS_CONF" "$target_name" "cpu" "") RUNNER_MEMORY=$(ini_get "$RUNNERS_CONF" "$target_name" "memory" "") # --- Host resolution --- case "$RUNNER_HOST" in unraid) RUNNER_SSH_HOST="${UNRAID_IP:-}" RUNNER_SSH_USER="${UNRAID_SSH_USER:-}" RUNNER_SSH_PORT="${UNRAID_SSH_PORT:-22}" RUNNER_SSH_KEY="${UNRAID_SSH_KEY:-}" ;; fedora) RUNNER_SSH_HOST="${FEDORA_IP:-}" RUNNER_SSH_USER="${FEDORA_SSH_USER:-}" RUNNER_SSH_PORT="${FEDORA_SSH_PORT:-22}" RUNNER_SSH_KEY="${FEDORA_SSH_KEY:-}" ;; local) RUNNER_SSH_HOST="local" RUNNER_SSH_USER="" RUNNER_SSH_PORT="" RUNNER_SSH_KEY="" ;; custom) RUNNER_SSH_HOST=$(ini_get "$RUNNERS_CONF" "$target_name" "ssh_host" "") RUNNER_SSH_USER=$(ini_get "$RUNNERS_CONF" "$target_name" "ssh_user" "") RUNNER_SSH_PORT=$(ini_get "$RUNNERS_CONF" "$target_name" "ssh_port" "22") RUNNER_SSH_KEY=$(ini_get "$RUNNERS_CONF" "$target_name" "ssh_key" "") ;; *) log_error "Runner '$target_name': unknown host '$RUNNER_HOST' (must be unraid, fedora, local, or custom)" return 1 ;; esac # --- Validate required fields --- if [[ -z "$RUNNER_TYPE" ]]; then log_error "Runner '$target_name': type is empty (must be docker or native)" return 1 fi if [[ "$RUNNER_TYPE" != "docker" ]] && [[ "$RUNNER_TYPE" != "native" ]]; then log_error "Runner '$target_name': type='$RUNNER_TYPE' (must be docker or native)" return 1 fi if [[ -z "$RUNNER_DATA_PATH" ]]; then log_error "Runner '$target_name': data_path is empty" return 1 fi if [[ -z "$RUNNER_LABELS" ]]; then log_error "Runner '$target_name': labels is empty" return 1 fi if ! [[ "$RUNNER_CAPACITY" =~ ^[1-9][0-9]*$ ]]; then log_error "Runner '$target_name': capacity='$RUNNER_CAPACITY' (must be positive integer >= 1)" return 1 fi return 0 } # --------------------------------------------------------------------------- # Resolve the runner's image (with LOCAL_REGISTRY prefix if set). # Sets RUNNER_RESOLVED_IMAGE. # --------------------------------------------------------------------------- resolve_runner_image() { local image="${RUNNER_DEFAULT_IMAGE:-${RUNNER_DEFAULT_IMAGE_ENV:-}}" if [[ -z "$image" ]] && [[ "$RUNNER_TYPE" == "docker" ]]; then image="${RUNNER_DEFAULT_IMAGE:-catthehacker/ubuntu:act-latest}" fi if [[ -n "$image" ]] && [[ -n "${LOCAL_REGISTRY:-}" ]]; then RUNNER_RESOLVED_IMAGE="${LOCAL_REGISTRY}/${image}" else RUNNER_RESOLVED_IMAGE="${image}" fi } # --------------------------------------------------------------------------- # Build act_runner label specs from labels + default_image. # Docker: "linux:docker://image" | Native: "macos:host" # Sets RUNNER_LABELS_CSV and RUNNER_LABELS_YAML. # --------------------------------------------------------------------------- build_runner_labels() { resolve_runner_image local labels_csv="" local labels_yaml="" local IFS=',' # shellcheck disable=SC2206 local -a parts=($RUNNER_LABELS) local label spec for label in "${parts[@]}"; do label=$(echo "$label" | xargs) [[ -z "$label" ]] && continue if [[ "$label" == *:* ]]; then # Already a full spec (e.g. linux:docker://node:20) spec="$label" elif [[ "$RUNNER_TYPE" == "docker" ]]; then spec="${label}:docker://${RUNNER_RESOLVED_IMAGE}" else spec="${label}:host" fi if [[ -z "$labels_csv" ]]; then labels_csv="$spec" else labels_csv="${labels_csv},${spec}" fi # shellcheck disable=SC2089 # intentional — value rendered via envsubst, not shell expansion labels_yaml="${labels_yaml} - \"${spec}\" " done export RUNNER_LABELS_CSV="$labels_csv" # shellcheck disable=SC2090 # intentional — value rendered via envsubst, not shell expansion export RUNNER_LABELS_YAML="$labels_yaml" } # --------------------------------------------------------------------------- # Resolve registration token based on repos field. # repos=all → instance-level token from .env # repos= → fetch repo-level token from Gitea API # Sets RUNNER_REG_TOKEN. # --------------------------------------------------------------------------- resolve_registration_token() { if [[ "$RUNNER_REPOS" == "all" ]] || [[ -z "$RUNNER_REPOS" ]]; then RUNNER_REG_TOKEN="${GITEA_RUNNER_REGISTRATION_TOKEN:-}" else # Fetch repo-level registration token from Gitea API local owner="${GITEA_ORG_NAME:-}" if [[ -z "$owner" ]]; then log_error "GITEA_ORG_NAME is empty — cannot fetch repo-level token" return 1 fi log_info "Fetching registration token for repo '${owner}/${RUNNER_REPOS}'..." local response response=$(gitea_api GET "/repos/${owner}/${RUNNER_REPOS}/actions/runners/registration-token" 2>/dev/null) || { log_error "Failed to fetch registration token for repo '${RUNNER_REPOS}'" return 1 } RUNNER_REG_TOKEN=$(printf '%s' "$response" | jq -r '.token // empty' 2>/dev/null) if [[ -z "$RUNNER_REG_TOKEN" ]]; then log_error "Empty registration token returned for repo '${RUNNER_REPOS}'" return 1 fi fi export RUNNER_REG_TOKEN } # --------------------------------------------------------------------------- # Build Docker deploy.resources block (only when cpu/memory are set). # Sets RUNNER_DEPLOY_RESOURCES. # --------------------------------------------------------------------------- build_deploy_resources() { if [[ -n "$RUNNER_CPU" ]] || [[ -n "$RUNNER_MEMORY" ]]; then local block=" deploy:\n resources:\n limits:" if [[ -n "$RUNNER_CPU" ]]; then block="${block}\n cpus: \"${RUNNER_CPU}\"" fi if [[ -n "$RUNNER_MEMORY" ]]; then block="${block}\n memory: \"${RUNNER_MEMORY}\"" fi RUNNER_DEPLOY_RESOURCES=$(printf '%b' "$block") else RUNNER_DEPLOY_RESOURCES="" fi export RUNNER_DEPLOY_RESOURCES } # --------------------------------------------------------------------------- # Execute a command on the runner's host. # For "local" hosts (macOS), runs the command directly. # For remote hosts, SSHs into them using resolved SSH credentials. # --------------------------------------------------------------------------- runner_ssh() { local cmd="$*" if [[ "$RUNNER_SSH_HOST" == "local" ]]; then eval "$cmd" else # shellcheck disable=SC2086 ssh ${RUNNER_SSH_KEY:+-i "$RUNNER_SSH_KEY"} \ -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 # shellcheck disable=SC2086 scp ${RUNNER_SSH_KEY:+-i "$RUNNER_SSH_KEY"} \ -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 # --------------------------------------------------------------------------- add_docker_runner() { log_info "Deploying Docker runner '${RUNNER_NAME}' on ${RUNNER_HOST} (${RUNNER_SSH_HOST})..." build_runner_labels resolve_registration_token build_deploy_resources # 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 local tmpfile tmpfile=$(mktemp) export RUNNER_NAME RUNNER_DATA_PATH RUNNER_LABELS_CSV RUNNER_REG_TOKEN RUNNER_DEPLOY_RESOURCES render_template "${SCRIPT_DIR}/templates/docker-compose-runner.yml.tpl" "$tmpfile" \ "\${ACT_RUNNER_VERSION} \${RUNNER_NAME} \${GITEA_INTERNAL_URL} \${RUNNER_REG_TOKEN} \${RUNNER_LABELS_CSV} \${RUNNER_DATA_PATH} \${RUNNER_DEPLOY_RESOURCES}" runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/docker-compose.yml" rm -f "$tmpfile" # Render runner config tmpfile=$(mktemp) # shellcheck disable=SC2090 # intentional — RUNNER_LABELS_YAML rendered via envsubst export RUNNER_LABELS_YAML export RUNNER_CAPACITY render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \ "\${RUNNER_NAME} \${RUNNER_LABELS_YAML} \${RUNNER_CAPACITY}" 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_HOST} (${RUNNER_SSH_HOST})" } # --------------------------------------------------------------------------- # add_native_runner — Deploy a runner as a native binary on macOS # --------------------------------------------------------------------------- add_native_runner() { require_local_os "Darwin" "Native runner deployment requires macOS (uses launchctl)" log_info "Deploying native runner '${RUNNER_NAME}' on local machine..." build_runner_labels resolve_registration_token # 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 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 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 "${RUNNER_REG_TOKEN}" \ --name "${RUNNER_NAME}" \ --labels "${RUNNER_LABELS_CSV}" \ --config "${RUNNER_DATA_PATH}/config.yaml" log_success "Runner registered" fi # Render runner config local tmpfile tmpfile=$(mktemp) # shellcheck disable=SC2090 # intentional — RUNNER_LABELS_YAML rendered via envsubst export RUNNER_NAME RUNNER_DATA_PATH RUNNER_LABELS_YAML RUNNER_CAPACITY render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \ "\${RUNNER_NAME} \${RUNNER_LABELS_YAML} \${RUNNER_CAPACITY}" 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" # Install newsyslog config for log rotation (daily, 5 archives, 50 MB max each). # newsyslog is macOS's built-in log rotator — configs in /etc/newsyslog.d/ are # picked up automatically. Requires sudo for /etc/newsyslog.d/ write access. local newsyslog_conf="/etc/newsyslog.d/com.gitea.runner.${RUNNER_NAME}.conf" if [[ ! -f "$newsyslog_conf" ]]; then tmpfile=$(mktemp) render_template "${SCRIPT_DIR}/templates/com.gitea.runner.newsyslog.conf.tpl" "$tmpfile" \ "\${RUNNER_NAME} \${RUNNER_DATA_PATH}" sudo cp "$tmpfile" "$newsyslog_conf" rm -f "$tmpfile" log_success "Log rotation installed: $newsyslog_conf" fi # 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_HOST} (${RUNNER_SSH_HOST})..." 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() { require_local_os "Darwin" "Native runner removal requires macOS (uses launchctl)" log_info "Removing native runner '${RUNNER_NAME}' from local machine..." RUNNER_DATA_PATH="${RUNNER_DATA_PATH/#\~/$HOME}" local plist_name="com.gitea.runner.${RUNNER_NAME}.plist" local plist_path="$HOME/Library/LaunchAgents/${plist_name}" 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 if [[ -f "$plist_path" ]]; then rm -f "$plist_path" log_success "Plist removed: $plist_path" fi # Remove newsyslog rotation config local newsyslog_conf="/etc/newsyslog.d/com.gitea.runner.${RUNNER_NAME}.conf" if [[ -f "$newsyslog_conf" ]]; then sudo rm -f "$newsyslog_conf" log_success "Log rotation config removed: $newsyslog_conf" fi 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 # --------------------------------------------------------------------------- list_runners() { log_info "Listing runners..." if [[ ! -f "$RUNNERS_CONF" ]]; then log_error "runners.conf not found at $RUNNERS_CONF" return 1 fi local api_runners api_runners=$(gitea_api GET "/admin/runners" 2>/dev/null || echo "[]") printf '%-20s %-10s %-10s %-8s %-6s %-10s\n' "NAME" "HOST" "LABELS" "TYPE" "CAP" "STATUS" printf '%-20s %-10s %-10s %-8s %-6s %-10s\n' "----" "----" "------" "----" "---" "------" local name host labels runner_type capacity status while IFS= read -r name; do host=$(ini_get "$RUNNERS_CONF" "$name" "host" "") labels=$(ini_get "$RUNNERS_CONF" "$name" "labels" "") runner_type=$(ini_get "$RUNNERS_CONF" "$name" "type" "") capacity=$(ini_get "$RUNNERS_CONF" "$name" "capacity" "1") 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 %-10s %-10s %-8s %-6s %-10s\n' "$name" "$host" "$labels" "$runner_type" "$capacity" "$status" done < <(ini_list_sections "$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 " usage fi 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 " 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