#!/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 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 } # --------------------------------------------------------------------------- # 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 ip="${!ip_var:-}" local user="${!user_var:-}" local port="${!port_var:-22}" if [[ -z "$ip" ]] || [[ -z "$user" ]]; then log_error "SSH config incomplete for $host_key: ${ip_var}=${ip:-}, ${user_var}=${user:-}" 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) ssh -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 ip="${!ip_var:-}" local user="${!user_var:-}" local port="${!port_var:-22}" if [[ -z "$ip" ]] || [[ -z "$user" ]]; then log_error "SCP config incomplete for $host_key" return 1 fi scp -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" if [[ ! -f "$src" ]]; then log_error "Template not found: $src" return 1 fi envsubst < "$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 }