273 lines
6.7 KiB
Bash
Executable file
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
|
|
}
|