# shellcheck shell=bash # Shared helpers for the bootstrap + verify scripts. Source from each # script ; never run directly. # # . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" # # Conventions : # * All functions log to stderr ; stdout is reserved for return values. # * Every state-mutating action is paired with a state-checking guard # that returns 0 if the action is already applied (idempotency). # * Failures call `die` which exits non-zero with a hint. # * Phase markers `>>>PHASE::<<<` are emitted on stdout # so a parent script (bootstrap-local.sh streaming bootstrap-remote.sh # over SSH) can grep + parse the progression. # ----- ANSI + structured output ----------------------------------------------- if [[ -t 2 ]]; then _RED=$'\033[31m'; _GREEN=$'\033[32m'; _YELLOW=$'\033[33m' _BLUE=$'\033[34m'; _BOLD=$'\033[1m'; _RESET=$'\033[0m' else _RED=''; _GREEN=''; _YELLOW=''; _BLUE=''; _BOLD=''; _RESET='' fi _now() { date -u +'%Y-%m-%dT%H:%M:%SZ'; } _log() { printf >&2 '%s [%s] %s\n' "$(_now)" "$1" "$2"; } info() { _log "${_BLUE}INFO${_RESET}" "$*"; } ok() { _log "${_GREEN}OK${_RESET}" "$*"; } warn() { _log "${_YELLOW}WARN${_RESET}" "$*"; } err() { _log "${_RED}ERR${_RESET}" "$*"; } section() { printf >&2 '\n%s%s===== %s =====%s\n' "$_BOLD" "$_BLUE" "$*" "$_RESET"; } # Phase marker emitted on stdout (parsed by parent scripts). phase() { printf '>>>PHASE:%s:%s<<<\n' "$1" "$2"; } # Hard fail with hint. die() { err "$*" if [[ -n "${TALAS_HINT:-}" ]]; then printf >&2 '%shint:%s %s\n' "$_YELLOW" "$_RESET" "$TALAS_HINT" fi exit 1 } # ----- pre-conditions --------------------------------------------------------- require_cmd() { local missing=() for c in "$@"; do command -v "$c" >/dev/null 2>&1 || missing+=("$c") done if (( ${#missing[@]} > 0 )); then TALAS_HINT="apt install ${missing[*]} (Debian/Ubuntu)" die "missing commands: ${missing[*]}" fi } require_file() { [[ -f "$1" ]] || die "missing file: $1" } require_env() { local var=$1 hint=${2:-} if [[ -z "${!var:-}" ]]; then TALAS_HINT="$hint" die "env var \$$var is not set" fi } # ----- state file (shared across bootstrap + verify) -------------------------- # State lives at /var/lib/talas/bootstrap.state on each host. One key=value # line per phase. mark_done is idempotent ; phase_done returns 0 if marked. : "${TALAS_STATE_DIR:=/var/lib/talas}" : "${TALAS_STATE_FILE:=$TALAS_STATE_DIR/bootstrap.state}" ensure_state_dir() { if [[ ! -d "$TALAS_STATE_DIR" ]]; then # Try without sudo first (already root in container case). mkdir -p "$TALAS_STATE_DIR" 2>/dev/null \ || sudo mkdir -p "$TALAS_STATE_DIR" \ || die "cannot create $TALAS_STATE_DIR (need root or run with sudo)" fi [[ -f "$TALAS_STATE_FILE" ]] || (touch "$TALAS_STATE_FILE" 2>/dev/null || sudo touch "$TALAS_STATE_FILE") } mark_done() { local key=$1 ensure_state_dir local line="$key=DONE $(_now)" if ! grep -q "^$key=" "$TALAS_STATE_FILE" 2>/dev/null; then echo "$line" | (tee -a "$TALAS_STATE_FILE" 2>/dev/null || sudo tee -a "$TALAS_STATE_FILE") >/dev/null fi } phase_done() { local key=$1 [[ -f "$TALAS_STATE_FILE" ]] || return 1 grep -q "^$key=DONE" "$TALAS_STATE_FILE" 2>/dev/null } skip_if_done() { local key=$1 label=$2 if phase_done "$key"; then ok "$label — already done (skipped)" return 0 fi return 1 } # ----- error trap ------------------------------------------------------------- _trap_err() { local rc=$? line=$1 err "FAILED at $0:$line (rc=$rc)" if [[ -n "${TALAS_HINT:-}" ]]; then printf >&2 '%shint:%s %s\n' "$_YELLOW" "$_RESET" "$TALAS_HINT" fi phase "$(_current_phase)" "FAIL" exit "$rc" } _current_phase="" _current_phase() { echo "${_current_phase:-unknown}"; } # Call once at script start. trap_errors() { set -Eeuo pipefail trap '_trap_err $LINENO' ERR } # ----- prompts (interactive only) --------------------------------------------- prompt_password() { local var=$1 question=${2:-"value (input hidden):"} local v="" while [[ -z "$v" ]]; do printf >&2 '%s ' "$question" IFS= read -rs v printf >&2 '\n' [[ -z "$v" ]] && warn "empty — try again" done eval "$var=\$v" } prompt_value() { local var=$1 question=${2:-"value:"} default=${3:-} local v="" if [[ -n "$default" ]]; then printf >&2 '%s [%s] ' "$question" "$default" else printf >&2 '%s ' "$question" fi IFS= read -r v [[ -z "$v" && -n "$default" ]] && v="$default" eval "$var=\$v" } # ----- Forgejo API helper ----------------------------------------------------- # Requires: $FORGEJO_API_URL, $FORGEJO_ADMIN_TOKEN # Honours $FORGEJO_INSECURE=1 to disable TLS verification (useful on # first-run, before Let's Encrypt has issued the cert for # forgejo.talas.group and the LAN URL https://10.0.20.105:3000 is # self-signed). forgejo_api() { local method=$1 path=$2; shift 2 local insecure=() [[ "${FORGEJO_INSECURE:-0}" == "1" ]] && insecure=(-k) curl -fsSL "${insecure[@]}" --max-time 30 \ -X "$method" \ -H "Authorization: token ${FORGEJO_ADMIN_TOKEN:?FORGEJO_ADMIN_TOKEN unset}" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ "$FORGEJO_API_URL/api/v1$path" "$@" } forgejo_set_secret() { local owner=$1 repo=$2 name=$3 value=$4 local body body=$(jq -nc --arg v "$value" '{data: $v}') if forgejo_api PUT "/repos/$owner/$repo/actions/secrets/$name" --data "$body" >/dev/null 2>&1; then ok "secret $name set" else die "failed to set secret $name (token scope ? repo path ?)" fi } forgejo_set_var() { local owner=$1 repo=$2 name=$3 value=$4 # Forgejo API quirks (verified empirically against 1.21+ Gitea-fork) : # * POST /actions/variables/ body {name, value} → 204 create # * PUT /actions/variables/ body {name, value} → 204 update # * POST /actions/variables (no in URL) → 405 # Both the URL path AND the body's "name" field are required even # though they're redundant — the Forgejo validator rejects body # without "name". The stored field is "data" on read, but on write # we send "value". local body body=$(jq -nc --arg n "$name" --arg v "$value" '{name: $n, value: $v}') if forgejo_api PUT "/repos/$owner/$repo/actions/variables/$name" --data "$body" >/dev/null 2>&1; then ok "variable $name updated" elif forgejo_api POST "/repos/$owner/$repo/actions/variables/$name" --data "$body" >/dev/null 2>&1; then ok "variable $name created" else die "failed to set variable $name (URL: $FORGEJO_API_URL/api/v1/repos/$owner/$repo/actions/variables/$name)" fi } # Try to fetch a per-repo runner registration token. Returns the token # on stdout if successful ; returns empty + non-zero if the endpoint # hangs / 404s / requires a higher scope. Caller should fall back to # prompting the operator for a manually-generated token. # # NB: --max-time 10 (down from forgejo_api's default 30) — this # endpoint is sometimes slow on the Forgejo side ; we'd rather fail # fast and prompt than wait 30s on every bootstrap re-run. forgejo_get_runner_token() { local owner=$1 repo=$2 token="" local insecure=() [[ "${FORGEJO_INSECURE:-0}" == "1" ]] && insecure=(-k) token=$(curl -fsSL "${insecure[@]}" --max-time 10 \ -H "Authorization: token ${FORGEJO_ADMIN_TOKEN:?}" \ -H "Accept: application/json" \ "$FORGEJO_API_URL/api/v1/repos/$owner/$repo/actions/runners/registration-token" 2>/dev/null \ | jq -r '.token // empty' 2>/dev/null || true) if [[ -n "$token" ]]; then printf '%s' "$token" return 0 fi return 1 }