Files
gitea-migration/lib/common.sh
S 7ce562bc1c refactor: update runner var names in env validation arrays
- RUNNER_DEFAULT_DATA_PATH → RUNNER_DATA_BASE_PATH
- LOCAL_RUNNER_DATA_PATH → LOCAL_RUNNER_DATA_BASE_PATH
- Remove RUNNER_DEFAULT_CAPACITY from validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:10:20 -05:00

886 lines
26 KiB
Bash

#!/usr/bin/env bash
# =============================================================================
# lib/common.sh — Shared functions for Gitea Migration Toolkit
# Source this file in every script: source "$(dirname "$0")/lib/common.sh"
# =============================================================================
# This file defines functions only. No side effects at source time.
# ---------------------------------------------------------------------------
# Colors (only if stderr is a terminal)
# ---------------------------------------------------------------------------
if [[ -t 2 ]]; then
_C_RESET='\033[0m'
_C_RED='\033[0;31m'
_C_GREEN='\033[0;32m'
_C_YELLOW='\033[0;33m'
_C_BLUE='\033[0;34m'
_C_BOLD='\033[1m'
else
_C_RESET='' _C_RED='' _C_GREEN='' _C_YELLOW='' _C_BLUE='' _C_BOLD=''
fi
# ---------------------------------------------------------------------------
# Logging — all output to stderr so stdout stays clean for data (JSON, etc.)
# ---------------------------------------------------------------------------
log_info() {
printf '%b[INFO]%b %s\n' "$_C_BLUE" "$_C_RESET" "$*" >&2
}
log_warn() {
printf '%b[WARN]%b %s\n' "$_C_YELLOW" "$_C_RESET" "$*" >&2
}
log_error() {
printf '%b[ERROR]%b %s\n' "$_C_RED" "$_C_RESET" "$*" >&2
}
log_success() {
printf '%b[OK]%b %s\n' "$_C_GREEN" "$_C_RESET" "$*" >&2
}
log_step() {
local step_num="$1"; shift
printf ' %b[%s]%b %s\n' "$_C_BOLD" "$step_num" "$_C_RESET" "$*" >&2
}
phase_header() {
local num="$1" name="$2"
printf '\n%b=== Phase %s: %s ===%b\n\n' "$_C_BOLD" "$num" "$name" "$_C_RESET" >&2
}
# ---------------------------------------------------------------------------
# Environment
# ---------------------------------------------------------------------------
# Resolve the project root (directory containing .env / .env.example)
_project_root() {
local dir
dir="$(cd "$(dirname "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}")/.." && pwd)"
# Walk up until we find .env or .env.example
while [[ "$dir" != "/" ]]; do
if [[ -f "$dir/.env" ]] || [[ -f "$dir/.env.example" ]]; then
printf '%s' "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
# Fallback: script's parent dir
cd "$(dirname "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}")/.." && pwd
}
# Source .env and export all variables.
# Uses set -a/+a to auto-export every variable defined in the file,
# making them available to child processes (envsubst, ssh, etc.).
load_env() {
local env_file
env_file="$(_project_root)/.env"
if [[ ! -f "$env_file" ]]; then
log_error ".env file not found at $env_file"
log_error "Copy .env.example to .env and populate values."
return 1
fi
set -a # auto-export all vars defined below
# shellcheck source=/dev/null
source "$env_file"
set +a # stop auto-exporting
}
save_env_var() {
local key="$1" value="$2"
local env_file
env_file="$(_project_root)/.env"
if [[ ! -f "$env_file" ]]; then
log_error ".env file not found at $env_file"
return 1
fi
# Escape special characters in value for sed (delimiter is |)
local escaped_value
escaped_value=$(printf '%s' "$value" | sed 's/[&/\|]/\\&/g')
if grep -q "^${key}=" "$env_file"; then
# Replace existing line — match KEY= followed by anything (value + optional comment)
sed -i.bak "s|^${key}=.*|${key}=${escaped_value}|" "$env_file"
rm -f "${env_file}.bak"
else
# Append new line
printf '%s=%s\n' "$key" "$value" >> "$env_file"
fi
# Also export it in current shell
export "${key}=${value}"
}
require_vars() {
local var
for var in "$@"; do
if [[ -z "${!var:-}" ]]; then
log_error "Missing required var: $var"
return 1
fi
done
}
# Split REPO_NAMES (space-delimited) into one name per line.
# Usage: while IFS= read -r repo; do ...; done < <(get_repo_list)
# or: read -ra REPOS <<< "$REPO_NAMES" (for array-style usage)
get_repo_list() {
local repos=()
read -ra repos <<< "${REPO_NAMES:-}"
printf '%s\n' "${repos[@]}"
}
# ---------------------------------------------------------------------------
# .env value validators — shared by configure_env.sh, preflight.sh,
# bitwarden_to_env.sh. Each returns 0 (valid) or 1 (invalid).
# ---------------------------------------------------------------------------
validate_ip() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
validate_port() {
[[ "$1" =~ ^[0-9]+$ ]] && [[ "$1" -ge 1 ]] && [[ "$1" -le 65535 ]]
}
validate_email() {
[[ "$1" == *@* ]]
}
validate_path() {
[[ "$1" == /* ]]
}
validate_url() {
[[ "$1" =~ ^https?:// ]]
}
validate_bool() {
[[ "$1" == "true" ]] || [[ "$1" == "false" ]]
}
validate_integer() {
[[ "$1" =~ ^[0-9]+$ ]]
}
validate_nonempty() {
[[ -n "$1" ]]
}
validate_password() {
[[ ${#1} -ge 8 ]]
}
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.
# Requires .env to be loaded first (variables in environment).
# Returns 0 if all pass, 1 if any fail. Does NOT exit — caller decides.
# ---------------------------------------------------------------------------
# Parallel arrays: variable name → validator type (bash 3.2 compatible).
# Order matches configure_env.sh prompt_var calls.
_ENV_VAR_NAMES=(
UNRAID_IP UNRAID_SSH_USER UNRAID_SSH_PORT
UNRAID_GITEA_PORT UNRAID_GITEA_SSH_PORT UNRAID_GITEA_DATA_PATH
FEDORA_IP FEDORA_SSH_USER FEDORA_SSH_PORT
FEDORA_GITEA_PORT FEDORA_GITEA_SSH_PORT FEDORA_GITEA_DATA_PATH
GITEA_ADMIN_USER GITEA_ADMIN_PASSWORD GITEA_ADMIN_EMAIL
GITEA_ORG_NAME GITEA_INSTANCE_NAME GITEA_DB_TYPE GITEA_VERSION ACT_RUNNER_VERSION
GITEA_DOMAIN GITEA_INTERNAL_URL
GITEA_BACKUP_INTERNAL_URL GITEA_BACKUP_MIRROR_INTERVAL
BACKUP_STORAGE_PATH BACKUP_RETENTION_COUNT
RUNNER_DEFAULT_IMAGE RUNNER_DATA_BASE_PATH LOCAL_RUNNER_DATA_BASE_PATH
GITHUB_USERNAME GITHUB_TOKEN
REPO_NAMES
MIGRATE_ISSUES MIGRATE_LABELS MIGRATE_MILESTONES MIGRATE_WIKI
GITHUB_MIRROR_INTERVAL
NGINX_CONTAINER_NAME NGINX_CONF_PATH SSL_MODE
PROTECTED_BRANCH REQUIRE_PR_REVIEW REQUIRED_APPROVALS
SEMGREP_VERSION TRIVY_VERSION GITLEAKS_VERSION SECURITY_FAIL_ON_ERROR
)
_ENV_VAR_TYPES=(
ip nonempty port
port port path
ip nonempty port
port port path
nonempty password email
nonempty nonempty nonempty nonempty nonempty
nonempty url
url nonempty
path integer
nonempty nonempty nonempty
nonempty nonempty
nonempty
bool bool bool bool
nonempty
nonempty path ssl_mode
nonempty bool integer
nonempty nonempty nonempty bool
)
# Conditional variables — validated only when SSL_MODE matches.
_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 /" ;;
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
}
validate_env() {
local errors=0
local i var_name var_type value
# Validate main variables
for ((i = 0; i < ${#_ENV_VAR_NAMES[@]}; i++)); do
var_name="${_ENV_VAR_NAMES[$i]}"
var_type="${_ENV_VAR_TYPES[$i]}"
value="${!var_name:-}"
if [[ -z "$value" ]]; then
log_error "$var_name is empty ($(_validator_hint "$var_type"))"
errors=$((errors + 1))
elif ! "validate_${var_type}" "$value"; then
log_error "$var_name='$value' ($(_validator_hint "$var_type"))"
errors=$((errors + 1))
fi
done
# Validate conditional variables (SSL_MODE-dependent)
local ssl_mode="${SSL_MODE:-}"
for ((i = 0; i < ${#_ENV_CONDITIONAL_NAMES[@]}; i++)); do
var_name="${_ENV_CONDITIONAL_NAMES[$i]}"
var_type="${_ENV_CONDITIONAL_TYPES[$i]}"
local required_when="${_ENV_CONDITIONAL_WHEN[$i]}"
if [[ "$ssl_mode" != "$required_when" ]]; then
continue
fi
value="${!var_name:-}"
if [[ -z "$value" ]]; then
log_error "$var_name is empty (required when SSL_MODE=$required_when, $(_validator_hint "$var_type"))"
errors=$((errors + 1))
elif ! "validate_${var_type}" "$value"; then
log_error "$var_name='$value' ($(_validator_hint "$var_type"))"
errors=$((errors + 1))
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
fi
return 0
}
# ---------------------------------------------------------------------------
# OS compatibility checks
# ---------------------------------------------------------------------------
# Verify the local machine is running the expected OS.
# Usage: require_local_os "Darwin" "This script requires macOS"
# os_type: "Darwin" for macOS, "Linux" for Linux
# Checks `uname -s` against the expected value.
require_local_os() {
local expected="$1" msg="${2:-This script requires ${1}}"
local actual
actual="$(uname -s)"
if [[ "$actual" != "$expected" ]]; then
log_error "$msg"
log_error "Detected OS: $actual (expected: $expected)"
return 1
fi
}
# Verify a remote machine (via SSH) is running the expected OS.
# Usage: require_remote_os "UNRAID" "Linux" "Unraid must be a Linux machine"
# host_key: SSH host key (UNRAID, FEDORA) — uses ssh_exec
# os_type: expected `uname -s` output
require_remote_os() {
local host_key="$1" expected="$2" msg="${3:-Remote host $1 must be running $2}"
local actual
actual="$(ssh_exec "$host_key" "uname -s" 2>/dev/null)" || {
log_error "Cannot determine OS on ${host_key} (SSH failed)"
return 1
}
if [[ "$actual" != "$expected" ]]; then
log_error "$msg"
log_error "${host_key} detected OS: $actual (expected: $expected)"
return 1
fi
}
# Check if a remote host has a specific package manager available.
# Usage: require_remote_pkg_manager "FEDORA" "dnf" "Fedora requires dnf"
require_remote_pkg_manager() {
local host_key="$1" pkg_mgr="$2"
local msg="${3:-${host_key} requires ${pkg_mgr}}"
if ! ssh_exec "$host_key" "command -v $pkg_mgr" &>/dev/null; then
log_error "$msg"
log_error "${host_key} does not have '${pkg_mgr}' — is this the right machine?"
return 1
fi
}
# ---------------------------------------------------------------------------
# SSH
# ---------------------------------------------------------------------------
# Execute a command on a remote host via SSH.
# Uses indirect variable expansion: ssh_exec "UNRAID" "ls" reads
# UNRAID_IP, UNRAID_SSH_USER, UNRAID_SSH_PORT from the environment.
# This pattern avoids passing connection details to every function call.
ssh_exec() {
local host_key="$1"; shift
local cmd="$*"
# Indirect expansion: ${!ip_var} dereferences the variable named by $ip_var
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>}"
return 1
fi
# 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)
# ${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" \
"${user}@${ip}" \
"$cmd"
}
ssh_check() {
local host_key="$1"
ssh_exec "$host_key" "true" 2>/dev/null
}
scp_to() {
local host_key="$1" src="$2" dst="$3"
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 ${key:+-i "$key"} \
-o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \
-P "$port" \
"$src" "${user}@${ip}:${dst}"
}
# ---------------------------------------------------------------------------
# API wrappers — return JSON on stdout, logs go to stderr
# ---------------------------------------------------------------------------
# Internal API call helper. Writes response to a tmpfile so we can separate
# the HTTP status code (via curl -w) from the response body. This ensures
# JSON output goes to stdout and error messages go to stderr — callers can
# pipe JSON through jq without log noise contaminating the output.
_api_call() {
local base_url="$1" token="$2" method="$3" path="$4" data="${5:-}"
local http_code
local tmpfile
tmpfile=$(mktemp)
local -a curl_args=(
-s
-X "$method"
-H "Authorization: token ${token}"
-H "Content-Type: application/json"
-H "Accept: application/json"
-o "$tmpfile"
-w "%{http_code}"
)
if [[ -n "$data" ]]; then
curl_args+=(-d "$data")
fi
http_code=$(curl "${curl_args[@]}" "${base_url}${path}") || {
log_error "curl failed for $method ${base_url}${path}"
rm -f "$tmpfile"
return 1
}
if [[ "$http_code" -ge 400 ]]; then
log_error "API $method ${path} returned HTTP ${http_code}"
log_error "Response: $(cat "$tmpfile")"
rm -f "$tmpfile"
return 1
fi
cat "$tmpfile"
rm -f "$tmpfile"
}
gitea_api() {
local method="$1" path="$2" data="${3:-}"
_api_call "${GITEA_INTERNAL_URL}/api/v1" "${GITEA_ADMIN_TOKEN}" "$method" "$path" "$data"
}
gitea_backup_api() {
local method="$1" path="$2" data="${3:-}"
_api_call "${GITEA_BACKUP_INTERNAL_URL}/api/v1" "${GITEA_BACKUP_ADMIN_TOKEN}" "$method" "$path" "$data"
}
github_api() {
local method="$1" path="$2" data="${3:-}"
_api_call "https://api.github.com" "${GITHUB_TOKEN}" "$method" "$path" "$data"
}
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
render_template() {
local src="$1" dest="$2" vars="${3:-}"
if [[ ! -f "$src" ]]; then
log_error "Template not found: $src"
return 1
fi
if [[ -z "$vars" ]]; then
log_error "render_template requires an explicit variable list (third argument)"
log_error "Example: render_template src dest '\${VAR1} \${VAR2}'"
return 1
fi
envsubst "$vars" < "$src" > "$dest"
}
# ---------------------------------------------------------------------------
# Polling / waiting
# ---------------------------------------------------------------------------
wait_for_http() {
local url="$1" max_secs="${2:-60}"
local elapsed=0
log_info "Waiting for HTTP 200 at ${url} (timeout: ${max_secs}s)..."
while [[ $elapsed -lt $max_secs ]]; do
if curl -sf -o /dev/null "$url" 2>/dev/null; then
log_success "HTTP 200 at ${url} (after ${elapsed}s)"
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
log_error "Timeout waiting for ${url} after ${max_secs}s"
return 1
}
wait_for_ssh() {
local host_key="$1" max_secs="${2:-60}"
local elapsed=0
log_info "Waiting for SSH to ${host_key} (timeout: ${max_secs}s)..."
while [[ $elapsed -lt $max_secs ]]; do
if ssh_check "$host_key"; then
log_success "SSH to ${host_key} connected (after ${elapsed}s)"
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
log_error "Timeout waiting for SSH to ${host_key} after ${max_secs}s"
return 1
}
# ---------------------------------------------------------------------------
# Version checking
# ---------------------------------------------------------------------------
# Compare two semver-like version strings (major.minor or major.minor.patch).
# Returns 0 if $1 >= $2, 1 otherwise.
# Works by comparing each numeric component left to right.
_version_gte() {
local ver="$1" min="$2"
# Split on dots into arrays
local IFS='.'
# shellcheck disable=SC2206
local -a v=($ver)
# shellcheck disable=SC2206
local -a m=($min)
local max_parts=${#m[@]}
local i
for ((i = 0; i < max_parts; i++)); do
local vp="${v[$i]:-0}"
local mp="${m[$i]:-0}"
if (( vp > mp )); then return 0; fi
if (( vp < mp )); then return 1; fi
done
return 0
}
# Extract the first semver-like string (X.Y or X.Y.Z) from arbitrary output.
# Handles common patterns like "jq-1.7.1", "Docker version 24.0.7", "v2.29.1", etc.
_extract_version() {
local raw="$1"
# Match the first occurrence of digits.digits (optionally .digits more)
if [[ "$raw" =~ ([0-9]+\.[0-9]+(\.[0-9]+)*) ]]; then
printf '%s' "${BASH_REMATCH[1]}"
else
printf ''
fi
}
# Check that a local command meets a minimum version.
# Usage: check_min_version "docker" "docker --version" "20.0"
# tool_name: display name for log messages
# version_cmd: command to run (must output version somewhere in its output)
# min_version: minimum required version (e.g. "1.6", "20.0.0")
# Returns 0 if version >= min, 1 otherwise.
check_min_version() {
local tool_name="$1" version_cmd="$2" min_version="$3"
local raw_output
raw_output=$(eval "$version_cmd" 2>&1) || {
log_error "$tool_name: failed to run '$version_cmd'"
return 1
}
local actual
actual=$(_extract_version "$raw_output")
if [[ -z "$actual" ]]; then
log_error "$tool_name: could not parse version from: $raw_output"
return 1
fi
if _version_gte "$actual" "$min_version"; then
log_success "$tool_name $actual (>= $min_version)"
return 0
else
log_error "$tool_name $actual is below minimum $min_version"
return 1
fi
}
# Same as check_min_version but runs the command on a remote host via SSH.
# Usage: check_remote_min_version "UNRAID" "docker" "docker --version" "20.0"
check_remote_min_version() {
local host_key="$1" tool_name="$2" version_cmd="$3" min_version="$4"
local raw_output
raw_output=$(ssh_exec "$host_key" "$version_cmd" 2>&1) || {
log_error "$tool_name on ${host_key}: failed to run '$version_cmd'"
return 1
}
local actual
actual=$(_extract_version "$raw_output")
if [[ -z "$actual" ]]; then
log_error "$tool_name on ${host_key}: could not parse version from: $raw_output"
return 1
fi
if _version_gte "$actual" "$min_version"; then
log_success "$tool_name $actual on ${host_key} (>= $min_version)"
return 0
else
log_error "$tool_name $actual on ${host_key} is below minimum $min_version"
return 1
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
# ---------------------------------------------------------------------------
# Manifest files live in $PROJECT_ROOT/.manifests/<host>.manifest
# Each line: TYPE|TARGET|DETAILS
# Types:
# brew_pkg — Homebrew package on macOS (TARGET=package_name)
# dnf_pkg — DNF package on Fedora (TARGET=package_name)
# static_bin — Static binary installed to a path (TARGET=path)
# docker_group — User added to docker group (TARGET=username)
# systemd_svc — Systemd service enabled (TARGET=service_name)
# xcode_cli — Xcode CLI Tools installed (TARGET=xcode-select)
# directory — Directory created (TARGET=path)
_manifest_dir() {
printf '%s/.manifests' "$(_project_root)"
}
# Initialize manifest directory and file for a host.
# Usage: manifest_init "macbook"
manifest_init() {
local host="$1"
local dir
dir="$(_manifest_dir)"
mkdir -p "$dir"
local file="${dir}/${host}.manifest"
if [[ ! -f "$file" ]]; then
printf '# Install manifest for %s — created %s\n' "$host" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$file"
fi
}
# Record an action in the manifest. Skips duplicates.
# Usage: manifest_record "macbook" "brew_pkg" "jq"
# manifest_record "unraid" "static_bin" "/usr/local/bin/jq"
manifest_record() {
local host="$1" action_type="$2" target="$3" details="${4:-}"
local file
file="$(_manifest_dir)/${host}.manifest"
# Ensure manifest file exists
manifest_init "$host"
local entry="${action_type}|${target}|${details}"
# Skip if already recorded
if grep -qF "$entry" "$file" 2>/dev/null; then
return 0
fi
printf '%s\n' "$entry" >> "$file"
}
# Check if a manifest file exists and has entries (beyond the header).
# Usage: manifest_exists "macbook"
manifest_exists() {
local host="$1"
local file
file="$(_manifest_dir)/${host}.manifest"
[[ -f "$file" ]] && grep -qv '^#' "$file" 2>/dev/null
}
# Read all entries from a manifest (skipping comments).
# Usage: manifest_entries "macbook"
# Outputs lines to stdout: TYPE|TARGET|DETAILS
manifest_entries() {
local host="$1"
local file
file="$(_manifest_dir)/${host}.manifest"
if [[ ! -f "$file" ]]; then
return 0
fi
grep -v '^#' "$file" | grep -v '^$' || true
}
# Remove the manifest file for a host (after successful cleanup).
# Usage: manifest_clear "macbook"
manifest_clear() {
local host="$1"
local file
file="$(_manifest_dir)/${host}.manifest"
rm -f "$file"
}