#!/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 < [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 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 " 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 " 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