From f4a6b04d141ac1cac162b64439945a538e06c53b Mon Sep 17 00:00:00 2001 From: S Date: Sat, 28 Feb 2026 23:14:46 -0500 Subject: [PATCH] feat: rework runner config to INI format with full field support Replace pipe-delimited runners.conf with INI-style sections supporting host resolution, container images, repo-scoped tokens, resource limits, capacity, and SSH key passthrough. All defaults pulled from .env. - Add INI parsing helpers (ini_list_sections, ini_get, ini_set) to common.sh - Add SSH key support (UNRAID_SSH_KEY, FEDORA_SSH_KEY) to ssh_exec/scp_to - Add .env vars: RUNNER_DEFAULT_IMAGE, RUNNER_DEFAULT_CAPACITY, RUNNER_DEFAULT_DATA_PATH, LOCAL_RUNNER_DATA_PATH, LOCAL_REGISTRY - Rewrite manage_runner.sh with host/image/token resolution and resource limits - Rewrite configure_runners.sh wizard for INI format with all 9 fields - Update phase3 scripts to use ini_list_sections instead of pipe parsing - Add runners.conf INI validation to preflight.sh (check 5b) - Update templates to use resolved labels, capacity, and deploy resources Co-Authored-By: Claude Opus 4.6 --- .env.example | 9 +- lib/common.sh | 206 +++++++++++- manage_runner.sh | 341 +++++++++++++------ phase3_post_check.sh | 10 +- phase3_runners.sh | 15 +- phase3_teardown.sh | 8 +- preflight.sh | 191 ++++++++++- runners.conf.example | 90 ++++- setup/configure_env.sh | 21 +- setup/configure_runners.sh | 429 +++++++++++++++--------- templates/docker-compose-runner.yml.tpl | 5 +- templates/runner-config.yaml.tpl | 4 +- 12 files changed, 1000 insertions(+), 329 deletions(-) diff --git a/.env.example b/.env.example index 0b3b754..3a53e45 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ UNRAID_SSH_PORT=22 # SSH port (default 22) UNRAID_GITEA_PORT=3000 # Port Gitea web UI will listen on UNRAID_GITEA_SSH_PORT=2222 # Port for git-over-SSH (host 22 is taken by SSH server) UNRAID_GITEA_DATA_PATH= # Absolute path on NVMe for Gitea data (e.g. /mnt/nvme/gitea) +UNRAID_SSH_KEY= # Path to SSH private key (optional, uses ssh-agent default if empty) # ----------------------------------------------------------------------------- @@ -25,6 +26,7 @@ FEDORA_SSH_PORT=22 # SSH port (default 22) FEDORA_GITEA_PORT=3000 # Port Gitea web UI will listen on FEDORA_GITEA_SSH_PORT=2222 # Port for git-over-SSH (host 22 is taken by SSH server) FEDORA_GITEA_DATA_PATH= # Absolute path on NVMe for Gitea data (e.g. /mnt/nvme/gitea) +FEDORA_SSH_KEY= # Path to SSH private key (optional, uses ssh-agent default if empty) # ----------------------------------------------------------------------------- @@ -62,9 +64,14 @@ GITEA_BACKUP_ADMIN_TOKEN= # API token for backup instance — do not fil # ----------------------------------------------------------------------------- # RUNNERS -# Runner definitions live in runners.conf (see runners.conf.example) +# Runner definitions live in runners.conf (INI format, see runners.conf.example) # Use manage_runner.sh to add/remove runners at any time # ----------------------------------------------------------------------------- +RUNNER_DEFAULT_IMAGE=catthehacker/ubuntu:act-latest # Default container image for docker runners +RUNNER_DEFAULT_CAPACITY=1 # Default max concurrent jobs per runner (positive integer) +RUNNER_DEFAULT_DATA_PATH=/mnt/nvme/gitea-runner # Default data path for remote (docker) runners +LOCAL_RUNNER_DATA_PATH=~/gitea-runner # Data path for native macOS runner +LOCAL_REGISTRY= # Local registry prefix (e.g. registry.local:5000), empty = Docker Hub # AUTO-POPULATED by phase1 scripts — do not fill manually: GITEA_RUNNER_REGISTRATION_TOKEN= # Retrieved from Gitea admin panel via API diff --git a/lib/common.sh b/lib/common.sh index a48eb99..793fbf0 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -168,6 +168,19 @@ validate_ssl_mode() { [[ "$1" == "letsencrypt" ]] || [[ "$1" == "existing" ]] } +validate_positive_integer() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +validate_optional() { + return 0 # always valid — value can be empty or anything +} + +validate_optional_path() { + # shellcheck disable=SC2088 # tilde intentionally stored as literal string + [[ -z "$1" ]] || [[ "$1" == /* ]] || [[ "$1" == "~/"* ]] +} + # --------------------------------------------------------------------------- # .env format validation — validate_env() # Checks every .env variable against its expected type. @@ -187,6 +200,7 @@ _ENV_VAR_NAMES=( GITEA_DOMAIN GITEA_INTERNAL_URL GITEA_BACKUP_INTERNAL_URL GITEA_BACKUP_MIRROR_INTERVAL BACKUP_STORAGE_PATH BACKUP_RETENTION_COUNT + RUNNER_DEFAULT_IMAGE RUNNER_DEFAULT_CAPACITY RUNNER_DEFAULT_DATA_PATH LOCAL_RUNNER_DATA_PATH GITHUB_USERNAME GITHUB_TOKEN REPO_1_NAME REPO_2_NAME REPO_3_NAME MIGRATE_ISSUES MIGRATE_LABELS MIGRATE_MILESTONES MIGRATE_WIKI @@ -206,6 +220,7 @@ _ENV_VAR_TYPES=( nonempty url url nonempty path integer + nonempty positive_integer nonempty nonempty nonempty nonempty nonempty nonempty nonempty bool bool bool bool @@ -220,20 +235,27 @@ _ENV_CONDITIONAL_NAMES=(SSL_EMAIL SSL_CERT_PATH SSL_KEY_PATH) _ENV_CONDITIONAL_TYPES=(email path path) _ENV_CONDITIONAL_WHEN=( letsencrypt existing existing) +# Optional variables — validated only when non-empty (never required). +_ENV_OPTIONAL_NAMES=(UNRAID_SSH_KEY FEDORA_SSH_KEY LOCAL_REGISTRY) +_ENV_OPTIONAL_TYPES=(optional_path optional_path nonempty) + # Human-readable format hints for error messages. _validator_hint() { case "$1" in - ip) echo "expected: x.x.x.x" ;; - port) echo "expected: 1-65535" ;; - email) echo "must contain @" ;; - path) echo "must start with /" ;; - url) echo "must start with http:// or https://" ;; - bool) echo "must be true or false" ;; - integer) echo "must be a number" ;; - nonempty) echo "cannot be empty" ;; - password) echo "must be at least 8 characters" ;; - ssl_mode) echo "must be letsencrypt or existing" ;; - *) echo "invalid" ;; + ip) echo "expected: x.x.x.x" ;; + port) echo "expected: 1-65535" ;; + email) echo "must contain @" ;; + path) echo "must start with /" ;; + optional_path) echo "must start with / or ~/ (or be empty)" ;; + url) echo "must start with http:// or https://" ;; + bool) echo "must be true or false" ;; + integer) echo "must be a number" ;; + positive_integer) echo "must be a positive integer (>= 1)" ;; + nonempty) echo "cannot be empty" ;; + password) echo "must be at least 8 characters" ;; + ssl_mode) echo "must be letsencrypt or existing" ;; + optional) echo "any value or empty" ;; + *) echo "invalid" ;; esac } @@ -277,6 +299,21 @@ validate_env() { fi done + # Validate optional variables (only when non-empty) + for ((i = 0; i < ${#_ENV_OPTIONAL_NAMES[@]}; i++)); do + var_name="${_ENV_OPTIONAL_NAMES[$i]}" + var_type="${_ENV_OPTIONAL_TYPES[$i]}" + value="${!var_name:-}" + + # Skip if empty — these are optional + [[ -z "$value" ]] && continue + + if ! "validate_${var_type}" "$value"; then + log_error " → $var_name='$value' ($(_validator_hint "$var_type"))" + errors=$((errors + 1)) + fi + done + if [[ $errors -gt 0 ]]; then log_error "$errors .env variable(s) failed format validation" return 1 @@ -349,10 +386,12 @@ ssh_exec() { local ip_var="${host_key}_IP" local user_var="${host_key}_SSH_USER" local port_var="${host_key}_SSH_PORT" + local key_var="${host_key}_SSH_KEY" local ip="${!ip_var:-}" local user="${!user_var:-}" local port="${!port_var:-22}" + local key="${!key_var:-}" if [[ -z "$ip" ]] || [[ -z "$user" ]]; then log_error "SSH config incomplete for $host_key: ${ip_var}=${ip:-}, ${user_var}=${user:-}" @@ -362,7 +401,9 @@ ssh_exec() { # ConnectTimeout: fail fast if host is unreachable (don't hang for 60s) # StrictHostKeyChecking=accept-new: auto-accept new hosts but reject changed keys # BatchMode=yes: never prompt for password (fail if key auth doesn't work) - ssh -o ConnectTimeout=10 \ + # ${key:+-i "$key"}: pass -i only when SSH_KEY is set (otherwise use ssh-agent default) + ssh ${key:+-i "$key"} \ + -o ConnectTimeout=10 \ -o StrictHostKeyChecking=accept-new \ -o BatchMode=yes \ -p "$port" \ @@ -381,17 +422,20 @@ scp_to() { local ip_var="${host_key}_IP" local user_var="${host_key}_SSH_USER" local port_var="${host_key}_SSH_PORT" + local key_var="${host_key}_SSH_KEY" local ip="${!ip_var:-}" local user="${!user_var:-}" local port="${!port_var:-22}" + local key="${!key_var:-}" if [[ -z "$ip" ]] || [[ -z "$user" ]]; then log_error "SCP config incomplete for $host_key" return 1 fi - scp -o ConnectTimeout=10 \ + scp ${key:+-i "$key"} \ + -o ConnectTimeout=10 \ -o StrictHostKeyChecking=accept-new \ -o BatchMode=yes \ -P "$port" \ @@ -612,6 +656,142 @@ check_remote_min_version() { fi } +# --------------------------------------------------------------------------- +# INI file parsing — used by runners.conf (INI-style sections) +# --------------------------------------------------------------------------- + +# List all [section] names from an INI file. +# Usage: ini_list_sections "runners.conf" +# Outputs one section name per line (without brackets). +ini_list_sections() { + local file="$1" + if [[ ! -f "$file" ]]; then + return 1 + fi + sed -n 's/^[[:space:]]*\[\([^]]*\)\].*/\1/p' "$file" +} + +# Get a value from an INI file by section and key. +# Usage: ini_get "runners.conf" "unraid-runner" "host" "default_value" +# Returns the value (stripped of inline comments and whitespace), or +# the default if key not found. Exit code 1 if section not found. +ini_get() { + local file="$1" section="$2" key="$3" default="${4:-}" + + if [[ ! -f "$file" ]]; then + printf '%s' "$default" + return 1 + fi + + local in_section=false + local line k v + while IFS= read -r line; do + # Strip leading/trailing whitespace + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + + # Skip empty lines and comments + [[ -z "$line" ]] && continue + [[ "$line" == \#* ]] && continue + + # Check for section header + if [[ "$line" =~ ^\[([^]]+)\] ]]; then + if [[ "${BASH_REMATCH[1]}" == "$section" ]]; then + in_section=true + elif $in_section; then + # Moved past our section without finding the key + break + fi + continue + fi + + # Parse key = value lines within our section + if $in_section && [[ "$line" =~ ^([^=]+)=(.*) ]]; then + k="${BASH_REMATCH[1]}" + v="${BASH_REMATCH[2]}" + # Trim whitespace from key and value + k="${k#"${k%%[![:space:]]*}"}" + k="${k%"${k##*[![:space:]]}"}" + v="${v#"${v%%[![:space:]]*}"}" + v="${v%"${v##*[![:space:]]}"}" + # Strip inline comment (# preceded by whitespace) + v="${v%%[[:space:]]#*}" + # Trim trailing whitespace again after comment strip + v="${v%"${v##*[![:space:]]}"}" + + if [[ "$k" == "$key" ]]; then + printf '%s' "$v" + return 0 + fi + fi + done < "$file" + + printf '%s' "$default" + if $in_section; then + return 0 # section found, key missing — return default + fi + return 1 # section not found +} + +# Set a value in an INI file (create section/key if missing). +# Usage: ini_set "runners.conf" "unraid-runner" "host" "unraid" +ini_set() { + local file="$1" section="$2" key="$3" value="$4" + + if [[ ! -f "$file" ]]; then + # Create file with section and key + printf '[%s]\n%s = %s\n' "$section" "$key" "$value" > "$file" + return 0 + fi + + local tmpfile + tmpfile=$(mktemp) + local in_section=false + local key_written=false + local section_exists=false + + while IFS= read -r line; do + # Check for section header + if [[ "$line" =~ ^[[:space:]]*\[([^]]+)\] ]]; then + local found_section="${BASH_REMATCH[1]}" + if $in_section && ! $key_written; then + # Leaving our section without writing key — append it + printf '%s = %s\n' "$key" "$value" >> "$tmpfile" + key_written=true + fi + if [[ "$found_section" == "$section" ]]; then + in_section=true + section_exists=true + else + in_section=false + fi + printf '%s\n' "$line" >> "$tmpfile" + continue + fi + + # Replace existing key within our section + if $in_section && ! $key_written; then + local stripped="${line#"${line%%[![:space:]]*}"}" + if [[ "$stripped" =~ ^${key}[[:space:]]*= ]]; then + printf '%s = %s\n' "$key" "$value" >> "$tmpfile" + key_written=true + continue + fi + fi + + printf '%s\n' "$line" >> "$tmpfile" + done < "$file" + + # Handle end-of-file cases + if $in_section && ! $key_written; then + printf '%s = %s\n' "$key" "$value" >> "$tmpfile" + elif ! $section_exists; then + printf '\n[%s]\n%s = %s\n' "$section" "$key" "$value" >> "$tmpfile" + fi + + mv "$tmpfile" "$file" +} + # --------------------------------------------------------------------------- # Install manifest — tracks what each setup script installs for rollback # --------------------------------------------------------------------------- diff --git a/manage_runner.sh b/manage_runner.sh index 2d88e7f..4261f58 100755 --- a/manage_runner.sh +++ b/manage_runner.sh @@ -3,7 +3,7 @@ set -euo pipefail # ============================================================================= # manage_runner.sh — Add, remove, or list Gitea Actions runners -# Reads runner definitions from runners.conf (pipe-delimited format). +# 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 @@ -31,16 +31,19 @@ Commands: list Show all runners with status Options: - --name Runner name as defined in runners.conf + --name Runner name (= section name 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 +# 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() { @@ -51,63 +54,205 @@ parse_runner_entry() { 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" + # 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 - while IFS= read -r line; do - # Skip comments and blank lines - [[ "$line" =~ ^[[:space:]]*# ]] && continue - [[ -z "$line" ]] && continue - echo "$line" - done < "$RUNNERS_CONF" + 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 — 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. +# For remote hosts, SSHs into them using resolved SSH credentials. # --------------------------------------------------------------------------- runner_ssh() { local cmd="$*" if [[ "$RUNNER_SSH_HOST" == "local" ]]; then - # macOS runner — execute directly on this machine eval "$cmd" else - ssh -o ConnectTimeout=10 \ + # 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}" \ @@ -126,7 +271,9 @@ runner_scp() { if [[ "$RUNNER_SSH_HOST" == "local" ]]; then cp "$src" "$dst" else - scp -o ConnectTimeout=10 \ + # 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}" \ @@ -136,16 +283,13 @@ runner_scp() { # --------------------------------------------------------------------------- # 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}..." + 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 @@ -158,44 +302,41 @@ add_docker_runner() { # Create data directory on remote host runner_ssh "mkdir -p '${RUNNER_DATA_PATH}'" - # Render docker-compose template with runner-specific vars + # Render docker-compose template local tmpfile tmpfile=$(mktemp) - export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH - export GITEA_RUNNER_REGISTRATION_TOKEN="${GITEA_RUNNER_REGISTRATION_TOKEN:-}" + 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} \${GITEA_RUNNER_REGISTRATION_TOKEN} \${RUNNER_LABELS} \${RUNNER_DATA_PATH}" + "\${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}" + "\${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_SSH_HOST}" + 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 -# 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..." + 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 @@ -214,7 +355,6 @@ add_native_runner() { # 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 @@ -230,26 +370,26 @@ add_native_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 + # 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 "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" \ + --token "${RUNNER_REG_TOKEN}" \ --name "${RUNNER_NAME}" \ - --labels "${RUNNER_LABELS}" \ + --labels "${RUNNER_LABELS_CSV}" \ --config "${RUNNER_DATA_PATH}/config.yaml" log_success "Runner registered" fi - # Render runner config (overwrites any default generated by register) + # Render runner config local tmpfile tmpfile=$(mktemp) - export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH + # 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}" + "\${RUNNER_NAME} \${RUNNER_LABELS_YAML} \${RUNNER_CAPACITY}" cp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml" rm -f "$tmpfile" @@ -270,9 +410,8 @@ add_native_runner() { # remove_docker_runner — Stop + remove Docker runner container # --------------------------------------------------------------------------- remove_docker_runner() { - log_info "Removing Docker runner '${RUNNER_NAME}' from ${RUNNER_SSH_HOST}..." + log_info "Removing Docker runner '${RUNNER_NAME}' from ${RUNNER_HOST} (${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" @@ -285,30 +424,25 @@ remove_docker_runner() { # 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 @@ -323,41 +457,35 @@ remove_native_runner() { # --------------------------------------------------------------------------- # 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 + 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 "[]") - # Print header - printf '%-20s %-16s %-10s %-8s %-10s\n' "NAME" "HOST" "LABELS" "TYPE" "STATUS" - printf '%-20s %-16s %-10s %-8s %-10s\n' "----" "----" "------" "----" "------" + printf '%-20s %-10s %-10s %-8s %-6s %-10s\n' "NAME" "HOST" "LABELS" "TYPE" "CAP" "STATUS" + printf '%-20s %-10s %-10s %-8s %-6s %-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 + 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") - 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" + printf '%-20s %-10s %-10s %-8s %-6s %-10s\n' "$name" "$host" "$labels" "$runner_type" "$capacity" "$status" + done < <(ini_list_sections "$RUNNERS_CONF") } # --------------------------------------------------------------------------- @@ -388,7 +516,6 @@ case "$COMMAND" in 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 ;; diff --git a/phase3_post_check.sh b/phase3_post_check.sh index a2912ee..0cdb614 100755 --- a/phase3_post_check.sh +++ b/phase3_post_check.sh @@ -36,13 +36,9 @@ API_RUNNERS=$(gitea_api GET "/admin/runners" 2>/dev/null || echo "[]") # Check each runner: exists in API response AND status is online/idle/active # A runner that registered but is not running will show as "offline". # --------------------------------------------------------------------------- -while IFS='|' read -r name rest; do - # Skip comments and blank lines - [[ "$name" =~ ^[[:space:]]*# ]] && continue +while IFS= read -r name; do [[ -z "$name" ]] && continue - name=$(echo "$name" | xargs) - # Look up runner by name in the API response local_status=$(printf '%s' "$API_RUNNERS" | jq -r --arg n "$name" '.[] | select(.name == $n) | .status' 2>/dev/null || true) @@ -56,12 +52,12 @@ while IFS='|' read -r name rest; do log_success "Runner '${name}' is ${local_status}" PASS=$((PASS + 1)) fi -done < "$RUNNERS_CONF" +done < <(ini_list_sections "$RUNNERS_CONF") # --------------------------------------------------------------------------- # Check: runner count matches runners.conf # --------------------------------------------------------------------------- -EXPECTED_COUNT=$(grep -Evc '^[[:space:]]*($|#)' "$RUNNERS_CONF") +EXPECTED_COUNT=$(ini_list_sections "$RUNNERS_CONF" | wc -l | xargs) ACTUAL_COUNT=$(printf '%s' "$API_RUNNERS" | jq 'length' 2>/dev/null || echo 0) if [[ "$ACTUAL_COUNT" -ge "$EXPECTED_COUNT" ]]; then diff --git a/phase3_runners.sh b/phase3_runners.sh index ff14d61..758fb98 100755 --- a/phase3_runners.sh +++ b/phase3_runners.sh @@ -29,8 +29,8 @@ if [[ ! -f "$RUNNERS_CONF" ]]; then exit 1 fi -# Count non-comment, non-blank lines to verify there are runners to deploy -RUNNER_COUNT=$(grep -Evc '^[[:space:]]*($|#)' "$RUNNERS_CONF") +# Count INI sections to verify there are runners to deploy +RUNNER_COUNT=$(ini_list_sections "$RUNNERS_CONF" | wc -l | xargs) if [[ "$RUNNER_COUNT" -eq 0 ]]; then log_error "No runners defined in runners.conf" exit 1 @@ -63,8 +63,7 @@ fi # --------------------------------------------------------------------------- # Step 2: Deploy each runner via manage_runner.sh -# Iterates over every non-comment line in runners.conf, extracts the name -# (first pipe-delimited field), and invokes manage_runner.sh add. +# Iterates over every INI section in runners.conf (each section = one runner). # manage_runner.sh handles its own idempotency (skips already-running runners). # --------------------------------------------------------------------------- log_step 2 "Deploying runners..." @@ -72,12 +71,8 @@ log_step 2 "Deploying runners..." DEPLOYED=0 FAILED=0 -while IFS='|' read -r name rest; do - # Skip comments and blank lines - [[ "$name" =~ ^[[:space:]]*# ]] && continue +while IFS= read -r name; do [[ -z "$name" ]] && continue - - name=$(echo "$name" | xargs) log_info "Processing runner: ${name}" if "${SCRIPT_DIR}/manage_runner.sh" add --name "$name"; then @@ -86,7 +81,7 @@ while IFS='|' read -r name rest; do log_error "Failed to deploy runner: ${name}" FAILED=$((FAILED + 1)) fi -done < "$RUNNERS_CONF" +done < <(ini_list_sections "$RUNNERS_CONF") # --------------------------------------------------------------------------- # Summary diff --git a/phase3_teardown.sh b/phase3_teardown.sh index 53d1fe2..b9f3c16 100755 --- a/phase3_teardown.sh +++ b/phase3_teardown.sh @@ -31,15 +31,11 @@ fi printf 'This will stop and remove all runners. Continue? [y/N] ' read -r confirm if [[ "$confirm" =~ ^[Yy]$ ]]; then - while IFS='|' read -r name rest; do - # Skip comments and blank lines - [[ "$name" =~ ^[[:space:]]*# ]] && continue + while IFS= read -r name; do [[ -z "$name" ]] && continue - - name=$(echo "$name" | xargs) log_info "Removing runner: ${name}" "${SCRIPT_DIR}/manage_runner.sh" remove --name "$name" || true - done < "$RUNNERS_CONF" + done < <(ini_list_sections "$RUNNERS_CONF") log_success "All runners removed" else log_info "Skipped runner removal" diff --git a/preflight.sh b/preflight.sh index d8170ce..32ce99c 100755 --- a/preflight.sh +++ b/preflight.sh @@ -99,6 +99,122 @@ if [[ ! -f "${SCRIPT_DIR}/runners.conf" ]]; then log_error " → runners.conf not found. Copy runners.conf.example to runners.conf." fi +# --------------------------------------------------------------------------- +# Check 5b: runners.conf INI format validation +# Validates required fields, enum values, and format per runner section. +# --------------------------------------------------------------------------- +# shellcheck disable=SC2329 +check_runners_ini_format() { + local conf="${SCRIPT_DIR}/runners.conf" + [[ -f "$conf" ]] || return 1 + + local errors=0 + local section + + while IFS= read -r section; do + [[ -z "$section" ]] && continue + + # Required fields + local host type data_path labels capacity repos + host=$(ini_get "$conf" "$section" "host" "") + type=$(ini_get "$conf" "$section" "type" "") + data_path=$(ini_get "$conf" "$section" "data_path" "") + labels=$(ini_get "$conf" "$section" "labels" "") + capacity=$(ini_get "$conf" "$section" "capacity" "") + repos=$(ini_get "$conf" "$section" "repos" "all") + + # host: must be unraid, fedora, local, or custom + case "$host" in + unraid|fedora|local|custom) ;; + "") + log_error " → [$section] host is empty (must be unraid, fedora, local, or custom)" + errors=$((errors + 1)) + ;; + *) + log_error " → [$section] host='$host' (must be unraid, fedora, local, or custom)" + errors=$((errors + 1)) + ;; + esac + + # type: must be docker or native + case "$type" in + docker|native) ;; + "") + log_error " → [$section] type is empty (must be docker or native)" + errors=$((errors + 1)) + ;; + *) + log_error " → [$section] type='$type' (must be docker or native)" + errors=$((errors + 1)) + ;; + esac + + # data_path: must start with / or ~/ + # shellcheck disable=SC2088 # tilde intentionally stored as literal string + if [[ -z "$data_path" ]]; then + log_error " → [$section] data_path is empty" + errors=$((errors + 1)) + elif [[ "$data_path" != /* ]] && [[ "$data_path" != "~/"* ]] && [[ "$data_path" != "~" ]]; then + log_error " → [$section] data_path='$data_path' (must start with / or ~/)" + errors=$((errors + 1)) + fi + + # labels: non-empty + if [[ -z "$labels" ]]; then + log_error " → [$section] labels is empty" + errors=$((errors + 1)) + fi + + # capacity: positive integer >= 1 + if [[ -n "$capacity" ]] && ! [[ "$capacity" =~ ^[1-9][0-9]*$ ]]; then + log_error " → [$section] capacity='$capacity' (must be positive integer >= 1)" + errors=$((errors + 1)) + fi + + # repos: must be "all" or a known REPO_*_NAME + if [[ "$repos" != "all" ]] && [[ -n "$repos" ]]; then + local repos_valid=false + for var in REPO_1_NAME REPO_2_NAME REPO_3_NAME; do + if [[ "$repos" == "${!var:-}" ]]; then repos_valid=true; break; fi + done + if ! $repos_valid; then + log_error " → [$section] repos='$repos' (must be 'all' or a known repo name)" + errors=$((errors + 1)) + fi + fi + + # docker runner: default_image should be set + if [[ "$type" == "docker" ]]; then + local default_image + default_image=$(ini_get "$conf" "$section" "default_image" "") + if [[ -z "$default_image" ]] && [[ -z "${RUNNER_DEFAULT_IMAGE:-}" ]]; then + log_error " → [$section] default_image is empty and RUNNER_DEFAULT_IMAGE not set in .env" + errors=$((errors + 1)) + fi + fi + + # custom host: extra SSH keys required + if [[ "$host" == "custom" ]]; then + local ssh_host ssh_user + ssh_host=$(ini_get "$conf" "$section" "ssh_host" "") + ssh_user=$(ini_get "$conf" "$section" "ssh_user" "") + if [[ -z "$ssh_host" ]]; then + log_error " → [$section] ssh_host is empty (required for host=custom)" + errors=$((errors + 1)) + fi + if [[ -z "$ssh_user" ]]; then + log_error " → [$section] ssh_user is empty (required for host=custom)" + errors=$((errors + 1)) + fi + fi + done < <(ini_list_sections "$conf") + + [[ $errors -eq 0 ]] +} +if [[ -f "${SCRIPT_DIR}/runners.conf" ]]; then + check 5b "runners.conf INI format validation" check_runners_ini_format +fi + # --------------------------------------------------------------------------- # Load env for remaining checks (may fail if .env missing) # --------------------------------------------------------------------------- @@ -118,6 +234,7 @@ REQUIRED_VARS=( GITEA_BACKUP_INTERNAL_URL BACKUP_STORAGE_PATH GITHUB_USERNAME GITHUB_TOKEN REPO_1_NAME REPO_2_NAME REPO_3_NAME + RUNNER_DEFAULT_IMAGE RUNNER_DEFAULT_DATA_PATH LOCAL_RUNNER_DATA_PATH GITHUB_MIRROR_TOKEN NGINX_CONTAINER_NAME NGINX_CONF_PATH SSL_MODE ) @@ -262,11 +379,31 @@ if [[ "$SKIP_PORT_CHECKS" == "true" ]]; then else check_port_unraid() { local port="${UNRAID_GITEA_PORT:-3000}" - ! ssh_exec UNRAID "ss -tlnp | grep -q ':${port} '" 2>/dev/null + local ss_output + local grep_rc + + # Fail closed on SSH/remote command errors. + ss_output=$(ssh_exec UNRAID "ss -tlnp" 2>/dev/null) || return 1 + + # grep exit codes: + # 0 => match found (port in use) => FAIL check + # 1 => no match (port free) => PASS check + # >1 => grep error => FAIL check + if printf '%s\n' "$ss_output" | grep -q ":${port} "; then + grep_rc=0 + else + grep_rc=$? + fi + + case "$grep_rc" in + 0) return 1 ;; + 1) return 0 ;; + *) return 1 ;; + esac } check 13 "Port ${UNRAID_GITEA_PORT:-3000} free on Unraid" check_port_unraid if ! check_port_unraid 2>/dev/null; then - log_error " → Port ${UNRAID_GITEA_PORT:-3000} already in use on Unraid." + log_error " → Port check failed or port ${UNRAID_GITEA_PORT:-3000} is already in use on Unraid." fi # --------------------------------------------------------------------------- @@ -274,11 +411,31 @@ else # --------------------------------------------------------------------------- check_port_fedora() { local port="${FEDORA_GITEA_PORT:-3000}" - ! ssh_exec FEDORA "ss -tlnp | grep -q ':${port} '" 2>/dev/null + local ss_output + local grep_rc + + # Fail closed on SSH/remote command errors. + ss_output=$(ssh_exec FEDORA "ss -tlnp" 2>/dev/null) || return 1 + + # grep exit codes: + # 0 => match found (port in use) => FAIL check + # 1 => no match (port free) => PASS check + # >1 => grep error => FAIL check + if printf '%s\n' "$ss_output" | grep -q ":${port} "; then + grep_rc=0 + else + grep_rc=$? + fi + + case "$grep_rc" in + 0) return 1 ;; + 1) return 0 ;; + *) return 1 ;; + esac } check 14 "Port ${FEDORA_GITEA_PORT:-3000} free on Fedora" check_port_fedora if ! check_port_fedora 2>/dev/null; then - log_error " → Port ${FEDORA_GITEA_PORT:-3000} already in use on Fedora." + log_error " → Port check failed or port ${FEDORA_GITEA_PORT:-3000} is already in use on Fedora." fi fi @@ -286,9 +443,19 @@ fi # Check 15: DNS resolves # --------------------------------------------------------------------------- check_dns() { + # Fail closed when required values are missing. + [[ -n "${GITEA_DOMAIN:-}" ]] || return 1 + [[ -n "${UNRAID_IP:-}" ]] || return 1 + local resolved - resolved=$(dig +short "${GITEA_DOMAIN:-}" 2>/dev/null | head -1) - [[ "$resolved" == "${UNRAID_IP:-}" ]] + resolved=$(dig +short "${GITEA_DOMAIN}" A 2>/dev/null | sed '/^[[:space:]]*$/d') || return 1 + [[ -n "$resolved" ]] || return 1 + + # Pass only if one of the domain's A records exactly matches UNRAID_IP. + if printf '%s\n' "$resolved" | grep -Fxq "${UNRAID_IP}"; then + return 0 + fi + return 1 } check 15 "DNS: ${GITEA_DOMAIN:-} resolves to ${UNRAID_IP:-}" check_dns if ! check_dns 2>/dev/null; then @@ -312,13 +479,21 @@ fi # shellcheck disable=SC2329 check_github_repos() { local all_ok=0 + + if [[ -z "${GITHUB_USERNAME:-}" ]]; then + log_error " → GITHUB_USERNAME is empty" + return 1 + fi + for var in REPO_1_NAME REPO_2_NAME REPO_3_NAME; do local repo="${!var:-}" if [[ -z "$repo" ]]; then + log_error " → ${var} is empty" + all_ok=1 continue fi - if ! curl -sf -H "Authorization: token ${GITHUB_TOKEN:-}" "https://api.github.com/repos/${GITHUB_USERNAME:-}/${repo}" -o /dev/null 2>/dev/null; then - log_error " → GitHub repo ${repo} not found under ${GITHUB_USERNAME:-}" + if ! curl -sf -H "Authorization: token ${GITHUB_TOKEN:-}" "https://api.github.com/repos/${GITHUB_USERNAME}/${repo}" -o /dev/null 2>/dev/null; then + log_error " → GitHub repo ${repo} not found under ${GITHUB_USERNAME}" all_ok=1 fi done diff --git a/runners.conf.example b/runners.conf.example index 27922c0..8ee4186 100644 --- a/runners.conf.example +++ b/runners.conf.example @@ -1,20 +1,82 @@ # ============================================================================= -# runners.conf — Gitea Actions Runner Definitions -# Copy to runners.conf and edit. One runner per line. +# runners.conf — Gitea Actions Runner Definitions (INI format) +# Copy to runners.conf and edit, or generate interactively with: +# ./setup/configure_runners.sh # Use manage_runner.sh to add/remove runners dynamically. # ============================================================================= # -# FORMAT: name|ssh_host|ssh_user|ssh_port|data_path|labels|type +# Each [section] defines one runner. Section name = runner display name in Gitea. # -# name — Display name in Gitea admin panel -# ssh_host — IP address or hostname (for local machine, use "local") -# ssh_user — SSH username (ignored if ssh_host is "local") -# ssh_port — SSH port (ignored if ssh_host is "local") -# data_path — Absolute path for runner binary + data on that machine -# labels — Comma-separated runner labels (used in workflow runs-on) -# type — "docker" (Linux, runs jobs in containers) or "native" (macOS, runs jobs on host) +# FIELD REFERENCE: # -# EXAMPLES: -unraid-runner|192.168.1.10|root|22|/mnt/nvme/gitea-runner|linux|docker -fedora-runner|192.168.1.20|user|22|/mnt/nvme/gitea-runner|linux|docker -macbook-runner|local|_|_|~/gitea-runner|macos|native +# host — enum: unraid, fedora, local, custom +# Known hosts resolve SSH from .env (IP, user, port, key). +# "local" runs on this machine. "custom" requires extra keys +# in the section: ssh_host, ssh_user, ssh_port, ssh_key. +# +# type — enum: docker, native +# "docker" = Linux container on remote host via Docker Compose. +# "native" = macOS binary + launchd on local machine. +# +# data_path — Absolute path (/ or ~/) where act_runner stores its binary, +# config, and job cache. NOT Gitea's data path. +# Default: RUNNER_DEFAULT_DATA_PATH (docker), LOCAL_RUNNER_DATA_PATH (native) +# +# labels — Workflow runs-on value (e.g. linux, macos, ubuntu-latest). +# Combined with default_image at deploy time to produce +# act_runner label spec: "linux:docker://image" or "macos:host". +# +# default_image — Docker image for job execution (docker runners only). +# Default: RUNNER_DEFAULT_IMAGE from .env. +# If LOCAL_REGISTRY is set, resolved as LOCAL_REGISTRY/image. +# Leave empty for native runners. +# +# repos — Token hint (not an act_runner setting): controls which +# registration token manage_runner.sh fetches at deploy time. +# "all" = instance-level token (runner available to all repos). +# Repo name = fetch repo-level token from Gitea API. +# +# capacity — Positive integer (>= 1). Max concurrent jobs. +# Default: RUNNER_DEFAULT_CAPACITY from .env. +# Constrained by host CPU/RAM. 0 and negative are invalid. +# +# cpu — Docker CPU limit (e.g. 2.0, 0.5). Ignored for native. +# Empty = no limit. +# +# memory — Docker memory limit (e.g. 2g, 512m). Ignored for native. +# Empty = no limit. +# +# STARTER ENTRIES (uncomment and edit): + +#[unraid-runner] +#host = unraid +#type = docker +#data_path = /mnt/nvme/gitea-runner +#labels = linux +#default_image = catthehacker/ubuntu:act-latest +#repos = all +#capacity = 1 +#cpu = +#memory = + +#[fedora-runner] +#host = fedora +#type = docker +#data_path = /mnt/nvme/gitea-runner +#labels = linux +#default_image = catthehacker/ubuntu:act-latest +#repos = all +#capacity = 1 +#cpu = +#memory = + +#[macbook-runner] +#host = local +#type = native +#data_path = ~/gitea-runner +#labels = macos +#default_image = +#repos = all +#capacity = 1 +#cpu = +#memory = diff --git a/setup/configure_env.sh b/setup/configure_env.sh index c91540e..2fc3021 100755 --- a/setup/configure_env.sh +++ b/setup/configure_env.sh @@ -64,7 +64,7 @@ done < "$ENV_FILE" # --------------------------------------------------------------------------- # Prompt function # --------------------------------------------------------------------------- -TOTAL_PROMPTS=50 +TOTAL_PROMPTS=59 CURRENT_PROMPT=0 LAST_SECTION="" @@ -122,6 +122,11 @@ prompt_var() { continue fi + # Optional fields accept any value including empty + if [[ "$validation" == "optional" ]]; then + break + fi + case "$validation" in ip) if validate_ip "$value"; then break; fi @@ -151,6 +156,10 @@ prompt_var() { if validate_integer "$value"; then break; fi printf '%b Invalid: must be a number%b\n' "$C_RED" "$C_RESET" ;; + positive_integer) + if validate_positive_integer "$value"; then break; fi + printf '%b Invalid: must be a positive integer (>= 1)%b\n' "$C_RED" "$C_RESET" + ;; password) if validate_password "$value"; then break; fi printf '%b Invalid: password must be at least 8 characters%b\n' "$C_RED" "$C_RESET" @@ -218,6 +227,7 @@ prompt_var "UNRAID_SSH_PORT" "SSH port" prompt_var "UNRAID_GITEA_PORT" "Port Gitea web UI will listen on" port "3000" "UNRAID SERVER" prompt_var "UNRAID_GITEA_SSH_PORT" "Port for git-over-SSH" port "2222" "UNRAID SERVER" prompt_var "UNRAID_GITEA_DATA_PATH" "Absolute path on NVMe for Gitea data" path "" "UNRAID SERVER" +prompt_var "UNRAID_SSH_KEY" "Path to SSH private key (empty = ssh-agent)" optional "" "UNRAID SERVER" # --- FEDORA SERVER --- prompt_var "FEDORA_IP" "Static IP of Fedora server" ip "" "FEDORA SERVER" @@ -226,6 +236,7 @@ prompt_var "FEDORA_SSH_PORT" "SSH port" prompt_var "FEDORA_GITEA_PORT" "Port Gitea web UI will listen on" port "3000" "FEDORA SERVER" prompt_var "FEDORA_GITEA_SSH_PORT" "Port for git-over-SSH" port "2222" "FEDORA SERVER" prompt_var "FEDORA_GITEA_DATA_PATH" "Absolute path on NVMe for Gitea data" path "" "FEDORA SERVER" +prompt_var "FEDORA_SSH_KEY" "Path to SSH private key (empty = ssh-agent)" optional "" "FEDORA SERVER" # --- GITEA SHARED CREDENTIALS --- prompt_var "GITEA_ADMIN_USER" "Admin username (same on both instances)" nonempty "" "GITEA SHARED CREDENTIALS" @@ -258,6 +269,14 @@ prompt_var "MIGRATE_LABELS" "Migrate GitHub labels" bool prompt_var "MIGRATE_MILESTONES" "Migrate GitHub milestones" bool "false" "REPOSITORIES" prompt_var "MIGRATE_WIKI" "Migrate GitHub wiki" bool "false" "REPOSITORIES" +# --- RUNNERS --- +prompt_var "RUNNER_DEFAULT_IMAGE" "Default container image for docker runners" nonempty "catthehacker/ubuntu:act-latest" "RUNNERS" +prompt_var "RUNNER_DEFAULT_CAPACITY" "Default max concurrent jobs per runner" positive_integer "1" "RUNNERS" +prompt_var "RUNNER_DEFAULT_DATA_PATH" "Default data path for remote (docker) runners" nonempty "/mnt/nvme/gitea-runner" "RUNNERS" +# shellcheck disable=SC2088 # tilde intentionally stored as literal (expanded at runtime) +prompt_var "LOCAL_RUNNER_DATA_PATH" "Data path for native macOS runner" nonempty "~/gitea-runner" "RUNNERS" +prompt_var "LOCAL_REGISTRY" "Local registry prefix (empty = Docker Hub)" optional "" "RUNNERS" + # --- GITHUB MIRROR --- prompt_var "GITHUB_MIRROR_TOKEN" "GitHub PAT with repo write scope" nonempty "" "GITHUB MIRROR" prompt_var "GITHUB_MIRROR_INTERVAL" "How often Gitea pushes to GitHub" nonempty "8h" "GITHUB MIRROR" diff --git a/setup/configure_runners.sh b/setup/configure_runners.sh index a0b0084..eadfab2 100755 --- a/setup/configure_runners.sh +++ b/setup/configure_runners.sh @@ -4,6 +4,8 @@ set -euo pipefail # ============================================================================= # setup/configure_runners.sh — Interactive runners.conf configuration wizard # Prompts for each runner's fields with validation and progress tracking. +# Writes INI-style runners.conf (see runners.conf.example for format). +# Defaults are pulled from .env — nothing is hardcoded. # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -14,6 +16,9 @@ RUNNERS_CONF="${PROJECT_ROOT}/runners.conf" # shellcheck disable=SC1091 source "${PROJECT_ROOT}/lib/common.sh" +# Load .env for defaults (RUNNER_DEFAULT_IMAGE, RUNNER_DEFAULT_CAPACITY, etc.) +load_env + # Colors — only emit ANSI escapes when stdout is a terminal (not piped/redirected) if [[ -t 1 ]]; then C_RESET='\033[0m'; C_BOLD='\033[1m'; C_GREEN='\033[0;32m' @@ -24,62 +29,56 @@ fi # --------------------------------------------------------------------------- # Load existing runner entries as defaults (idempotent re-runs). -# If runners.conf already exists, parse each non-comment line into parallel -# arrays so we can pre-fill prompts with previous values. +# If runners.conf already exists and is INI format, we read existing values +# to pre-fill prompts when re-running the wizard. # --------------------------------------------------------------------------- -EXISTING_NAMES=() -EXISTING_HOSTS=() -EXISTING_USERS=() -EXISTING_PORTS=() -EXISTING_PATHS=() -EXISTING_LABELS=() -EXISTING_TYPES=() - +EXISTING_SECTIONS=() if [[ -f "$RUNNERS_CONF" ]]; then - while IFS='|' read -r name host user port path labels type; do - [[ "$name" =~ ^[[:space:]]*# ]] && continue # skip comments - [[ -z "$name" ]] && continue # skip blank lines - # Trim whitespace from each field (xargs strips leading/trailing spaces) - # shellcheck disable=SC2005 - EXISTING_NAMES+=("$(echo "$name" | xargs)") - # shellcheck disable=SC2005 - EXISTING_HOSTS+=("$(echo "$host" | xargs)") - # shellcheck disable=SC2005 - EXISTING_USERS+=("$(echo "$user" | xargs)") - # shellcheck disable=SC2005 - EXISTING_PORTS+=("$(echo "$port" | xargs)") - # shellcheck disable=SC2005 - EXISTING_PATHS+=("$(echo "$path" | xargs)") - # shellcheck disable=SC2005 - EXISTING_LABELS+=("$(echo "$labels" | xargs)") - # shellcheck disable=SC2005 - EXISTING_TYPES+=("$(echo "$type" | xargs)") - done < "$RUNNERS_CONF" + while IFS= read -r sec; do + [[ -z "$sec" ]] && continue + EXISTING_SECTIONS+=("$sec") + done < <(ini_list_sections "$RUNNERS_CONF") fi - -EXISTING_COUNT=${#EXISTING_NAMES[@]} +EXISTING_COUNT=${#EXISTING_SECTIONS[@]} # --------------------------------------------------------------------------- # Validation helpers # --------------------------------------------------------------------------- -# Must start with alphanumeric; rest can include hyphens and underscores. -# Matches the naming convention Gitea uses in its admin panel. validate_runner_name() { [[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] } -# Only two runner types exist: docker (Linux containers) and native (macOS binary) validate_runner_type() { [[ "$1" == "docker" || "$1" == "native" ]] } -# Absolute paths (/) for remote hosts, or ~/ for native macOS runners. -# Tilde is stored literally; manage_runner.sh expands it at runtime. +validate_runner_host() { + [[ "$1" == "unraid" || "$1" == "fedora" || "$1" == "local" || "$1" == "custom" ]] +} + validate_runner_path() { # shellcheck disable=SC2088 # tilde intentionally stored as literal string [[ "$1" == /* || "$1" == "~/"* || "$1" == "~" ]] } +validate_runner_repos() { + if [[ "$1" == "all" ]]; then return 0; fi + # Check against known REPO_*_NAME values + [[ "$1" == "${REPO_1_NAME:-}" ]] || [[ "$1" == "${REPO_2_NAME:-}" ]] || [[ "$1" == "${REPO_3_NAME:-}" ]] +} + +validate_capacity() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +validate_docker_cpu() { + [[ -z "$1" ]] || [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]] +} + +validate_docker_memory() { + [[ -z "$1" ]] || [[ "$1" =~ ^[0-9]+[kmgKMG]?$ ]] +} + # --------------------------------------------------------------------------- # Prompt function — matches configure_env.sh UX (progress counter, default # values in yellow brackets, validation loop with red error hints). @@ -91,16 +90,15 @@ TOTAL_PROMPTS=0 prompt_field() { local field_name="$1" # e.g. "name", "ssh_host" local description="$2" # human-readable hint shown in parentheses - local validation="$3" # validator key: runner_type, runner_name, ip, port, etc. + local validation="$3" # validator key local default="${4:-}" # pre-filled value shown in [brackets]; Enter accepts it + local allow_empty="${5:-false}" # true if empty is a valid answer CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) - # Progress indicator in dim grey: [3/21] local progress progress=$(printf '[%d/%d]' "$CURRENT_PROMPT" "$TOTAL_PROMPTS") - # Format: [3/21] field_name (description) [default]: _ local prompt_text if [[ -n "$default" ]]; then prompt_text=$(printf '%b%s%b %s (%s) %b[%s]%b: ' "$C_DIM" "$progress" "$C_RESET" "$field_name" "$description" "$C_YELLOW" "$default" "$C_RESET") @@ -108,32 +106,57 @@ prompt_field() { prompt_text=$(printf '%b%s%b %s (%s): ' "$C_DIM" "$progress" "$C_RESET" "$field_name" "$description") fi - # Validation loop — re-prompts on invalid input until the value passes local value while true; do printf '%b' "$prompt_text" read -r value - # Empty input → accept the default (if one exists) if [[ -z "$value" ]]; then value="$default" fi - # Reject empty when no default was available + # Allow empty when explicitly permitted (optional fields) + if [[ -z "$value" ]] && [[ "$allow_empty" == "true" ]]; then + break + fi + if [[ -z "$value" ]]; then printf '%b Invalid: value cannot be empty%b\n' "$C_RED" "$C_RESET" continue fi - # Dispatch to the appropriate validator case "$validation" in + runner_name) + if validate_runner_name "$value"; then break; fi + printf '%b Invalid: alphanumeric, hyphens, and underscores only%b\n' "$C_RED" "$C_RESET" + ;; runner_type) if validate_runner_type "$value"; then break; fi printf '%b Invalid: must be "docker" or "native"%b\n' "$C_RED" "$C_RESET" ;; - runner_name) - if validate_runner_name "$value"; then break; fi - printf '%b Invalid: alphanumeric, hyphens, and underscores only%b\n' "$C_RED" "$C_RESET" + runner_host) + if validate_runner_host "$value"; then break; fi + printf '%b Invalid: must be unraid, fedora, local, or custom%b\n' "$C_RED" "$C_RESET" + ;; + runner_path) + if validate_runner_path "$value"; then break; fi + printf '%b Invalid path (must start with / or ~/)%b\n' "$C_RED" "$C_RESET" + ;; + runner_repos) + if validate_runner_repos "$value"; then break; fi + printf '%b Invalid: must be "all" or a known repo name%b\n' "$C_RED" "$C_RESET" + ;; + capacity) + if validate_capacity "$value"; then break; fi + printf '%b Invalid: must be a positive integer (>= 1)%b\n' "$C_RED" "$C_RESET" + ;; + docker_cpu) + if validate_docker_cpu "$value"; then break; fi + printf '%b Invalid: must be a positive number (e.g. 2.0, 0.5) or empty%b\n' "$C_RED" "$C_RESET" + ;; + docker_memory) + if validate_docker_memory "$value"; then break; fi + printf '%b Invalid: must be Docker memory format (e.g. 2g, 512m) or empty%b\n' "$C_RED" "$C_RESET" ;; ip) if validate_ip "$value"; then break; fi @@ -143,9 +166,9 @@ prompt_field() { if validate_port "$value"; then break; fi printf '%b Invalid port (expected: 1-65535)%b\n' "$C_RED" "$C_RESET" ;; - runner_path) - if validate_runner_path "$value"; then break; fi - printf '%b Invalid path (must start with / or ~/)%b\n' "$C_RED" "$C_RESET" + optional_path) + if validate_optional_path "$value"; then break; fi + printf '%b Invalid path (must start with / or ~/ or be empty)%b\n' "$C_RED" "$C_RESET" ;; nonempty|*) if validate_nonempty "$value"; then break; fi @@ -154,7 +177,6 @@ prompt_field() { esac done - # Return the validated value via global (bash has no return-by-value) PROMPT_RESULT="$value" } @@ -166,6 +188,7 @@ printf '\n%b╔═════════════════════ printf '%b║ Gitea Migration — Runner Setup ║%b\n' "$C_BOLD" "$C_RESET" printf '%b╚══════════════════════════════════════════════════════════╝%b\n\n' "$C_BOLD" "$C_RESET" printf 'Press Enter to keep the current value shown in [brackets].\n' +printf 'Defaults are pulled from .env variables.\n' # --------------------------------------------------------------------------- # Ask how many runners to configure. @@ -187,47 +210,28 @@ if ! [[ "$runner_count" =~ ^[0-9]+$ ]] || [[ "$runner_count" -lt 1 ]]; then runner_count=$local_default fi -# Each runner has 7 fields. Native runners auto-fill 3 (host/user/port) but -# we still count them toward the total so the progress bar stays consistent. -TOTAL_PROMPTS=$((runner_count * 7)) +# Prompt count per runner: +# name(1) + host(1) + type(1) + data_path(1) + labels(1) + default_image(1) + +# repos(1) + capacity(1) + cpu(1) + memory(1) = 10 +# Custom host adds: ssh_host(1) + ssh_user(1) + ssh_port(1) + ssh_key(1) = 4 +# We estimate max 10 per runner for progress display +TOTAL_PROMPTS=$((runner_count * 10)) # --------------------------------------------------------------------------- # Collect runner definitions # --------------------------------------------------------------------------- -# Storage arrays for collected values +# Storage arrays for collected INI data COLLECTED_NAMES=() -COLLECTED_HOSTS=() -COLLECTED_USERS=() -COLLECTED_PORTS=() -COLLECTED_PATHS=() -COLLECTED_LABELS=() -COLLECTED_TYPES=() +declare -A COLLECTED_DATA # COLLECTED_DATA["name:key"] = value for ((i = 0; i < runner_count; i++)); do runner_num=$((i + 1)) printf '\n%b── RUNNER %d OF %d ──────────────────────────────────────────%b\n' "$C_BOLD" "$runner_num" "$runner_count" "$C_RESET" - # Defaults from existing config (if available) - ex_type="${EXISTING_TYPES[$i]:-}" - ex_name="${EXISTING_NAMES[$i]:-}" - ex_host="${EXISTING_HOSTS[$i]:-}" - ex_user="${EXISTING_USERS[$i]:-}" - ex_port="${EXISTING_PORTS[$i]:-}" - ex_path="${EXISTING_PATHS[$i]:-}" - ex_labels="${EXISTING_LABELS[$i]:-}" + # Existing defaults from previous runners.conf (if available) + ex_name="${EXISTING_SECTIONS[$i]:-}" - # --- type (asked first — drives conditionals below) --- - # Like SSL_MODE in configure_env.sh, type determines which fields are prompted - # vs auto-filled. Docker runners need SSH creds; native runners run locally. - type_default="$ex_type" - if [[ -z "$type_default" ]]; then - # Smart default: first two runners are docker (Unraid/Fedora), third is native (MacBook) - if [[ $i -lt 2 ]]; then type_default="docker"; else type_default="native"; fi - fi - prompt_field "type" "docker (Linux) or native (macOS)" "runner_type" "$type_default" - r_type="$PROMPT_RESULT" - - # --- name (shown in Gitea admin panel) --- + # --- name --- name_default="$ex_name" if [[ -z "$name_default" ]]; then case $i in @@ -237,120 +241,229 @@ for ((i = 0; i < runner_count; i++)); do *) name_default="runner-${runner_num}" ;; esac fi - prompt_field "name" "display name in Gitea admin" "runner_name" "$name_default" + prompt_field "name" "runner display name in Gitea" "runner_name" "$name_default" r_name="$PROMPT_RESULT" + COLLECTED_NAMES+=("$r_name") - # --- Conditional SSH fields --- - # Native runners execute on the local macOS machine — no SSH needed. - # Docker runners deploy to a remote Linux host via SSH. - if [[ "$r_type" == "native" ]]; then - r_host="local" - r_user="_" - r_port="_" - printf '%b → ssh_host=local, ssh_user=_, ssh_port=_ (native runner)%b\n' "$C_DIM" "$C_RESET" - # Increment counter for the 3 skipped prompts to keep progress accurate - CURRENT_PROMPT=$((CURRENT_PROMPT + 3)) - else - # --- ssh_host (IP of remote Linux host) --- - prompt_field "ssh_host" "IP address of remote host" "ip" "${ex_host:-}" - r_host="$PROMPT_RESULT" - - # --- ssh_user --- - user_default="$ex_user" - if [[ -z "$user_default" ]]; then user_default="root"; fi - prompt_field "ssh_user" "SSH username" "nonempty" "$user_default" - r_user="$PROMPT_RESULT" - - # --- ssh_port --- - port_default="$ex_port" - if [[ -z "$port_default" ]]; then port_default="22"; fi - prompt_field "ssh_port" "SSH port" "port" "$port_default" - r_port="$PROMPT_RESULT" + # --- host --- + host_default="" + if [[ -n "$ex_name" ]]; then + host_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "host" "") fi + if [[ -z "$host_default" ]]; then + case $i in + 0) host_default="unraid" ;; + 1) host_default="fedora" ;; + 2) host_default="local" ;; + *) host_default="unraid" ;; + esac + fi + prompt_field "host" "unraid, fedora, local, or custom" "runner_host" "$host_default" + r_host="$PROMPT_RESULT" + COLLECTED_DATA["${r_name}:host"]="$r_host" - # --- data_path (where runner binary + config + data live) --- - path_default="$ex_path" + # Show resolved SSH details for known hosts + case "$r_host" in + unraid) + printf '%b → SSH: %s@%s:%s%b\n' "$C_DIM" "${UNRAID_SSH_USER:-}" "${UNRAID_IP:-}" "${UNRAID_SSH_PORT:-22}" "$C_RESET" + if [[ -n "${UNRAID_SSH_KEY:-}" ]]; then + printf '%b → SSH key: %s%b\n' "$C_DIM" "${UNRAID_SSH_KEY}" "$C_RESET" + fi + ;; + fedora) + printf '%b → SSH: %s@%s:%s%b\n' "$C_DIM" "${FEDORA_SSH_USER:-}" "${FEDORA_IP:-}" "${FEDORA_SSH_PORT:-22}" "$C_RESET" + if [[ -n "${FEDORA_SSH_KEY:-}" ]]; then + printf '%b → SSH key: %s%b\n' "$C_DIM" "${FEDORA_SSH_KEY}" "$C_RESET" + fi + ;; + local) + printf '%b → Runs on this machine (no SSH)%b\n' "$C_DIM" "$C_RESET" + ;; + custom) + # Prompt for custom SSH details + ex_ssh_host="" + ex_ssh_user="" + ex_ssh_port="" + ex_ssh_key="" + if [[ -n "$ex_name" ]]; then + ex_ssh_host=$(ini_get "$RUNNERS_CONF" "$ex_name" "ssh_host" "") + ex_ssh_user=$(ini_get "$RUNNERS_CONF" "$ex_name" "ssh_user" "") + ex_ssh_port=$(ini_get "$RUNNERS_CONF" "$ex_name" "ssh_port" "22") + ex_ssh_key=$(ini_get "$RUNNERS_CONF" "$ex_name" "ssh_key" "") + fi + prompt_field "ssh_host" "IP address of remote host" "ip" "$ex_ssh_host" + COLLECTED_DATA["${r_name}:ssh_host"]="$PROMPT_RESULT" + prompt_field "ssh_user" "SSH username" "nonempty" "${ex_ssh_user:-root}" + COLLECTED_DATA["${r_name}:ssh_user"]="$PROMPT_RESULT" + prompt_field "ssh_port" "SSH port" "port" "${ex_ssh_port:-22}" + COLLECTED_DATA["${r_name}:ssh_port"]="$PROMPT_RESULT" + prompt_field "ssh_key" "path to SSH key (empty = ssh-agent)" "optional_path" "$ex_ssh_key" "true" + COLLECTED_DATA["${r_name}:ssh_key"]="$PROMPT_RESULT" + # Adjust total prompts (added 4 custom fields, but we already allocated 10) + ;; + esac + + # --- type --- + type_default="" + if [[ -n "$ex_name" ]]; then + type_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "type" "") + fi + if [[ -z "$type_default" ]]; then + if [[ "$r_host" == "local" ]]; then type_default="native"; else type_default="docker"; fi + fi + prompt_field "type" "docker (Linux) or native (macOS)" "runner_type" "$type_default" + r_type="$PROMPT_RESULT" + COLLECTED_DATA["${r_name}:type"]="$r_type" + + # --- data_path --- + path_default="" + if [[ -n "$ex_name" ]]; then + path_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "data_path" "") + fi if [[ -z "$path_default" ]]; then if [[ "$r_type" == "native" ]]; then - # shellcheck disable=SC2088 # literal tilde — expanded by manage_runner.sh - path_default="~/gitea-runner" + path_default="${LOCAL_RUNNER_DATA_PATH:-~/gitea-runner}" else - path_default="/mnt/nvme/gitea-runner" # typical Unraid/Fedora NVMe mount + path_default="${RUNNER_DEFAULT_DATA_PATH:-/mnt/nvme/gitea-runner}" fi fi prompt_field "data_path" "absolute path for runner data" "runner_path" "$path_default" - r_path="$PROMPT_RESULT" + COLLECTED_DATA["${r_name}:data_path"]="$PROMPT_RESULT" - # --- labels (matched by workflow `runs-on:` directives) --- - labels_default="$ex_labels" + # --- labels --- + labels_default="" + if [[ -n "$ex_name" ]]; then + labels_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "labels" "") + fi if [[ -z "$labels_default" ]]; then if [[ "$r_type" == "native" ]]; then labels_default="macos"; else labels_default="linux"; fi fi - prompt_field "labels" "workflow runs-on labels" "nonempty" "$labels_default" - r_labels="$PROMPT_RESULT" + prompt_field "labels" "workflow runs-on value" "nonempty" "$labels_default" + COLLECTED_DATA["${r_name}:labels"]="$PROMPT_RESULT" - # Append this runner's values to the collection arrays - COLLECTED_NAMES+=("$r_name") - COLLECTED_HOSTS+=("$r_host") - COLLECTED_USERS+=("$r_user") - COLLECTED_PORTS+=("$r_port") - COLLECTED_PATHS+=("$r_path") - COLLECTED_LABELS+=("$r_labels") - COLLECTED_TYPES+=("$r_type") + # --- default_image (skip for native) --- + if [[ "$r_type" == "docker" ]]; then + image_default="" + if [[ -n "$ex_name" ]]; then + image_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "default_image" "") + fi + if [[ -z "$image_default" ]]; then + image_default="${RUNNER_DEFAULT_IMAGE:-catthehacker/ubuntu:act-latest}" + fi + prompt_field "default_image" "Docker image for job execution" "nonempty" "$image_default" + COLLECTED_DATA["${r_name}:default_image"]="$PROMPT_RESULT" + else + COLLECTED_DATA["${r_name}:default_image"]="" + CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) # skip but count for progress + fi + + # --- repos --- + repos_default="" + if [[ -n "$ex_name" ]]; then + repos_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "repos" "all") + fi + if [[ -z "$repos_default" ]]; then repos_default="all"; fi + # Build hint with known repo names + repos_hint="token scope: all" + for var in REPO_1_NAME REPO_2_NAME REPO_3_NAME; do + if [[ -n "${!var:-}" ]]; then + repos_hint="${repos_hint}, ${!var}" + fi + done + prompt_field "repos" "$repos_hint" "runner_repos" "$repos_default" + COLLECTED_DATA["${r_name}:repos"]="$PROMPT_RESULT" + + # --- capacity --- + cap_default="" + if [[ -n "$ex_name" ]]; then + cap_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "capacity" "") + fi + if [[ -z "$cap_default" ]]; then + cap_default="${RUNNER_DEFAULT_CAPACITY:-1}" + fi + prompt_field "capacity" "max concurrent jobs (>= 1)" "capacity" "$cap_default" + COLLECTED_DATA["${r_name}:capacity"]="$PROMPT_RESULT" + + # --- cpu (skip for native) --- + if [[ "$r_type" == "docker" ]]; then + cpu_default="" + if [[ -n "$ex_name" ]]; then + cpu_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "cpu" "") + fi + prompt_field "cpu" "Docker CPU limit (e.g. 2.0, empty = no limit)" "docker_cpu" "$cpu_default" "true" + COLLECTED_DATA["${r_name}:cpu"]="$PROMPT_RESULT" + else + COLLECTED_DATA["${r_name}:cpu"]="" + CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) + fi + + # --- memory (skip for native) --- + if [[ "$r_type" == "docker" ]]; then + mem_default="" + if [[ -n "$ex_name" ]]; then + mem_default=$(ini_get "$RUNNERS_CONF" "$ex_name" "memory" "") + fi + prompt_field "memory" "Docker memory limit (e.g. 2g, empty = no limit)" "docker_memory" "$mem_default" "true" + COLLECTED_DATA["${r_name}:memory"]="$PROMPT_RESULT" + else + COLLECTED_DATA["${r_name}:memory"]="" + CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) + fi done # --------------------------------------------------------------------------- -# Write runners.conf — overwrites any existing file with the header comment -# block and one pipe-delimited line per runner. +# Write runners.conf — INI format with header comment block # --------------------------------------------------------------------------- cat > "$RUNNERS_CONF" <<'HEADER' # ============================================================================= -# runners.conf — Gitea Actions Runner Definitions +# runners.conf — Gitea Actions Runner Definitions (INI format) # Generated by setup/configure_runners.sh — edit manually or re-run wizard. # Use manage_runner.sh to add/remove runners dynamically. +# See runners.conf.example for field reference. # ============================================================================= -# -# FORMAT: name|ssh_host|ssh_user|ssh_port|data_path|labels|type -# -# name — Display name in Gitea admin panel -# ssh_host — IP address or hostname (for local machine, use "local") -# ssh_user — SSH username (ignored if ssh_host is "local") -# ssh_port — SSH port (ignored if ssh_host is "local") -# data_path — Absolute path for runner binary + data on that machine -# labels — Comma-separated runner labels (used in workflow runs-on) -# type — "docker" (Linux, runs jobs in containers) or "native" (macOS, runs jobs on host) -# + HEADER -for ((i = 0; i < ${#COLLECTED_NAMES[@]}; i++)); do - printf '%s|%s|%s|%s|%s|%s|%s\n' \ - "${COLLECTED_NAMES[$i]}" \ - "${COLLECTED_HOSTS[$i]}" \ - "${COLLECTED_USERS[$i]}" \ - "${COLLECTED_PORTS[$i]}" \ - "${COLLECTED_PATHS[$i]}" \ - "${COLLECTED_LABELS[$i]}" \ - "${COLLECTED_TYPES[$i]}" >> "$RUNNERS_CONF" +for r_name in "${COLLECTED_NAMES[@]}"; do + printf '[%s]\n' "$r_name" >> "$RUNNERS_CONF" + + # Write fields in canonical order + for key in host type data_path labels default_image repos capacity cpu memory; do + local_val="${COLLECTED_DATA["${r_name}:${key}"]:-}" + printf '%-14s= %s\n' "$key" "$local_val" >> "$RUNNERS_CONF" + done + + # Write custom SSH fields if host=custom + if [[ "${COLLECTED_DATA["${r_name}:host"]:-}" == "custom" ]]; then + for key in ssh_host ssh_user ssh_port ssh_key; do + local_val="${COLLECTED_DATA["${r_name}:${key}"]:-}" + printf '%-14s= %s\n' "$key" "$local_val" >> "$RUNNERS_CONF" + done + fi + + printf '\n' >> "$RUNNERS_CONF" done # =========================================================================== -# Summary — green border box + table matching manage_runner.sh list format +# Summary — green border box + table # =========================================================================== printf '\n%b╔══════════════════════════════════════════════════════════╗%b\n' "$C_GREEN" "$C_RESET" printf '%b║ Runner Configuration Complete ║%b\n' "$C_GREEN" "$C_RESET" printf '%b╚══════════════════════════════════════════════════════════╝%b\n\n' "$C_GREEN" "$C_RESET" printf '%bRunners configured:%b %d\n\n' "$C_BOLD" "$C_RESET" "${#COLLECTED_NAMES[@]}" -printf '%-20s %-16s %-10s %-8s %-24s\n' "NAME" "HOST" "LABELS" "TYPE" "DATA PATH" -printf '%-20s %-16s %-10s %-8s %-24s\n' "----" "----" "------" "----" "---------" +printf '%-20s %-10s %-8s %-10s %-6s %-10s %-24s\n' "NAME" "HOST" "TYPE" "LABELS" "CAP" "REPOS" "DATA PATH" +printf '%-20s %-10s %-8s %-10s %-6s %-10s %-24s\n' "----" "----" "----" "------" "---" "-----" "---------" -for ((i = 0; i < ${#COLLECTED_NAMES[@]}; i++)); do - printf '%-20s %-16s %-10s %-8s %-24s\n' \ - "${COLLECTED_NAMES[$i]}" \ - "${COLLECTED_HOSTS[$i]}" \ - "${COLLECTED_LABELS[$i]}" \ - "${COLLECTED_TYPES[$i]}" \ - "${COLLECTED_PATHS[$i]}" +for r_name in "${COLLECTED_NAMES[@]}"; do + printf '%-20s %-10s %-8s %-10s %-6s %-10s %-24s\n' \ + "$r_name" \ + "${COLLECTED_DATA["${r_name}:host"]:-}" \ + "${COLLECTED_DATA["${r_name}:type"]:-}" \ + "${COLLECTED_DATA["${r_name}:labels"]:-}" \ + "${COLLECTED_DATA["${r_name}:capacity"]:-}" \ + "${COLLECTED_DATA["${r_name}:repos"]:-}" \ + "${COLLECTED_DATA["${r_name}:data_path"]:-}" done printf '\n Saved to: %s\n\n' "$RUNNERS_CONF" diff --git a/templates/docker-compose-runner.yml.tpl b/templates/docker-compose-runner.yml.tpl index 83d58de..8bdd9f0 100644 --- a/templates/docker-compose-runner.yml.tpl +++ b/templates/docker-compose-runner.yml.tpl @@ -7,9 +7,10 @@ services: restart: unless-stopped environment: - GITEA_INSTANCE_URL=${GITEA_INTERNAL_URL} - - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN} + - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_REG_TOKEN} - GITEA_RUNNER_NAME=${RUNNER_NAME} - - GITEA_RUNNER_LABELS=${RUNNER_LABELS} + - GITEA_RUNNER_LABELS=${RUNNER_LABELS_CSV} volumes: - /var/run/docker.sock:/var/run/docker.sock - ${RUNNER_DATA_PATH}:/data +${RUNNER_DEPLOY_RESOURCES} diff --git a/templates/runner-config.yaml.tpl b/templates/runner-config.yaml.tpl index 57653e6..1d107bc 100644 --- a/templates/runner-config.yaml.tpl +++ b/templates/runner-config.yaml.tpl @@ -5,8 +5,8 @@ log: runner: name: ${RUNNER_NAME} labels: - - "${RUNNER_LABELS}:docker://node:20-bookworm" - capacity: 1 +${RUNNER_LABELS_YAML} + capacity: ${RUNNER_CAPACITY} timeout: 3h insecure: false fetch_timeout: 5s