- lib/common.sh: add require_local_os, require_remote_os, require_remote_pkg_manager - setup/macbook.sh: require macOS (Darwin) - setup/unraid.sh: require remote is Linux - setup/fedora.sh: require remote is Linux + has dnf (RPM-based) - manage_runner.sh: native runner add/remove requires macOS - run_all.sh: control plane must be macOS - preflight.sh: 3 new checks (1: local=macOS, 2: Unraid=Linux, 3: Fedora=Linux+dnf) - phase5_migrate_pipelines.sh: fix sed -i to be portable (no macOS-only syntax) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
355 lines
10 KiB
Bash
355 lines
10 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
|
|
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:-<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)
|
|
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
|
|
}
|