#!/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. # 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)" PROJECT_ROOT="${SCRIPT_DIR}/.." RUNNERS_CONF="${PROJECT_ROOT}/runners.conf" # shellcheck source=../lib/common.sh # 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' 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 and is INI format, we read existing values # to pre-fill prompts when re-running the wizard. # --------------------------------------------------------------------------- EXISTING_SECTIONS=() if [[ -f "$RUNNERS_CONF" ]]; then while IFS= read -r sec; do [[ -z "$sec" ]] && continue EXISTING_SECTIONS+=("$sec") done < <(ini_list_sections "$RUNNERS_CONF") fi EXISTING_COUNT=${#EXISTING_SECTIONS[@]} # --------------------------------------------------------------------------- # Validation helpers # --------------------------------------------------------------------------- validate_runner_name() { [[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] } validate_runner_type() { [[ "$1" == "docker" || "$1" == "native" ]] } 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). # 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 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)) local progress progress=$(printf '[%d/%d]' "$CURRENT_PROMPT" "$TOTAL_PROMPTS") 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 local value while true; do printf '%b' "$prompt_text" read -r value if [[ -z "$value" ]]; then value="$default" fi # 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 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_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 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" ;; 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 printf '%b Invalid: value cannot be empty%b\n' "$C_RED" "$C_RESET" ;; esac done 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' printf 'Defaults are pulled from .env variables.\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 # 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 INI data COLLECTED_NAMES=() 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" # Existing defaults from previous runners.conf (if available) ex_name="${EXISTING_SECTIONS[$i]:-}" # --- name --- 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" "runner display name in Gitea" "runner_name" "$name_default" r_name="$PROMPT_RESULT" COLLECTED_NAMES+=("$r_name") # --- 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" # 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 path_default="${LOCAL_RUNNER_DATA_PATH:-~/gitea-runner}" else 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" COLLECTED_DATA["${r_name}:data_path"]="$PROMPT_RESULT" # --- 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 value" "nonempty" "$labels_default" COLLECTED_DATA["${r_name}:labels"]="$PROMPT_RESULT" # --- 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 — INI format with header comment block # --------------------------------------------------------------------------- cat > "$RUNNERS_CONF" <<'HEADER' # ============================================================================= # 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. # ============================================================================= HEADER 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 # =========================================================================== 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 %-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 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" printf 'Next step: run %bsetup/macbook.sh%b to install local prerequisites.\n' "$C_BOLD" "$C_RESET"