#!/usr/bin/env bash # # standup.sh — bring a bootstrapped cluster's reconciliation graph all the way up. # # ENVIRONMENT-AGNOSTIC (R-ENV-02): it reads everything it needs from the cluster it is pointed at and # from the authored manifests. There is no dev/prod fork. What makes it safe to run against prod is # the MANDATORY --expect-server guard, not a name in the filename. # # WHY THIS EXISTS # `bootstrap/flux/bootstrap.sh` installs Flux and applies the graph. It does NOT finish the job when # tiers have been deliberately suspended (a DNS/TLS migration window, an incident recovery) or when # a HelmRelease has wedged. Those last steps were living in shell history and in a human's head, # which is the same failure the Kyverno CRD pre-apply had. This is that sequence, in git, ordered, # idempotent, and gated. # # THE ORDERING IS LOAD-BEARING. Read infra/PROD-STANDUP.md before deviating from it. # # USAGE # bootstrap/standup.sh --kubeconfig --expect-server [--confirm-destructive] # # steps: preflight crds unwedge externaldns s3check resume verify (or: all) # # --expect-server is REQUIRED and is compared against the live API server before ANY command runs. # Pointing this at the wrong cluster is the one mistake with unbounded blast radius: dev is live and # shares a Cloudflare zone with prod. The guard is not optional and there is no override flag. # # --confirm-destructive is required by `unwedge` only. Everything else is additive or reversible. # # --s3-endpoint makes `s3check` probe a CANDIDATE backup endpoint instead of the live # cluster-vars value, so a proposed target can be PROVEN before it is authored into cluster-vars. # `s3check` writes and then deletes ONE throwaway object under `/.s3check/`; it changes # nothing else and applies nothing. See infra/BACKUP-R2.md. # set -euo pipefail # ---------------------------------------------------------------------------- args KUBECONFIG_ARG="" EXPECT_SERVER="" CONFIRM_DESTRUCTIVE=0 S3_ENDPOINT_OVERRIDE="" STEPS=() while [ $# -gt 0 ]; do case "$1" in --kubeconfig) KUBECONFIG_ARG="${2:?--kubeconfig needs a value}"; shift 2 ;; --expect-server) EXPECT_SERVER="${2:?--expect-server needs a value}"; shift 2 ;; --confirm-destructive) CONFIRM_DESTRUCTIVE=1; shift ;; --s3-endpoint) S3_ENDPOINT_OVERRIDE="${2:?--s3-endpoint needs a value}"; shift 2 ;; -h|--help) sed -n '2,30p' "$0"; exit 0 ;; -*) echo "ERROR: unknown flag '$1'" >&2; exit 2 ;; *) STEPS+=("$1"); shift ;; esac done [ -n "${KUBECONFIG_ARG}" ] || { echo "ERROR: --kubeconfig is required." >&2; exit 2; } [ -n "${EXPECT_SERVER}" ] || { echo "ERROR: --expect-server is required (safety guard)." >&2; exit 2; } [ "${#STEPS[@]}" -gt 0 ] || { echo "ERROR: no step given. One of: preflight crds unwedge externaldns s3check resume verify all" >&2; exit 2; } script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(git -C "${script_dir}" rev-parse --show-toplevel 2>/dev/null || (cd "${script_dir}/.." && pwd))" cd "${repo_root}" export KUBECONFIG="${KUBECONFIG_ARG}" [ -f "${KUBECONFIG}" ] || { echo "ERROR: kubeconfig not found: ${KUBECONFIG}" >&2; exit 1; } # Where DNS snapshots go. This MUST be a path that survives between invocations: each `dev.sh run` is # a FRESH container, so a $TMPDIR default silently threw the baseline away and made `verify`'s # before/after comparison — the check that detects damage to the OTHER cluster's records — impossible # to run across steps. The repo is bind-mounted, so a repo-relative dir persists on the host. # Gitignored: these are run artifacts, not authored state. SNAP_DIR="${STANDUP_SNAP_DIR:-${repo_root}/.standup-dns}" mkdir -p "${SNAP_DIR}" say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } info() { printf ' %s\n' "$*"; } die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } warn() { printf '\033[33mWARN: %s\033[0m\n' "$*" >&2; } for bin in kubectl flux helm yq sops; do command -v "$bin" >/dev/null 2>&1 || die "'$bin' not found — run this inside infra/devcontainer." done # ------------------------------------------------------------------ THE SAFETY GUARD # Checked once, before any step, and re-checked by every mutating helper. The failure mode this # prevents is running a prod bring-up against the LIVE dev cluster, whose public DNS records prod # has already destroyed once (2026-09-04). live_server="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')" [ "${live_server}" = "${EXPECT_SERVER}" ] || die \ "TARGET MISMATCH — refusing to run. --expect-server : ${EXPECT_SERVER} live API server : ${live_server} The kubeconfig does not point where you think it does. Nothing has been changed." say "target confirmed: ${live_server}" # Dependency order. Flux enforces the edges itself via dependsOn; resuming in this order simply means # each wait is meaningful instead of blocking on a dependency that is still suspended. # flux-system -> cilium -> platform -> platform-config -> platform-config-cdi # -> {leaf tiers, apps-wave-0} -> wave-1 -> wave-2 -> apps-vms # # `platform-config-cdi` MUST stay in this list and MUST stay before apps-vms. It carries the CDI # StorageProfiles, which were split out of platform-config because StorageProfile's CRD does not exist # until cdi-operator has reconciled the CDI CR (clusters/base/platform-config-cdi.yaml). apps-vms # dependsOn it: apps/vms/unifi provisions on `longhorn-vm`, and without the profile applied CDI infers # ReadWriteOnce instead of the authored ReadWriteMany — permanently non-live-migratable for that disk. # Omitting it here would make `resume` return success without ever waiting on the tier that guarantees it. TIER_ORDER=(platform platform-config platform-config-cdi oauth2-proxy headlamp homepage crafty apps-wave-0 apps-wave-1 apps-wave-2 apps-vms) # velero is NOT in that list on purpose — see step `resume`: it consumes S3_BACKUP_ENDPOINT, which is # an intentionally-invalid placeholder until the operator supplies the real R2 endpoint. cvar() { kubectl -n flux-system get cm cluster-vars -o jsonpath="{.data.$1}" 2>/dev/null || true; } k_ready() { kubectl -n flux-system get kustomization "$1" \ -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true; } k_msg() { kubectl -n flux-system get kustomization "$1" \ -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}' 2>/dev/null || true; } k_susp() { kubectl -n flux-system get kustomization "$1" -o jsonpath='{.spec.suspend}' 2>/dev/null || true; } wait_ready() { # local n="$1" t="${2:-600}" i=0 while [ "$i" -lt "$t" ]; do [ "$(k_ready "$n")" = "True" ] && { info "$n Ready (${i}s)"; return 0; } sleep 10; i=$((i+10)) done warn "$n did NOT reach Ready in ${t}s: $(k_msg "$n")" return 1 } # Snapshot the Cloudflare zone. This is how damage to the OTHER cluster's records becomes detectable. # Queried against the Cloudflare API, never a local resolver: the workstation's own dnsmasq wildcard # (address=/.home.keithjackson.dev/) answers the whole tree locally and produced a false # healthy reading during the 2026-09-04 incident and hid the damage for a while. dns_snapshot() { # local out="$1" tok zid page tp tok="$(sops -d platform/externaldns/cloudflare-api-token.sops.yaml \ | yq '.stringData."api-token"' | tr -d '"')" [ -n "${tok}" ] && [ "${tok}" != "null" ] || die "could not read the Cloudflare token (SOPS/age key available?)" zid="$(curl -sS -H "Authorization: Bearer ${tok}" \ 'https://api.cloudflare.com/client/v4/zones?name=keithjackson.dev' | yq '.result[0].id' | tr -d '"')" [ -n "${zid}" ] && [ "${zid}" != "null" ] || die "Cloudflare zone lookup failed" page=1; : > "${out}" while :; do local r; r="$(curl -sS -H "Authorization: Bearer ${tok}" \ "https://api.cloudflare.com/client/v4/zones/${zid}/dns_records?per_page=100&page=${page}")" printf '%s' "${r}" | yq -o=json '.result' \ | yq -r '.[] | (.type + "|" + .name + "|" + .content)' | tr -d '"' >> "${out}" tp="$(printf '%s' "${r}" | yq '.result_info.total_pages' | tr -d '"')" [ "${page}" -ge "${tp:-1}" ] && break page=$((page+1)) done # Strip CRs, and sort under a FIXED collation. Both matter, and both were learned the hard way: # - a stray \r makes every line differ from a baseline captured without one; # - locale-dependent sort ordering puts `*.keithjackson.dev` in a different position on the host # than in the container, so a diff reports pairs of IDENTICAL lines as changed. That produced a # "RECORDS OUTSIDE ... CHANGED" alarm on a zone where nothing had changed at all. # A regression check that cries wolf is worse than none — it trains you to skip the one alarm that # matters. LC_ALL=C makes the ordering reproducible everywhere. tr -d '\r' < "${out}" | grep -vE '^\s*$' | LC_ALL=C sort -o "${out}" info "$(wc -l < "${out}") records -> ${out}" } dns_diff() { # — returns non-zero if anything changed if diff -u "$1" "$2" > "${SNAP_DIR}/dns.diff"; then info "Cloudflare zone IDENTICAL — nothing created, modified or deleted." return 0 fi warn "Cloudflare zone CHANGED:"; cat "${SNAP_DIR}/dns.diff" return 1 } # ======================================================================= s3check plumbing # WHY THIS IS THIS BIG. The previous version of `s3check` curl'd /minio/health/live and reported # REACHABLE. That probe could not pass against Cloudflare R2 (no such path), and reachability is not # the property anyone cares about: a reachable endpoint with a wrong key, a missing bucket, or R2's # checksum rejection still yields a velero tier that reports Ready while NO backup exists. What must # be proven is the write path Velero and CNPG-barman actually use, with the credentials they actually # hold, from the network position they actually occupy. # Pinned. The AWS CLI is a deliberate choice over `minio/mc`: # - it prints the S3 ERROR CODE (NoSuchBucket / AccessDenied / InvalidAccessKeyId / InvalidArgument), # so a failure can be NAMED and turned into a fix instead of guessed at from an HTTP status; # - it exposes the FLEXIBLE-CHECKSUM behaviour that is the R2 trap # (AWS_REQUEST_CHECKSUM_CALCULATION), so the trap can be reproduced on purpose here instead of # discovered by a silently-failing backup at 03:00; # - its image carries bash + coreutils. `minio/mc` ships no sed/awk/diff — that minimalism has # already broken one probe in this repo — and it cannot be made to send the checksum headers, so # it cannot test for the trap at all. S3CHECK_IMAGE="amazon/aws-cli:2.36.40" S3CHECK_RUN_ID="$(date +%s)" # Throwaway probe pods, tracked so they are removed on EVERY exit path (including `die`). A probe # must never leave a pod behind on a live cluster. S3CHECK_PODS=() s3_cleanup_pods() { local p for p in ${S3CHECK_PODS[@]+"${S3CHECK_PODS[@]}"}; do kubectl -n "${p%%/*}" delete pod "${p#*/}" --ignore-not-found --wait=false >/dev/null 2>&1 || true done S3CHECK_PODS=() } trap s3_cleanup_pods EXIT # TRUE when the endpoint is not a usable target. `TBD-*` alone was WRONG once the placeholder became # `https://TBD-R2-ACCOUNT-ID.r2.cloudflarestorage.com`: that does not START with TBD-, so the old # prefix pattern matched nothing and `resume` would have resumed velero straight onto a placeholder # hostname — the exact silent-no-backups failure the guard exists to prevent. Match anywhere. s3_is_placeholder() { # case "$1" in ""|*TBD*|*tbd-*|*REPLACE-ME*|*replace-me*|*CHANGE-ME*|*change-me*) return 0 ;; esac return 1 } # Refuse any value that is not a plain URL/name fragment. This is BOTH a safety guard — these values # are interpolated into the probe pod's JSON overrides AND into a shell script that runs inside it, so # a quote, a backslash or a newline could rewrite either — and a correctness guard: a cluster-vars # value with a trailing space or a stray quote is a defect, not something to paper over. s3_safe_token() { #