doran/scripts/hetzner/lib.sh
2026-03-28 23:05:43 +01:00

273 lines
6.7 KiB
Bash
Executable file

#!/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
}