Files
gitea-migration/manage_runner.sh
S f15ab8c18c fix: remove stale RUNNER_DEFAULT_IMAGE_ENV fallback in manage_runner.sh
RUNNER_DEFAULT_IMAGE_ENV was never defined anywhere in the codebase.
The nested default was dead code left from a prior refactor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:01:56 -05:00

628 lines
23 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 (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 <<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 (= 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, RUNNER_BOOT
# 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" "1")
RUNNER_CPU=$(ini_get "$RUNNERS_CONF" "$target_name" "cpu" "")
RUNNER_MEMORY=$(ini_get "$RUNNERS_CONF" "$target_name" "memory" "")
# boot: controls launchd install location for native runners.
# "true" → /Library/LaunchDaemons/ (starts at boot, requires sudo)
# "false" (default) → ~/Library/LaunchAgents/ (starts at login)
RUNNER_BOOT=$(ini_get "$RUNNERS_CONF" "$target_name" "boot" "false")
# --- 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:-}"
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=<name> → 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"
# Route plist to LaunchDaemons (boot) or LaunchAgents (login) based on boot flag.
# LaunchDaemons start at boot before any user logs in — useful for headless Macs.
# LaunchAgents start when the user logs in — no elevated privileges needed.
local plist_dir
if [[ "$RUNNER_BOOT" == "true" ]]; then
plist_dir="/Library/LaunchDaemons"
else
plist_dir="$HOME/Library/LaunchAgents"
fi
local plist_path="${plist_dir}/${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.
# When boot=true, insert a <key>UserName</key> entry so the daemon runs as
# the deploying user instead of root (LaunchDaemons default to root).
# When boot=false (LaunchAgent), the block is empty — agents run as the user.
if [[ "$RUNNER_BOOT" == "true" ]]; then
RUNNER_PLIST_USERNAME_BLOCK="$(printf ' <key>UserName</key>\n <string>%s</string>\n' "$(whoami)")"
else
RUNNER_PLIST_USERNAME_BLOCK=""
fi
export RUNNER_PLIST_USERNAME_BLOCK
tmpfile=$(mktemp)
render_template "${SCRIPT_DIR}/templates/com.gitea.runner.plist.tpl" "$tmpfile" \
"\${RUNNER_NAME} \${RUNNER_DATA_PATH} \${RUNNER_PLIST_USERNAME_BLOCK}"
mkdir -p "$plist_dir"
# LaunchDaemons lives in a system directory — requires sudo to write.
if [[ "$RUNNER_BOOT" == "true" ]]; then
sudo cp "$tmpfile" "$plist_path"
else
cp "$tmpfile" "$plist_path"
fi
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.
# LaunchDaemons require sudo to load/unload; LaunchAgents do not.
if [[ "$RUNNER_BOOT" == "true" ]]; then
sudo launchctl load "$plist_path"
log_success "Native runner '${RUNNER_NAME}' loaded via launchd (boot daemon — starts at boot)"
else
launchctl load "$plist_path"
log_success "Native runner '${RUNNER_NAME}' loaded via launchd (login agent — starts at login)"
fi
}
# ---------------------------------------------------------------------------
# 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"
# The plist may live in either LaunchDaemons (boot=true) or LaunchAgents (boot=false).
# Check both directories so removal works regardless of how the runner was originally
# deployed — the runner.conf boot flag may have changed since deployment.
local plist_path=""
local needs_sudo=false
if [[ -f "/Library/LaunchDaemons/${plist_name}" ]]; then
plist_path="/Library/LaunchDaemons/${plist_name}"
needs_sudo=true
elif [[ -f "$HOME/Library/LaunchAgents/${plist_name}" ]]; then
plist_path="$HOME/Library/LaunchAgents/${plist_name}"
fi
if launchctl list 2>/dev/null | grep -q "com.gitea.runner.${RUNNER_NAME}"; then
if [[ -n "$plist_path" ]]; then
if $needs_sudo; then
sudo launchctl unload "$plist_path" 2>/dev/null || true
else
launchctl unload "$plist_path" 2>/dev/null || true
fi
log_success "Launchd service unloaded"
else
# Plist file is gone but launchctl still shows the service — force-remove by label.
launchctl remove "com.gitea.runner.${RUNNER_NAME}" 2>/dev/null || \
sudo launchctl remove "com.gitea.runner.${RUNNER_NAME}" 2>/dev/null || true
log_warn "Plist not found on disk — removed service by label"
fi
fi
if [[ -n "$plist_path" ]] && [[ -f "$plist_path" ]]; then
if $needs_sudo; then
sudo rm -f "$plist_path"
else
rm -f "$plist_path"
fi
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 <runner_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 <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