Files
gitea-migration/setup/configure_runners.sh

612 lines
23 KiB
Bash
Executable File

#!/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.
# Persists answers to runners.conf as each prompt is completed.
# 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, 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.
# ---------------------------------------------------------------------------
DEFAULTS_CONF=""
cleanup_defaults_conf() {
if [[ -n "$DEFAULTS_CONF" ]] && [[ -f "$DEFAULTS_CONF" ]]; then
rm -f "$DEFAULTS_CONF"
fi
}
trap cleanup_defaults_conf EXIT
EXISTING_SECTIONS=()
if [[ -f "$RUNNERS_CONF" ]]; then
# Keep a snapshot for defaults so we can rewrite runners.conf incrementally
# without losing pre-filled values when the wizard is re-run.
DEFAULTS_CONF=$(mktemp)
cp "$RUNNERS_CONF" "$DEFAULTS_CONF"
while IFS= read -r sec; do
[[ -z "$sec" ]] && continue
EXISTING_SECTIONS+=("$sec")
done < <(ini_list_sections "$DEFAULTS_CONF")
fi
EXISTING_COUNT=${#EXISTING_SECTIONS[@]}
ini_default_get() {
local section="$1" key="$2" default="${3:-}"
if [[ -z "$DEFAULTS_CONF" ]]; then
printf '%s' "$default"
return 0
fi
ini_get "$DEFAULTS_CONF" "$section" "$key" "$default"
}
# ---------------------------------------------------------------------------
# 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_host_type_combo() {
local host="$1" runner_type="$2"
if [[ "$host" == "local" ]]; then
[[ "$runner_type" == "native" ]]
else
[[ "$runner_type" == "docker" ]]
fi
}
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
# Accept comma-separated list of repo names (e.g. "augur,periodvault")
local _input _rn _found _item
IFS=',' read -ra _input <<< "$1"
for _item in "${_input[@]}"; do
_item="${_item## }"; _item="${_item%% }" # trim spaces
[[ -z "$_item" ]] && return 1
_found=0
for _rn in ${REPO_NAMES:-}; do
if [[ "$_item" == "$_rn" ]]; then _found=1; break; fi
done
[[ $_found -eq 0 ]] && return 1
done
return 0
}
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 comma-separated repo names from: %s%b\n' "$C_RED" "${REPO_NAMES:-<no repos in .env>}" "$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"
;;
bool)
if [[ "$value" == "true" || "$value" == "false" ]]; then break; fi
printf '%b Invalid: must be "true" or "false"%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"
}
init_runners_conf() {
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
}
ensure_runner_section() {
local section="$1"
if ! grep -Fqx "[$section]" "$RUNNERS_CONF" 2>/dev/null; then
printf '[%s]\n\n' "$section" >> "$RUNNERS_CONF"
fi
}
save_runner_field() {
local section="$1" key="$2" value="$3"
ini_set "$RUNNERS_CONF" "$section" "$key" "$value"
_cdata_set "${section}:${key}" "$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) + boot(1) = 11
# Custom host adds: ssh_host(1) + ssh_user(1) + ssh_port(1) + ssh_key(1) = 4
# We estimate max 11 per runner for progress display
TOTAL_PROMPTS=$((runner_count * 11))
# ---------------------------------------------------------------------------
# Configure runner definitions
# ---------------------------------------------------------------------------
# Keep arrays for end-of-run summary; values are persisted immediately.
COLLECTED_NAMES=()
# Bash 3.2 compatible key-value store (no associative arrays)
_CDATA_KEYS=()
_CDATA_VALS=()
_cdata_set() {
local k="$1" v="$2" i
for i in "${!_CDATA_KEYS[@]}"; do
if [[ "${_CDATA_KEYS[$i]}" == "$k" ]]; then
_CDATA_VALS[i]="$v"
return 0
fi
done
_CDATA_KEYS+=("$k")
_CDATA_VALS+=("$v")
}
_cdata_get() {
local k="$1" default="${2:-}" i
for i in "${!_CDATA_KEYS[@]}"; do
if [[ "${_CDATA_KEYS[$i]}" == "$k" ]]; then
printf '%s' "${_CDATA_VALS[$i]}"
return 0
fi
done
printf '%s' "$default"
}
init_runners_conf
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")
ensure_runner_section "$r_name"
# --- host ---
host_default=""
if [[ -n "$ex_name" ]]; then
host_default=$(ini_default_get "$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"
save_runner_field "$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:-<not set>}" "${UNRAID_IP:-<not set>}" "${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:-<not set>}" "${FEDORA_IP:-<not set>}" "${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_default_get "$ex_name" "ssh_host" "")
ex_ssh_user=$(ini_default_get "$ex_name" "ssh_user" "")
ex_ssh_port=$(ini_default_get "$ex_name" "ssh_port" "22")
ex_ssh_key=$(ini_default_get "$ex_name" "ssh_key" "")
fi
prompt_field "ssh_host" "IP address of remote host" "ip" "$ex_ssh_host"
save_runner_field "$r_name" "ssh_host" "$PROMPT_RESULT"
prompt_field "ssh_user" "SSH username" "nonempty" "${ex_ssh_user:-root}"
save_runner_field "$r_name" "ssh_user" "$PROMPT_RESULT"
prompt_field "ssh_port" "SSH port" "port" "${ex_ssh_port:-22}"
save_runner_field "$r_name" "ssh_port" "$PROMPT_RESULT"
prompt_field "ssh_key" "path to SSH key (empty = use default keys)" "optional_path" "$ex_ssh_key" "true"
save_runner_field "$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_default_get "$ex_name" "type" "")
fi
if [[ -z "$type_default" ]]; then
if [[ "$r_host" == "local" ]]; then type_default="native"; else type_default="docker"; fi
fi
if [[ "$r_host" == "local" ]]; then
type_hint="native only for host=local"
else
type_hint="docker only for host=unraid/fedora/custom"
fi
prompt_field "type" "$type_hint" "runner_type" "$type_default"
r_type="$PROMPT_RESULT"
while ! validate_runner_host_type_combo "$r_host" "$r_type"; do
if [[ "$r_host" == "local" ]]; then
printf '%b Invalid combo: host=local requires type=native%b\n' "$C_RED" "$C_RESET"
prompt_field "type" "native only for host=local" "runner_type" "native"
else
printf '%b Invalid combo: host=%s requires type=docker%b\n' "$C_RED" "$r_host" "$C_RESET"
prompt_field "type" "docker only for host=unraid/fedora/custom" "runner_type" "docker"
fi
r_type="$PROMPT_RESULT"
done
save_runner_field "$r_name" "type" "$r_type"
# --- data_path ---
path_default=""
if [[ -n "$ex_name" ]]; then
path_default=$(ini_default_get "$ex_name" "data_path" "")
fi
if [[ -z "$path_default" ]]; then
if [[ "$r_type" == "native" ]]; then
# shellcheck disable=SC2088 # tilde intentionally stored as literal (expanded at runtime)
path_default="~/gitea-runner/${r_name}"
elif [[ "$r_host" == "fedora" ]]; then
path_default="${FEDORA_GITEA_DATA_PATH:-/mnt/nvme/gitea}/runner/${r_name}"
else
path_default="${UNRAID_GITEA_DATA_PATH:-/mnt/nvme/gitea}/runner/${r_name}"
fi
fi
prompt_field "data_path" "absolute path for runner data" "runner_path" "$path_default"
save_runner_field "$r_name" "data_path" "$PROMPT_RESULT"
# --- labels ---
labels_default=""
if [[ -n "$ex_name" ]]; then
labels_default=$(ini_default_get "$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"
save_runner_field "$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_default_get "$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"
save_runner_field "$r_name" "default_image" "$PROMPT_RESULT"
else
save_runner_field "$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_default_get "$ex_name" "repos" "all")
fi
if [[ -z "$repos_default" ]]; then repos_default="all"; fi
# "all" = instance-level. Comma-separated repo names = one runner entry per repo.
prompt_field "repos" "\"all\" or comma-separated repo names (e.g. augur,periodvault)" "runner_repos" "$repos_default"
save_runner_field "$r_name" "repos" "$PROMPT_RESULT"
# --- capacity ---
cap_default=""
if [[ -n "$ex_name" ]]; then
cap_default=$(ini_default_get "$ex_name" "capacity" "")
fi
if [[ -z "$cap_default" ]]; then
# Smart defaults per host type
case "$r_host" in
unraid) cap_default=2 ;;
fedora) cap_default=2 ;;
*) cap_default=1 ;; # macOS / native
esac
fi
prompt_field "capacity" "max concurrent jobs (>= 1)" "capacity" "$cap_default"
save_runner_field "$r_name" "capacity" "$PROMPT_RESULT"
# --- cpu (skip for native) ---
if [[ "$r_type" == "docker" ]]; then
cpu_default=""
if [[ -n "$ex_name" ]]; then
cpu_default=$(ini_default_get "$ex_name" "cpu" "")
fi
prompt_field "cpu" "Docker CPU limit (e.g. 2.0, empty = no limit)" "docker_cpu" "$cpu_default" "true"
save_runner_field "$r_name" "cpu" "$PROMPT_RESULT"
else
save_runner_field "$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_default_get "$ex_name" "memory" "")
fi
prompt_field "memory" "Docker memory limit (e.g. 2g, empty = no limit)" "docker_memory" "$mem_default" "true"
save_runner_field "$r_name" "memory" "$PROMPT_RESULT"
else
save_runner_field "$r_name" "memory" ""
CURRENT_PROMPT=$((CURRENT_PROMPT + 1))
fi
# --- boot (skip for docker — only applies to native macOS runners) ---
# boot=true installs the launchd plist to /Library/LaunchDaemons/ (starts at
# boot before login, requires sudo). boot=false installs to ~/Library/LaunchAgents/
# (starts at login, no sudo needed).
if [[ "$r_type" == "native" ]]; then
boot_default=""
if [[ -n "$ex_name" ]]; then
boot_default=$(ini_default_get "$ex_name" "boot" "false")
fi
if [[ -z "$boot_default" ]]; then boot_default="false"; fi
prompt_field "boot" "start at boot (before login)? requires sudo [true/false]" "bool" "$boot_default"
save_runner_field "$r_name" "boot" "$PROMPT_RESULT"
else
save_runner_field "$r_name" "boot" ""
CURRENT_PROMPT=$((CURRENT_PROMPT + 1))
fi
done
# ===========================================================================
# Expand multi-repo runners into one section per repo.
# A single act_runner process can only register to one repo, so "repos=a,b"
# becomes two INI sections: [name-a] repos=a and [name-b] repos=b.
# ===========================================================================
EXPANDED_NAMES=()
_orig_path=""
for r_name in "${COLLECTED_NAMES[@]}"; do
r_repos="$(_cdata_get "${r_name}:repos")"
if [[ "$r_repos" == "all" ]] || [[ "$r_repos" != *,* ]]; then
EXPANDED_NAMES+=("$r_name")
continue
fi
# Multi-repo: split and create per-repo sections
IFS=',' read -ra _repo_list <<< "$r_repos"
for _repo in "${_repo_list[@]}"; do
_repo="${_repo## }"; _repo="${_repo%% }" # trim spaces
_expanded_name="${r_name}-${_repo}"
# Copy all fields from the original section, override repos and name
ini_copy_section "$RUNNERS_CONF" "$r_name" "$_expanded_name"
ini_set "$RUNNERS_CONF" "$_expanded_name" "repos" "$_repo"
# Update data_path to avoid collisions
_orig_path=$(ini_get "$RUNNERS_CONF" "$r_name" "data_path" "")
if [[ -n "$_orig_path" ]]; then
ini_set "$RUNNERS_CONF" "$_expanded_name" "data_path" "${_orig_path%/}-${_repo}"
fi
# Track for summary
_cdata_set "${_expanded_name}:host" "$(_cdata_get "${r_name}:host")"
_cdata_set "${_expanded_name}:type" "$(_cdata_get "${r_name}:type")"
_cdata_set "${_expanded_name}:boot" "$(_cdata_get "${r_name}:boot")"
_cdata_set "${_expanded_name}:labels" "$(_cdata_get "${r_name}:labels")"
_cdata_set "${_expanded_name}:capacity" "$(_cdata_get "${r_name}:capacity")"
_cdata_set "${_expanded_name}:repos" "$_repo"
_cdata_set "${_expanded_name}:data_path" "${_orig_path%/}-${_repo}"
EXPANDED_NAMES+=("$_expanded_name")
done
# Remove the original multi-repo section
ini_remove_section "$RUNNERS_CONF" "$r_name"
done
COLLECTED_NAMES=("${EXPANDED_NAMES[@]}")
# ===========================================================================
# 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 %-6s %-10s %-6s %-10s %-24s\n' "NAME" "HOST" "TYPE" "BOOT" "LABELS" "CAP" "REPOS" "DATA PATH"
printf '%-20s %-10s %-8s %-6s %-10s %-6s %-10s %-24s\n' "----" "----" "----" "----" "------" "---" "-----" "---------"
for r_name in "${COLLECTED_NAMES[@]}"; do
printf '%-20s %-10s %-8s %-6s %-10s %-6s %-10s %-24s\n' \
"$r_name" \
"$(_cdata_get "${r_name}:host")" \
"$(_cdata_get "${r_name}:type")" \
"$(_cdata_get "${r_name}:boot" "—")" \
"$(_cdata_get "${r_name}:labels")" \
"$(_cdata_get "${r_name}:capacity")" \
"$(_cdata_get "${r_name}:repos")" \
"$(_cdata_get "${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"