mirror of
https://gitlab.kube.huskypup.net/Scooby/Homelabv4.git
synced 2026-08-21 11:36:50 +00:00
Builds the one genuinely new component from the plan - everything else
reuses CNPG/etcd as-is.
- infrastructure/ha-failover/manifests/heartbeat-writer.yaml: home writes
a fresh timestamp to etcd (/ha-failover/home-heartbeat) every 10s via
its local etcd member. No custom quorum/voting logic - etcd's own Raft
consensus (majority write/linearizable read) already gives the 'a
majority agrees' guarantee.
- scripts/ha-failover-watcher.sh + .service: runs on the VPS as a
systemd service (not k3s - must survive the VPS's own cluster being
unhealthy). Reads the heartbeat via its own local etcd member; if the
read itself fails, we can't tell if home is down or if this VPS is the
one partitioned, so it does NOT act (etcd's consensus requirement
provides the split-brain safety here, not custom code). If the read
succeeds and the heartbeat is stale (>45s, ~4-5 missed beats), home is
confirmed down by majority - promotes pg-authentik's Cluster CR
(spec.replica self/primary/source -> vps, no promotionToken - confirmed
live via dry-run that CNPG's webhook doesn't require one, it's only for
planned/graceful switchover LSN cross-checks that aren't available
during a genuine unplanned outage) and flips the
pg-authentik.ha.huskypup.net Cloudflare A record to the VPS's IP.
- scripts/ha-failback-authentik.sh: the deliberately manual reverse -
human confirms home is healthy and caught up before running this.
- Floating hostname plumbing: both sites' Authentik values now read
AUTHENTIK_POSTGRESQL__HOST/PORT as pg-authentik.ha.huskypup.net:61432
instead of the local pg-authentik-app secret's host/port, so app pods
on either site always reach whichever site is actually primary. Both
sites listen on the same external port (VPS gets a new
pg-authentik-forward.service socat forward, mirroring the existing
minio-forward.service pattern, so its NodePort 32433 is externally
reachable on 61432 same as home's UniFi-forwarded port).
- Found and worked around two real bugs surfaced while wiring this up
(fixed manually via scripts handed to the user - both blocked by the
Claude Code auto-mode classifier as credential-transmission /
cluster-DNS-edit actions):
- VPS's pg-authentik-app secret had a STALE password from before
streaming replication existed - the live Postgres role password now
replicates from home via WAL, but VPS's local K8s secret never got
updated to match. Needs a one-time sync (and again on any future
rotation).
- This UniFi does not support NAT hairpin/loopback for its own WAN
port-forwards - home's own pods resolving the floating hostname need
a local CoreDNS rewrite straight to pg-authentik-rw, confirmed live by
a DNS-resolves-but-TCP-connect-fails test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
155 lines
7.2 KiB
Bash
Executable File
155 lines
7.2 KiB
Bash
Executable File
#!/bin/bash
|
|
# Multi-site active failover pilot (see
|
|
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - the VPS's half
|
|
# of the failover-watcher. Runs as a systemd service on the VPS
|
|
# (172.93.53.139), NOT in k3s - it needs to keep running even if the VPS's
|
|
# own k3s/CNPG cluster is unhealthy, and it's the one thing in this whole
|
|
# pilot that's genuinely new/bespoke rather than reusing an existing
|
|
# operator.
|
|
#
|
|
# Deploy (manual, matches every other VPS systemd unit this session - not
|
|
# git-applied automatically):
|
|
# scp this file to the VPS as /usr/local/bin/ha-failover-watcher.sh
|
|
# chmod +x it, then install scripts/ha-failover-watcher.service
|
|
# (see that file) and `systemctl enable --now ha-failover-watcher`.
|
|
#
|
|
# Logic (deliberately simple - see the plan doc's "what CNPG genuinely
|
|
# does NOT provide" paragraph for why this exists at all):
|
|
# 1. Every 10s, read /ha-failover/home-heartbeat from etcd via THIS
|
|
# node's own local etcd member (127.0.0.1:2379) using a linearizable
|
|
# (default, quorum-backed) read - home's heartbeat-writer
|
|
# (infrastructure/ha-failover/manifests/heartbeat-writer.yaml)
|
|
# refreshes this key every 10s while home is healthy.
|
|
# 2. If the read itself fails/times out, this VPS can't reach a
|
|
# majority of the 3-member etcd cluster (needs 2 of 3) - meaning
|
|
# EITHER home is genuinely down AND the witness is also unreachable
|
|
# from here, OR this VPS itself is the one that's partitioned.
|
|
# Either way, we cannot safely tell which, so we do NOT promote -
|
|
# this is the split-brain-prevention property etcd's own Raft
|
|
# consensus gives us for free, no custom quorum-counting needed.
|
|
# 3. If the read succeeds and the heartbeat is fresher than
|
|
# STALE_THRESHOLD seconds, home is confirmed up - no-op.
|
|
# 4. If the read succeeds (so we DO have majority/quorum) and the
|
|
# heartbeat is older than STALE_THRESHOLD - or missing entirely -
|
|
# home is confirmed down by majority agreement. Promote, once:
|
|
# a. Skip if already primary (checked via the Cluster CR itself,
|
|
# idempotent - safe to run this loop forever).
|
|
# b. kubectl patch the local pg-authentik Cluster:
|
|
# spec.replica.{self,primary,source} = vps. No promotionToken -
|
|
# confirmed live via dry-run that CNPG's admission webhook does
|
|
# NOT require one (it's optional, used for graceful/planned
|
|
# switchover to cross-check LSNs - not available for a genuine
|
|
# unplanned outage since home isn't reachable to generate one).
|
|
# This means promotion accepts whatever the VPS replica had
|
|
# already streamed - typically a couple seconds of async lag,
|
|
# an accepted tradeoff of async cross-WAN replication (there is
|
|
# no realistic sync-replication option over a home/VPS WAN
|
|
# link without crippling write latency).
|
|
# c. Flip the pg-authentik.ha.huskypup.net Cloudflare A record to
|
|
# this VPS's public IP - both sites listen on the SAME external
|
|
# port 61432 (home via UniFi WAN forward, VPS via
|
|
# pg-authentik-forward.service's local socat forward)
|
|
# specifically so a single floating hostname:port works for
|
|
# both sites without the app tier needing per-site config.
|
|
# d. Record the promotion in etcd (/ha-failover/promoted-at,
|
|
# /ha-failover/promoted-by) - both for the idempotency check
|
|
# above surviving a script restart, and as an audit trail for
|
|
# whoever does the (deliberately manual - see the plan doc)
|
|
# failback later.
|
|
#
|
|
# Failback is NOT automated by this script on purpose - see
|
|
# scripts/ha-failback-authentik.sh, run by a human once home is
|
|
# confirmed healthy again.
|
|
set -u
|
|
|
|
ETCD="http://127.0.0.1:2379"
|
|
STALE_THRESHOLD=45 # ~4-5 missed 10s heartbeats before acting - avoids flapping on one blip
|
|
CHECK_INTERVAL=10
|
|
CLOUDFLARE_TOKEN_FILE="/etc/ha-failover/cloudflare-token"
|
|
CF_ZONE_NAME="huskypup.net"
|
|
CF_RECORD_NAME="pg-authentik.ha.huskypup.net"
|
|
VPS_PUBLIC_IP="172.93.53.139"
|
|
LOG_TAG="ha-failover-watcher"
|
|
|
|
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) ${LOG_TAG}: $*"; }
|
|
|
|
b64() { printf '%s' "$1" | base64 | tr -d '\n'; }
|
|
|
|
etcd_get() {
|
|
# $1 = key. Prints the decoded value, or nothing + returns 1 if the
|
|
# read failed (unreachable/no quorum) or the key doesn't exist.
|
|
local key_b64 resp val_b64
|
|
key_b64="$(b64 "$1")"
|
|
resp="$(curl -sf --max-time 5 -X POST "${ETCD}/v3/kv/range" \
|
|
-d "{\"key\":\"${key_b64}\"}")" || return 1
|
|
val_b64="$(echo "$resp" | jq -r '.kvs[0].value // empty')"
|
|
[ -n "$val_b64" ] || return 1
|
|
echo "$val_b64" | base64 -d
|
|
}
|
|
|
|
etcd_put() {
|
|
local key_b64 val_b64
|
|
key_b64="$(b64 "$1")"
|
|
val_b64="$(b64 "$2")"
|
|
curl -sf --max-time 5 -X POST "${ETCD}/v3/kv/put" \
|
|
-d "{\"key\":\"${key_b64}\",\"value\":\"${val_b64}\"}" >/dev/null
|
|
}
|
|
|
|
flip_dns_to_vps() {
|
|
local token zone_id record_id
|
|
token="$(cat "$CLOUDFLARE_TOKEN_FILE")"
|
|
zone_id="$(curl -sf -H "Authorization: Bearer ${token}" \
|
|
"https://api.cloudflare.com/client/v4/zones?name=${CF_ZONE_NAME}" | jq -r '.result[0].id')"
|
|
if [ -z "$zone_id" ] || [ "$zone_id" = "null" ]; then
|
|
log "ERROR: could not resolve Cloudflare zone id for ${CF_ZONE_NAME}"
|
|
return 1
|
|
fi
|
|
record_id="$(curl -sf -H "Authorization: Bearer ${token}" \
|
|
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?name=${CF_RECORD_NAME}&type=A" \
|
|
| jq -r '.result[0].id // empty')"
|
|
if [ -z "$record_id" ]; then
|
|
log "ERROR: no existing A record for ${CF_RECORD_NAME} - refusing to create one blind, fix manually"
|
|
return 1
|
|
fi
|
|
curl -sf -X PATCH -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" \
|
|
-d "{\"type\":\"A\",\"name\":\"${CF_RECORD_NAME}\",\"content\":\"${VPS_PUBLIC_IP}\",\"ttl\":60,\"proxied\":false}" \
|
|
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${record_id}" >/dev/null
|
|
}
|
|
|
|
promote() {
|
|
local current_primary
|
|
current_primary="$(kubectl -n authentik get cluster pg-authentik -o jsonpath='{.spec.replica.primary}' 2>/dev/null)"
|
|
if [ "$current_primary" = "vps" ]; then
|
|
return 0 # already promoted, nothing to do
|
|
fi
|
|
log "PROMOTING: home confirmed down by etcd majority (heartbeat stale/missing). Flipping pg-authentik to vps."
|
|
if ! kubectl -n authentik patch cluster pg-authentik --type merge \
|
|
-p '{"spec":{"replica":{"self":"vps","primary":"vps","source":"vps"}}}'; then
|
|
log "ERROR: kubectl patch failed - Cluster CR NOT promoted, will retry next loop"
|
|
return 1
|
|
fi
|
|
if ! flip_dns_to_vps; then
|
|
log "ERROR: Cluster CR promoted but Cloudflare DNS flip failed - fix pg-authentik.ha.huskypup.net manually, it still points at home"
|
|
fi
|
|
etcd_put "/ha-failover/promoted-at" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
etcd_put "/ha-failover/promoted-by" "vps"
|
|
log "Promotion complete."
|
|
}
|
|
|
|
log "starting, stale threshold=${STALE_THRESHOLD}s check interval=${CHECK_INTERVAL}s"
|
|
while true; do
|
|
hb="$(etcd_get /ha-failover/home-heartbeat)"
|
|
if [ $? -ne 0 ]; then
|
|
log "cannot reach etcd quorum (or no heartbeat key yet) - not acting, will retry"
|
|
sleep "$CHECK_INTERVAL"
|
|
continue
|
|
fi
|
|
now="$(date +%s)"
|
|
age=$((now - hb))
|
|
if [ "$age" -gt "$STALE_THRESHOLD" ]; then
|
|
log "home heartbeat is ${age}s stale (threshold ${STALE_THRESHOLD}s)"
|
|
promote
|
|
fi
|
|
sleep "$CHECK_INTERVAL"
|
|
done
|