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 <noreply@anthropic.com>
This commit is contained in:
S
2026-02-28 23:14:46 -05:00
parent fcd966f97d
commit f4a6b04d14
12 changed files with 1000 additions and 329 deletions

View File

@@ -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:-<empty>}, ${user_var}=${user:-<empty>}"
@@ -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
# ---------------------------------------------------------------------------