get_repo_list() — never called, scripts use read -ra directly wait_for_ssh() — never called, scripts use ssh_check validate_optional() — never called, optional type unused in arrays manifest_exists() — never called Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
924 lines
29 KiB
Bash
924 lines
29 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
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# .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_tls_mode() {
|
|
[[ "$1" == "cloudflare" ]] || [[ "$1" == "existing" ]]
|
|
}
|
|
|
|
validate_db_type() {
|
|
[[ "$1" == "sqlite3" ]] || [[ "$1" == "mysql" ]] || [[ "$1" == "postgres" ]] || [[ "$1" == "mssql" ]]
|
|
}
|
|
|
|
validate_positive_integer() {
|
|
[[ "$1" =~ ^[1-9][0-9]*$ ]]
|
|
}
|
|
|
|
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_DATA_PATH
|
|
FEDORA_IP FEDORA_SSH_USER FEDORA_SSH_PORT FEDORA_GITEA_DATA_PATH
|
|
UNRAID_MACVLAN_PARENT UNRAID_MACVLAN_SUBNET UNRAID_MACVLAN_GATEWAY
|
|
UNRAID_MACVLAN_IP_RANGE UNRAID_GITEA_IP UNRAID_CADDY_IP
|
|
FEDORA_MACVLAN_PARENT FEDORA_MACVLAN_SUBNET FEDORA_MACVLAN_GATEWAY
|
|
FEDORA_MACVLAN_IP_RANGE FEDORA_GITEA_IP
|
|
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
|
|
TLS_MODE CADDY_DOMAIN CADDY_DATA_PATH
|
|
PROTECTED_BRANCH REQUIRE_PR_REVIEW REQUIRED_APPROVALS
|
|
SEMGREP_VERSION TRIVY_VERSION GITLEAKS_VERSION SECURITY_FAIL_ON_ERROR
|
|
)
|
|
|
|
_ENV_VAR_TYPES=(
|
|
ip nonempty port path
|
|
ip nonempty port path
|
|
nonempty nonempty ip
|
|
nonempty ip ip
|
|
nonempty nonempty ip
|
|
nonempty ip
|
|
nonempty password email
|
|
nonempty nonempty db_type nonempty nonempty
|
|
nonempty url
|
|
url nonempty
|
|
path integer
|
|
nonempty nonempty nonempty
|
|
nonempty nonempty
|
|
nonempty
|
|
bool bool bool bool
|
|
nonempty
|
|
tls_mode nonempty path
|
|
nonempty bool integer
|
|
nonempty nonempty nonempty bool
|
|
)
|
|
|
|
# Conditional variables — validated only when TLS_MODE matches.
|
|
_ENV_CONDITIONAL_TLS_NAMES=(CLOUDFLARE_API_TOKEN SSL_CERT_PATH SSL_KEY_PATH)
|
|
_ENV_CONDITIONAL_TLS_TYPES=(nonempty path path)
|
|
_ENV_CONDITIONAL_TLS_WHEN=( cloudflare existing existing)
|
|
|
|
# Conditional variables — validated only when GITEA_DB_TYPE is NOT sqlite3.
|
|
_ENV_CONDITIONAL_DB_NAMES=(GITEA_DB_HOST GITEA_DB_PORT GITEA_DB_NAME GITEA_DB_USER GITEA_DB_PASSWD)
|
|
_ENV_CONDITIONAL_DB_TYPES=(nonempty port nonempty nonempty password)
|
|
|
|
# Optional variables — validated only when non-empty (never required).
|
|
_ENV_OPTIONAL_NAMES=(UNRAID_SSH_KEY FEDORA_SSH_KEY LOCAL_REGISTRY UNRAID_DB_IP FEDORA_DB_IP)
|
|
_ENV_OPTIONAL_TYPES=(optional_path optional_path nonempty ip ip)
|
|
|
|
# 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" ;;
|
|
tls_mode) echo "must be cloudflare or existing" ;;
|
|
db_type) echo "must be sqlite3, mysql, postgres, or mssql" ;;
|
|
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 (TLS_MODE-dependent)
|
|
local tls_mode="${TLS_MODE:-}"
|
|
for ((i = 0; i < ${#_ENV_CONDITIONAL_TLS_NAMES[@]}; i++)); do
|
|
var_name="${_ENV_CONDITIONAL_TLS_NAMES[$i]}"
|
|
var_type="${_ENV_CONDITIONAL_TLS_TYPES[$i]}"
|
|
local required_when="${_ENV_CONDITIONAL_TLS_WHEN[$i]}"
|
|
|
|
if [[ "$tls_mode" != "$required_when" ]]; then
|
|
continue
|
|
fi
|
|
|
|
value="${!var_name:-}"
|
|
if [[ -z "$value" ]]; then
|
|
log_error " → $var_name is empty (required when TLS_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 conditional variables (DB_TYPE-dependent — required when NOT sqlite3)
|
|
local db_type="${GITEA_DB_TYPE:-sqlite3}"
|
|
if [[ "$db_type" != "sqlite3" ]]; then
|
|
for ((i = 0; i < ${#_ENV_CONDITIONAL_DB_NAMES[@]}; i++)); do
|
|
var_name="${_ENV_CONDITIONAL_DB_NAMES[$i]}"
|
|
var_type="${_ENV_CONDITIONAL_DB_TYPES[$i]}"
|
|
value="${!var_name:-}"
|
|
|
|
if [[ -z "$value" ]]; then
|
|
log_error " → $var_name is empty (required when GITEA_DB_TYPE=$db_type, $(_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
|
|
fi
|
|
|
|
# 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"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Template block helpers — conditional block stripping and DB-specific vars
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Strip conditional blocks from a rendered file.
|
|
# Usage: strip_template_block <file> <start_marker> <end_marker>
|
|
strip_template_block() {
|
|
local file="$1" start="$2" end="$3"
|
|
sed -i.bak "/${start}/,/${end}/d" "$file"
|
|
rm -f "${file}.bak"
|
|
}
|
|
|
|
# Set DB-specific variables for docker-compose template rendering.
|
|
# Requires GITEA_DB_TYPE, GITEA_DB_USER, GITEA_DB_PASSWD, GITEA_DB_NAME in env.
|
|
# Exports: DB_DOCKER_IMAGE, DB_ENV_VARS, DB_HEALTHCHECK, DB_DATA_DIR
|
|
set_db_vars() {
|
|
case "${GITEA_DB_TYPE}" in
|
|
postgres)
|
|
DB_DOCKER_IMAGE="postgres:16-alpine"
|
|
DB_ENV_VARS=" - POSTGRES_USER=${GITEA_DB_USER}
|
|
- POSTGRES_PASSWORD=${GITEA_DB_PASSWD}
|
|
- POSTGRES_DB=${GITEA_DB_NAME}"
|
|
DB_HEALTHCHECK='["CMD-SHELL", "pg_isready -U '"${GITEA_DB_USER}"'"]'
|
|
DB_DATA_DIR="postgresql/data"
|
|
;;
|
|
mysql)
|
|
DB_DOCKER_IMAGE="mysql:8.0"
|
|
DB_ENV_VARS=" - MYSQL_ROOT_PASSWORD=${GITEA_DB_PASSWD}
|
|
- MYSQL_DATABASE=${GITEA_DB_NAME}
|
|
- MYSQL_USER=${GITEA_DB_USER}
|
|
- MYSQL_PASSWORD=${GITEA_DB_PASSWD}"
|
|
DB_HEALTHCHECK='["CMD", "mysqladmin", "ping", "-h", "localhost"]'
|
|
DB_DATA_DIR="mysql"
|
|
;;
|
|
mssql)
|
|
DB_DOCKER_IMAGE="mcr.microsoft.com/mssql/server:2022-latest"
|
|
DB_ENV_VARS=" - ACCEPT_EULA=Y
|
|
- SA_PASSWORD=${GITEA_DB_PASSWD}"
|
|
# shellcheck disable=SC2089,SC2016
|
|
DB_HEALTHCHECK='["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '"'"'${GITEA_DB_PASSWD}'"'"' -Q \"SELECT 1\" -C -N"]'
|
|
DB_DATA_DIR="mssql/data"
|
|
;;
|
|
esac
|
|
# shellcheck disable=SC2090
|
|
export DB_DOCKER_IMAGE DB_ENV_VARS DB_HEALTHCHECK DB_DATA_DIR
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"
|
|
}
|
|
|
|
# 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"
|
|
}
|