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:
206
lib/common.sh
206
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:-<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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user