#!/usr/bin/env bash set -euo pipefail ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd) TF_DIR="$ROOT_DIR/infra/terraform/hetzner" STATE_DIR="$ROOT_DIR/.state/hetzner" KUBECONFIG_PATH="$STATE_DIR/kubeconfig.yaml" OVERLAY_DIR="$ROOT_DIR/deploy/k8s/overlays/hetzner-single-node" DEFAULT_PROJECT_NAME="unrip" DEFAULT_PROJECT_NAMESPACE="$DEFAULT_PROJECT_NAME" mkdir -p "$STATE_DIR" require() { command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1" >&2; exit 1; } } 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_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 } require terraform require kubectl require docker require curl require python3 require ssh require realpath : "${HCLOUD_TOKEN:?set HCLOUD_TOKEN}" : "${SSH_PUBLIC_KEY_PATH:?set SSH_PUBLIC_KEY_PATH}" : "${PUBLIC_DOMAIN:?set PUBLIC_DOMAIN}" : "${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL}" : "${TAILSCALE_AUTH_KEY:=}" : "${TAILSCALE_CONTROL_PLANE_HOSTNAME:=}" : "${NEAR_INTENTS_API_KEY:?set NEAR_INTENTS_API_KEY}" : "${BASE_DOMAIN:?set BASE_DOMAIN}" : "${FORGEJO_DOMAIN:=git.${BASE_DOMAIN}}" : "${FORGEJO_ROOT_URL:=https://${FORGEJO_DOMAIN}/}" : "${REGISTRY_DOMAIN:=registry.${BASE_DOMAIN}}" : "${REGISTRY_USERNAME:?set REGISTRY_USERNAME}" : "${REGISTRY_PASSWORD:?set REGISTRY_PASSWORD}" : "${FORGEJO_RUNNER_REGISTRATION_TOKEN:?set FORGEJO_RUNNER_REGISTRATION_TOKEN}" : "${TF_ADMIN_CIDR_BLOCKS:=}" : "${PROJECT_NAME:=$DEFAULT_PROJECT_NAME}" : "${PROJECT_NAMESPACE:=$DEFAULT_PROJECT_NAMESPACE}" : "${PROJECT_OVERLAY_DIR:=$OVERLAY_DIR}" : "${BOOTSTRAP_NODE_NAME:=unrip-1}" : "${SKIP_TERRAFORM_APPLY:=0}" : "${PROJECT_KUSTOMIZE_PATH:=../../projects/${PROJECT_NAME}/base}" : "${PROJECT_SECRET_NAME:=${PROJECT_NAME}-secrets}" : "${PROJECT_SECRET_ENV_BASENAME:=${PROJECT_NAME}.env}" : "${PROJECT_REGISTRY_SECRET_NAME:=${PROJECT_NAME}-registry-creds}" : "${PROJECT_IMAGE_REPOSITORY:=${PROJECT_NAME}}" : "${PROJECT_DEPLOYMENTS:=near-intents-ingest dummy-reactor dummy-executor dummy-consumer}" BOOTSTRAP_IMAGE="${PROJECT_IMAGE_REPOSITORY}:bootstrap" PROJECT_SECRET_ENV_PATH="$PROJECT_OVERLAY_DIR/secrets/$PROJECT_SECRET_ENV_BASENAME" GENERATED_OVERLAY_DIR="$STATE_DIR/generated-overlay" GENERATED_OVERLAY_KUSTOMIZATION="$GENERATED_OVERLAY_DIR/kustomization.yaml" SSH_PUBLIC_KEY=$(cat "$SSH_PUBLIC_KEY_PATH") SSH_PRIVATE_KEY_PATH="${SSH_PUBLIC_KEY_PATH%.pub}" if [[ ! -f "$SSH_PRIVATE_KEY_PATH" ]]; then echo "missing ssh private key for bootstrap: $SSH_PRIVATE_KEY_PATH" >&2 exit 1 fi TF_VARS=( -var "hcloud_token=$HCLOUD_TOKEN" -var "ssh_public_key=$SSH_PUBLIC_KEY" -var "public_domain=$PUBLIC_DOMAIN" -var "bootstrap_repo_url=local-bootstrap" -var "tailscale_auth_key=$TAILSCALE_AUTH_KEY" -var "tailscale_control_plane_hostname=$TAILSCALE_CONTROL_PLANE_HOSTNAME" ) if [[ -n "$TF_ADMIN_CIDR_BLOCKS" && "$TF_ADMIN_CIDR_BLOCKS" != '[]' ]]; then TF_VARS+=(-var "admin_cidr_blocks=$TF_ADMIN_CIDR_BLOCKS") fi if [[ -n "$TAILSCALE_AUTH_KEY" ]]; then bash "$ROOT_DIR/scripts/hetzner/print-tailscale-firewall-note.sh" fi terraform -chdir="$TF_DIR" init if [[ "$SKIP_TERRAFORM_APPLY" != "1" ]]; then terraform -chdir="$TF_DIR" apply -auto-approve "${TF_VARS[@]}" fi SERVER_IP=$(terraform -chdir="$TF_DIR" output -raw server_ipv4) K3S_API_URL=$(terraform -chdir="$TF_DIR" output -raw k3s_api_url) if [[ -n "$TAILSCALE_AUTH_KEY" ]]; then DISCOVERED_TAILSCALE_HOST="${TAILSCALE_CONTROL_PLANE_HOSTNAME:-$(wait_for_tailscale_node "$BOOTSTRAP_NODE_NAME")}" SSH_TARGET="root@${DISCOVERED_TAILSCALE_HOST}" K3S_API_URL="https://${DISCOVERED_TAILSCALE_HOST}:6443" else SSH_TARGET="root@${SERVER_IP}" fi if [[ -n "${CLOUDFLARE_API_TOKEN:-}" && -n "${CLOUDFLARE_ZONE_ID:-}" ]]; then if ! SERVER_IP="$SERVER_IP" BASE_DOMAIN="$BASE_DOMAIN" bash "$ROOT_DIR/scripts/hetzner/configure-cloudflare-dns.sh"; then echo "warning: cloudflare DNS automation failed; continuing without automated DNS" >&2 fi elif [[ -n "${PORKBUN_API_KEY:-}" && -n "${PORKBUN_SECRET_API_KEY:-}" ]]; then if ! SERVER_IP="$SERVER_IP" BASE_DOMAIN="$BASE_DOMAIN" bash "$ROOT_DIR/scripts/hetzner/configure-porkbun-dns.sh"; then echo "warning: porkbun DNS automation failed; continuing without automated DNS" >&2 fi fi wait_for_ssh "$SSH_TARGET" echo "waiting for Kubernetes API on $K3S_API_URL..." wait_for_url "${K3S_API_URL}/readyz" "k3s API readiness" ssh -i "$SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SSH_TARGET" 'sudo cat /etc/rancher/k3s/k3s.yaml' \ | sed "s|https://127.0.0.1:6443|${K3S_API_URL}|" > "$KUBECONFIG_PATH" export KUBECONFIG="$KUBECONFIG_PATH" mkdir -p "$PROJECT_OVERLAY_DIR/secrets" "$GENERATED_OVERLAY_DIR" cat > "$PROJECT_SECRET_ENV_PATH" < "$PROJECT_OVERLAY_DIR/secrets/forgejo.env" <