#!/usr/bin/env bash set -euo pipefail ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) BOOTSTRAP_ENV_FILE_DEFAULT="$ROOT_DIR/scripts/hetzner/bootstrap-secrets.env" require() { command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1" >&2; exit 1; } } load_bootstrap_env() { local env_file="${BOOTSTRAP_ENV_FILE:-$BOOTSTRAP_ENV_FILE_DEFAULT}" if [[ -f "$env_file" ]]; then # shellcheck disable=SC1090 source "$env_file" fi } pass_get_first_line() { local path="$1" pass show "$path" | awk 'NR==1 { print; exit }' } resolve_secret_var() { local name="$1" local mode="${2:-required}" local ref_name="${name}_PASS" local value="${!name:-}" local ref="${!ref_name:-}" if [[ -z "$value" && -n "$ref" ]]; then require pass value="$(pass_get_first_line "$ref")" printf -v "$name" '%s' "$value" export "$name" fi if [[ "$mode" == "required" && -z "${!name:-}" ]]; then echo "set $name or $ref_name" >&2 exit 1 fi } store_secret_to_pass() { local path="$1" local value="$2" [[ -n "$path" ]] || return 0 require pass printf '%s\n' "$value" | pass insert -m -f "$path" >/dev/null } wait_for_url() { local url="$1" local label="$2" local max_attempts="${3:-120}" local sleep_seconds="${4:-5}" local attempt=1 until curl -kfsS "$url" >/dev/null 2>&1; do if (( attempt >= max_attempts )); then echo "timed out waiting for ${label}: ${url}" >&2 return 1 fi if (( attempt == 1 || attempt % 6 == 0 )); then echo "waiting for ${label} (${attempt}/${max_attempts})..." fi sleep "$sleep_seconds" attempt=$((attempt + 1)) done } wait_for_http_status() { local url="$1" local label="$2" local expected_regex="$3" local max_attempts="${4:-120}" local sleep_seconds="${5:-5}" local attempt=1 while true; do local status status="$(curl -ksS -o /dev/null -w '%{http_code}' "$url" || true)" if [[ "$status" =~ ^(${expected_regex})$ ]]; then return 0 fi if (( attempt >= max_attempts )); then echo "timed out waiting for ${label}: ${url} (last status=${status:-n/a})" >&2 return 1 fi if (( attempt == 1 || attempt % 6 == 0 )); then echo "waiting for ${label} (${attempt}/${max_attempts}) status=${status:-n/a}..." fi sleep "$sleep_seconds" attempt=$((attempt + 1)) done } wait_for_ssh() { local target="$1" local max_attempts="${2:-120}" local sleep_seconds="${3:-5}" local attempt=1 until ssh -i "$SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 "$target" 'echo ssh-ready' >/dev/null 2>&1; do if (( attempt >= max_attempts )); then echo "timed out waiting for ssh: ${target}" >&2 return 1 fi if (( attempt == 1 || attempt % 6 == 0 )); then echo "waiting for ssh (${attempt}/${max_attempts})..." fi sleep "$sleep_seconds" attempt=$((attempt + 1)) done } wait_for_tailscale_node() { local host_name="$1" local max_attempts="${2:-120}" local sleep_seconds="${3:-5}" local attempt=1 command -v tailscale >/dev/null 2>&1 || { echo "tailscale CLI is required locally for tailscale-first bootstrap" >&2 return 1 } while true; do local discovered discovered=$(tailscale status --json 2>/dev/null | python3 - "$host_name" <<'PY' import json,sys host=sys.argv[1] try: data=json.load(sys.stdin) except Exception: print("") raise SystemExit(0) peers=data.get('Peer',{}) matches=[] for peer in peers.values(): if peer.get('HostName') == host: matches.append(peer) for peer in sorted(matches, key=lambda p: ((p.get('Online') is True), p.get('DNSName') or ''), reverse=True): if peer.get('Online'): dns=(peer.get('DNSName') or '').rstrip('.') if dns: print(dns) raise SystemExit(0) for peer in sorted(matches, key=lambda p: p.get('DNSName') or '', reverse=True): if peer.get('TailscaleIPs'): print(peer['TailscaleIPs'][0]) raise SystemExit(0) print("") PY ) if [[ -n "$discovered" ]]; then printf '%s\n' "$discovered" return 0 fi if (( attempt >= max_attempts )); then echo "timed out waiting for tailscale node: ${host_name}" >&2 return 1 fi if (( attempt == 1 || attempt % 6 == 0 )); then echo "waiting for tailscale node ${host_name} (${attempt}/${max_attempts})..." >&2 fi sleep "$sleep_seconds" attempt=$((attempt + 1)) done }