#!/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; } } require_python_modules() { local python_bin="${1:-python3}" shift || true local modules=("$@") [[ ${#modules[@]} -gt 0 ]] || return 0 "$python_bin" - "${modules[@]}" <<'PY' import importlib import sys missing = [] for module in sys.argv[1:]: try: importlib.import_module(module) except ModuleNotFoundError: missing.append(module) if missing: names = ", ".join(missing) print( f"missing Python module(s): {names}. Install them for {sys.executable} before running bootstrap ", f"(for example: {sys.executable} -m pip install {' '.join(missing)})", sep="", file=sys.stderr, ) raise SystemExit(1) PY } generate_htpasswd() { local username="$1" local password="$2" if command -v htpasswd >/dev/null 2>&1; then htpasswd -Bbn "$username" "$password" | tr -d '\r' return 0 fi if command -v docker >/dev/null 2>&1; then docker run --rm --entrypoint htpasswd httpd:2 -Bbn "$username" "$password" | tr -d '\r' return 0 fi echo "missing command: htpasswd or docker (required to generate registry htpasswd secret)" >&2 return 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_kubectl() { local max_attempts="${1:-120}" local sleep_seconds="${2:-5}" local attempt=1 until kubectl get ns >/dev/null 2>&1; do if (( attempt >= max_attempts )); then echo "timed out waiting for kubectl API access" >&2 return 1 fi if (( attempt == 1 || attempt % 6 == 0 )); then echo "waiting for kubectl API access (${attempt}/${max_attempts})..." fi sleep "$sleep_seconds" attempt=$((attempt + 1)) done } derive_tailscale_control_plane_hostname() { local host_name="$1" command -v tailscale >/dev/null 2>&1 || { echo "tailscale CLI is required locally for tailscale-first bootstrap" >&2 return 1 } local tailscale_json tailscale_json="$(mktemp)" tailscale status --json >"$tailscale_json" 2>/dev/null || { rm -f "$tailscale_json" return 1 } python3 - "$host_name" "$tailscale_json" <<'PY' import json,sys host=sys.argv[1] path=sys.argv[2] with open(path) as fh: data=json.load(fh) suffix=data.get('MagicDNSSuffix') or '' if suffix: print(f"{host}.{suffix}") PY rm -f "$tailscale_json" } 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 tailscale_json tailscale_json="$(mktemp)" if tailscale status --json >"$tailscale_json" 2>/dev/null; then discovered=$(python3 - "$host_name" "$tailscale_json" <<'PY' import json,sys host=sys.argv[1] path=sys.argv[2] try: with open(path) as fh: data=json.load(fh) 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('DNSName') or ''), reverse=True): if not peer.get('Online'): continue dns=(peer.get('DNSName') or '').rstrip('.') if dns: print(dns) raise SystemExit(0) if peer.get('TailscaleIPs'): print(peer['TailscaleIPs'][0]) raise SystemExit(0) print("") PY ) else discovered="" fi rm -f "$tailscale_json" 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 }