feat: add interactive runners.conf configuration wizard

Replaces manual pipe-delimited file editing with a guided setup
script matching the configure_env.sh UX pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
S
2026-02-28 22:21:14 -05:00
parent 5ce3a234f3
commit fcd966f97d

357
setup/configure_runners.sh Executable file
View File

@@ -0,0 +1,357 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# setup/configure_runners.sh — Interactive runners.conf configuration wizard
# Prompts for each runner's fields with validation and progress tracking.
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="${SCRIPT_DIR}/.."
RUNNERS_CONF="${PROJECT_ROOT}/runners.conf"
# shellcheck source=../lib/common.sh
# shellcheck disable=SC1091
source "${PROJECT_ROOT}/lib/common.sh"
# 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'
C_YELLOW='\033[0;33m'; C_RED='\033[0;31m'; C_DIM='\033[2m'
else
C_RESET=''; C_BOLD=''; C_GREEN=''; C_YELLOW=''; C_RED=''; C_DIM=''
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.
# ---------------------------------------------------------------------------
EXISTING_NAMES=()
EXISTING_HOSTS=()
EXISTING_USERS=()
EXISTING_PORTS=()
EXISTING_PATHS=()
EXISTING_LABELS=()
EXISTING_TYPES=()
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"
fi
EXISTING_COUNT=${#EXISTING_NAMES[@]}
# ---------------------------------------------------------------------------
# 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_path() {
# shellcheck disable=SC2088 # tilde intentionally stored as literal string
[[ "$1" == /* || "$1" == "~/"* || "$1" == "~" ]]
}
# ---------------------------------------------------------------------------
# Prompt function — matches configure_env.sh UX (progress counter, default
# values in yellow brackets, validation loop with red error hints).
# Sets the global PROMPT_RESULT to the validated user input.
# ---------------------------------------------------------------------------
CURRENT_PROMPT=0
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 default="${4:-}" # pre-filled value shown in [brackets]; Enter accepts it
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")
else
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
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_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"
;;
ip)
if validate_ip "$value"; then break; fi
printf '%b Invalid IP address format (expected: x.x.x.x)%b\n' "$C_RED" "$C_RESET"
;;
port)
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"
;;
nonempty|*)
if validate_nonempty "$value"; then break; fi
printf '%b Invalid: value cannot be empty%b\n' "$C_RED" "$C_RESET"
;;
esac
done
# Return the validated value via global (bash has no return-by-value)
PROMPT_RESULT="$value"
}
# ===========================================================================
# Main
# ===========================================================================
printf '\n%b╔══════════════════════════════════════════════════════════╗%b\n' "$C_BOLD" "$C_RESET"
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'
# ---------------------------------------------------------------------------
# Ask how many runners to configure.
# Default is 3 (unraid + fedora + macbook) or the count from existing config.
# ---------------------------------------------------------------------------
local_default=3
if [[ $EXISTING_COUNT -gt 0 ]]; then
local_default=$EXISTING_COUNT
fi
printf '\n%b── RUNNER COUNT ──────────────────────────────────────────%b\n' "$C_BOLD" "$C_RESET"
printf 'How many runners to configure? %b[%s]%b: ' "$C_YELLOW" "$local_default" "$C_RESET"
read -r runner_count
if [[ -z "$runner_count" ]]; then
runner_count=$local_default
fi
if ! [[ "$runner_count" =~ ^[0-9]+$ ]] || [[ "$runner_count" -lt 1 ]]; then
printf '%b Invalid: must be a positive number. Using %d.%b\n' "$C_RED" "$local_default" "$C_RESET"
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))
# ---------------------------------------------------------------------------
# Collect runner definitions
# ---------------------------------------------------------------------------
# Storage arrays for collected values
COLLECTED_NAMES=()
COLLECTED_HOSTS=()
COLLECTED_USERS=()
COLLECTED_PORTS=()
COLLECTED_PATHS=()
COLLECTED_LABELS=()
COLLECTED_TYPES=()
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]:-}"
# --- 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_default="$ex_name"
if [[ -z "$name_default" ]]; then
case $i in
0) name_default="unraid-runner" ;;
1) name_default="fedora-runner" ;;
2) name_default="macbook-runner" ;;
*) name_default="runner-${runner_num}" ;;
esac
fi
prompt_field "name" "display name in Gitea admin" "runner_name" "$name_default"
r_name="$PROMPT_RESULT"
# --- 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"
fi
# --- data_path (where runner binary + config + data live) ---
path_default="$ex_path"
if [[ -z "$path_default" ]]; then
if [[ "$r_type" == "native" ]]; then
# shellcheck disable=SC2088 # literal tilde — expanded by manage_runner.sh
path_default="~/gitea-runner"
else
path_default="/mnt/nvme/gitea-runner" # typical Unraid/Fedora NVMe mount
fi
fi
prompt_field "data_path" "absolute path for runner data" "runner_path" "$path_default"
r_path="$PROMPT_RESULT"
# --- labels (matched by workflow `runs-on:` directives) ---
labels_default="$ex_labels"
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"
# 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")
done
# ---------------------------------------------------------------------------
# Write runners.conf — overwrites any existing file with the header comment
# block and one pipe-delimited line per runner.
# ---------------------------------------------------------------------------
cat > "$RUNNERS_CONF" <<'HEADER'
# =============================================================================
# runners.conf — Gitea Actions Runner Definitions
# Generated by setup/configure_runners.sh — edit manually or re-run wizard.
# Use manage_runner.sh to add/remove runners dynamically.
# =============================================================================
#
# 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"
done
# ===========================================================================
# Summary — green border box + table matching manage_runner.sh list format
# ===========================================================================
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' "----" "----" "------" "----" "---------"
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]}"
done
printf '\n Saved to: %s\n\n' "$RUNNERS_CONF"
printf 'Next step: run %bsetup/macbook.sh%b to install local prerequisites.\n' "$C_BOLD" "$C_RESET"