feat: add Phase 3 — Runners + manage_runner.sh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
408
manage_runner.sh
Executable file
408
manage_runner.sh
Executable file
@@ -0,0 +1,408 @@
|
||||
#!/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"
|
||||
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_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() {
|
||||
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"
|
||||
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"
|
||||
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() {
|
||||
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
|
||||
Reference in New Issue
Block a user