Navidrome (Subsonic API) as the Spotify replacement for phone apps. - Library PVC on ceph-filesystem-bulk (RWX, 300Gi, mounted read-only); SQLite data on ceph-block (RWO). - Web UI behind a dedicated navidrome-gate Authentik proxy outpost; Navidrome trusts X-Authentik-Username (ExtAuth) and auto-creates the user on first SSO login (first user = admin). - /rest and /share routed straight to Navidrome with the identity header stripped, so Subsonic clients use their Navidrome password. - Namespace baseline CNP, gate egress CNP, Flux image automation, Velero daily schedule (09:15), bootstrap wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2028 lines
103 KiB
Bash
Executable File
2028 lines
103 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
# K8s Cluster Bootstrap — split into phases so CI can export access as early as possible.
|
||
#
|
||
# Usage:
|
||
# ./bootstrap.sh # run all phases locally
|
||
# ./bootstrap.sh talos # Talos apply + K8s bootstrap + kubeconfig (writes access-staging/)
|
||
# ./bootstrap.sh flux # reads access-staging/, installs flux, waits for infrastructure
|
||
# ./bootstrap.sh wait-apps # waits for apps/authentik/paperless to reconcile
|
||
# ./bootstrap.sh vmtest # throwaway QEMU VMs, then the talos phase
|
||
# # against them unchanged. Needs root for the
|
||
# # provisioner and leaves nothing behind.
|
||
# ./bootstrap.sh hcloudtest # the same rehearsal on Hetzner Cloud, which is
|
||
# # the only place the registry fallback can be
|
||
# # tested: gitea.renner.uno has no external
|
||
# # ingress, so a node there genuinely cannot
|
||
# # reach it. BILLS BY THE HOUR — teardown runs
|
||
# # from the EXIT trap and is by label.
|
||
# ./bootstrap.sh hcloudclean # that teardown on its own, for a run that was
|
||
# # killed before its trap could fire.
|
||
#
|
||
# Flux git source (break-glass): the in-cluster Gitea that Flux normally pulls
|
||
# from is itself Flux-managed, so a cold rebuild cannot pull from it until the
|
||
# cluster already exists. Set FLUX_SOURCE=github to bootstrap Flux from the
|
||
# external GitHub mirror instead — the only way out of that loop:
|
||
# FLUX_SOURCE=github GITHUB_TOKEN=<pat> ./bootstrap.sh flux
|
||
# The mirror is kept current server-side by Gitea's native push-mirror. Full
|
||
# cold-rebuild procedure: kubernetes/RUNBOOK-cold-rebuild.md.
|
||
#
|
||
# Requires: talosctl, kubectl, flux, sops age key at ~/.config/sops/age/keys.txt
|
||
|
||
TALOS_DIR="$(cd "$(dirname "$0")/talos" && pwd)"
|
||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||
# Cilium's single source of truth. Talos ships `cni: none` (talos/patches/
|
||
# cluster.yaml) and nothing else, so the CNI has to be installed here, between
|
||
# `talosctl bootstrap` and Flux — Flux's own controllers are pods and cannot
|
||
# reconcile the network they need in order to start. Everything the install
|
||
# needs is read out of this file at run time so there is no second copy of a
|
||
# version or a value to keep in step. See PROPOSAL-cilium-single-owner.md.
|
||
CILIUM_HR="$REPO_ROOT/kubernetes/infrastructure/cilium/helmrelease.yaml"
|
||
ACCESS_STAGING="$REPO_ROOT/kubernetes/access-staging"
|
||
NODES=(k8s-1 k8s-2 k8s-3)
|
||
# No dedicated workers since the k8s-edge VM was retired (2026-07-02) —
|
||
# hardware-bound services live on the bare-metal edge-1 Arch host
|
||
# (ansible/docs/edge-host.md).
|
||
WORKERS=()
|
||
ALL_NODES=("${NODES[@]}" "${WORKERS[@]}")
|
||
DOMAIN="srv.renner.uno"
|
||
DNS_SERVER="10.2.40.1" # UDM (old PVE DNS VIP 10.2.40.53 decommissioned)
|
||
# Cluster API endpoint — Talos VIP (10.2.40.60) elected via etcd, fronted by
|
||
# UniFi DNS A record k8s.renner.uno. Defined in talos/patches/k8s-{1,2,3}.yaml.
|
||
K8S_API_HOST="k8s.renner.uno"
|
||
# Which site patch the nodes get: talos/patches/site-$SITE.yaml. The three
|
||
# variables above and this one are everything the vmtest phase has to change in
|
||
# order to run this same code against throwaway VMs — if that list ever grows,
|
||
# the test is drifting away from the thing it claims to exercise.
|
||
SITE="${SITE:-homelab}"
|
||
# Where this script writes the cluster access files. These are the paths
|
||
# talosctl and kubectl read from anyway, so unset they resolve to exactly the
|
||
# old behaviour. Set, the whole run stays out of $HOME — which is what lets the
|
||
# vmtest phase drive a throwaway cluster without replacing the operator's
|
||
# production access with a VM's.
|
||
TALOSCONFIG_PATH="${TALOSCONFIG:-$HOME/.talos/config}"
|
||
KUBECONFIG_PATH="${KUBECONFIG:-$HOME/.kube/config}"
|
||
case "$KUBECONFIG_PATH" in
|
||
*:*) echo "KUBECONFIG holds multiple paths; set it to a single file" >&2; exit 1 ;;
|
||
esac
|
||
# Addresses to apply to, in the order of NODES. Empty means resolve the node
|
||
# FQDNs, which is right for real hardware on a network that has DNS for it.
|
||
NODE_ADDRS=()
|
||
GITEA_HOST="gitea.renner.uno"
|
||
GITEA_OWNER="gitea_admin"
|
||
GITEA_REPO="homelab"
|
||
# Flux git source: "gitea" (default, in-cluster) or "github" (break-glass mirror
|
||
# for a cold rebuild — see the usage note above and RUNBOOK-cold-rebuild.md).
|
||
FLUX_SOURCE="${FLUX_SOURCE:-gitea}"
|
||
# GitHub break-glass mirror (github.com/konairius/Homelab), kept current by
|
||
# Gitea's native push-mirror. Overridable in case the mirror moves.
|
||
GITHUB_OWNER="${GITHUB_OWNER:-konairius}"
|
||
GITHUB_REPO="${GITHUB_REPO:-Homelab}"
|
||
# Owner of the private ghcr.io namespace the rebuild-critical images are
|
||
# mirrored into, and the username half of the node registry credential. Matches
|
||
# GHCR_USER in .woodpecker/build-ci-images.yml, which pushes them.
|
||
GHCR_PULL_USER="${GHCR_PULL_USER:-konairius}"
|
||
FLUX_PATH="kubernetes/clusters/homelab"
|
||
# Branch Flux reconciles from, and that `flux bootstrap` commits its own sync
|
||
# manifests into. master for a real rebuild.
|
||
#
|
||
# Overridable so the phase can be rehearsed without writing to master — point it
|
||
# at a throwaway branch, which has to exist already: `flux bootstrap` clones the
|
||
# branch and fails with `couldn't find remote ref` rather than creating it.
|
||
#
|
||
# git push origin origin/master:refs/heads/rehearsal-1
|
||
# FLUX_BRANCH=rehearsal-1 ./bootstrap.sh flux
|
||
#
|
||
# Nothing else about the phase changes, which is the point. Delete the branch
|
||
# afterwards.
|
||
FLUX_BRANCH="${FLUX_BRANCH:-master}"
|
||
SOPS_KEY="$HOME/.config/sops/age/keys.txt"
|
||
|
||
PHASE="${1:-all}"
|
||
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
NC='\033[0m'
|
||
|
||
is_tty() { [[ -t 1 ]]; }
|
||
|
||
step() {
|
||
local _ignored=$1
|
||
local label="$2"
|
||
if is_tty; then
|
||
printf "${BLUE}==>${NC} %s\n" "$label"
|
||
else
|
||
printf "==> %s\n" "$label"
|
||
fi
|
||
}
|
||
|
||
# One EXIT trap for the whole script. Handlers register here rather than calling
|
||
# `trap ... EXIT` themselves, because a second registration replaces the first
|
||
# instead of chaining — which is how a secrets-cleanup handler silently disabled
|
||
# the VM teardown.
|
||
SECRETS_TMPDIR=""
|
||
on_exit() {
|
||
[[ -n "$SECRETS_TMPDIR" ]] && rm -rf "$SECRETS_TMPDIR"
|
||
declare -F cleanup_vmtest >/dev/null && cleanup_vmtest
|
||
# No teardown here: provisioning is its own phase and the machines it leaves
|
||
# behind are its product. `hcloudclean` is what deletes them.
|
||
# Only when this run actually booked machines. The function is always
|
||
# defined, so testing for it fired on the qemu phase too.
|
||
if [[ "${HCLOUD_PROVISIONED:-0}" == "1" ]]; then
|
||
echo -e "${YELLOW}Hetzner machines are UP and billed until deleted.${NC}"
|
||
echo " ./kubernetes/bootstrap.sh hcloudclean # when finished"
|
||
fi
|
||
return 0
|
||
}
|
||
trap on_exit EXIT
|
||
|
||
fail() { echo -e "\n${RED}FAILED:${NC} $1"; exit 1; }
|
||
ok() { echo -e "${GREEN}OK${NC}"; }
|
||
|
||
# Reads the Ready condition of a Flux Kustomization, without going through the
|
||
# table `flux get` prints.
|
||
#
|
||
# That table is NAME REVISION SUSPENDED READY MESSAGE, and every wait below used
|
||
# to pick READY as the fourth field. REVISION is empty until the first
|
||
# successful reconcile — precisely the state these waits exist to catch — so in
|
||
# the failure case the columns shift left and the fourth field lands on the
|
||
# message. Matching it against "False" then never fires, and a persistently
|
||
# failing Kustomization reads as merely slow. That cost a full two-hour run:
|
||
# infrastructure had been failing its server-side dry-run from the first minute,
|
||
# and the counter meant to catch it never moved.
|
||
#
|
||
# Echoes "<status>|<message>". An empty status means the object does not exist
|
||
# yet; kubectl's own error is carried through as the message rather than
|
||
# discarded, so "not created yet" stays distinguishable from "lookup broke".
|
||
ks_ready_condition() {
|
||
local name="$1" out jp
|
||
jp='{.status.conditions[?(@.type=="Ready")].status}'
|
||
jp+='|{.status.conditions[?(@.type=="Ready")].message}'
|
||
if out=$(kubectl -n flux-system get kustomization "$name" -o jsonpath="$jp" 2>&1); then
|
||
printf '%s' "$out"
|
||
else
|
||
printf '|%s' "$out"
|
||
fi
|
||
}
|
||
|
||
resolve_node_ip() {
|
||
local fqdn=$1
|
||
dig +short "$fqdn" A @"$DNS_SERVER" | head -1
|
||
}
|
||
|
||
phase_runs() {
|
||
[[ "$PHASE" == "all" || "$PHASE" == "$1" ]]
|
||
}
|
||
OPENBAO_NS="openbao"
|
||
|
||
# Where the secrets come from. On a workstation, the operator's pass store. In
|
||
# the in-cluster Job there is no pass and no GPG key — and there should not be:
|
||
# the pass store opens everything, and it stays on the operator's machine. Set
|
||
# OPENBAO_SECRETS_DIR and the run reads them from files instead, which the
|
||
# operator streams in when the run asks for them:
|
||
#
|
||
# pass show homelab/openbao/seal-key \
|
||
# | kubectl -n vmtest exec -i <pod> -- sh -c 'cat > /secrets/seal-key'
|
||
#
|
||
# They exist in the pod's filesystem for the length of the run and are never
|
||
# written to a Secret in this cluster, so they do not land in its etcd or in
|
||
# any backup of it. The seal key alone becomes a Secret in the cluster being
|
||
# built, which is what lets that OpenBao unseal itself.
|
||
OPENBAO_SECRETS_DIR="${OPENBAO_SECRETS_DIR:-}"
|
||
|
||
# Every secret the run needs, named once, in one place. The list is here and
|
||
# nowhere else on purpose: when it was spread over the call sites, each was read
|
||
# at the moment it was needed, so a missing one surfaced twenty minutes into a
|
||
# run — and a second copy of the same path in a runbook or a wrapper is a copy
|
||
# that goes stale.
|
||
#
|
||
# The git token for `flux bootstrap`, chosen by the source it will authenticate
|
||
# against — which is the whole point and is what the previous expression got
|
||
# wrong. It read ${GITHUB_TOKEN:-${GITEA_TOKEN:-}}, taking whichever variable
|
||
# happened to be set: on a workstation with GITEA_TOKEN exported and no
|
||
# GITHUB_TOKEN, a break-glass run handed a Gitea token to the GitHub API and
|
||
# died on `401 Bad credentials` after the cluster was already built.
|
||
#
|
||
# `gh auth token` as the GitHub fallback because that is the credential the
|
||
# operator already has on this machine, and it is what the vmtest secret feeder
|
||
# has always used. Emptiness is not checked here — require_secrets rejects an
|
||
# empty result and names what is missing.
|
||
#
|
||
# gh is called plainly, with its stderr left alone. Swallowing it would turn a
|
||
# failed login into an empty string, and require_secrets would then report
|
||
# "came back empty" for a credential that exists but could not be read — a zero
|
||
# that cannot be told apart from a fault. Its non-zero exit propagates, which is
|
||
# what makes require_secrets print the reason instead.
|
||
git_token_for_source() {
|
||
if [[ "$FLUX_SOURCE" == "github" ]]; then
|
||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||
printf '%s' "$GITHUB_TOKEN"
|
||
else
|
||
gh auth token
|
||
fi
|
||
else
|
||
printf '%s' "${GITEA_TOKEN:-}"
|
||
fi
|
||
}
|
||
|
||
# Format: <file in SECRETS_DIR>|<what it is>|<how the operator produces it>
|
||
OPENBAO_REQUIRED_SECRETS=(
|
||
"sops-age.key|SOPS age key|cat ~/.config/sops/age/keys.txt"
|
||
# Which token depends on FLUX_SOURCE, and that is not a detail: in a real
|
||
# cold rebuild the in-cluster Gitea cannot serve the cluster being rebuilt,
|
||
# so the break-glass path pulls from the GitHub mirror. A run that demands a
|
||
# Gitea token is a run that cannot rehearse the disaster it exists for.
|
||
# The variable names are the ones the usage header and RUNBOOK-cold-rebuild
|
||
# tell the operator to export. An invented third name was worse than a typo:
|
||
# `${VAR:?}` inside eval terminates the shell outright, so the documented
|
||
# recovery command exited with no message and no cleanup at all.
|
||
#
|
||
# Emptiness is not tested here on purpose — require_secrets rejects an empty
|
||
# file and names what is missing.
|
||
"git-token|token for flux bootstrap, for whichever forge FLUX_SOURCE names|git_token_for_source"
|
||
"s3-access|Hetzner restore access key|pass show homelab/hetztner/k8s-restore-access"
|
||
"s3-secret|Hetzner restore secret key|pass show homelab/hetztner/k8s-restore-secret"
|
||
# The static seal key. The snapshots are encrypted under it, so it has to
|
||
# be the one the backed-up cluster ran with — a fresh key restores nothing.
|
||
"seal-key|OpenBao seal key|pass show homelab/openbao/seal-key"
|
||
# Read by the node registry auth (see registry_auth_patch): the images an app
|
||
# needs while the cluster is still being built are mirrored to a private
|
||
# ghcr.io namespace, and a node has to be able to pull them before anything
|
||
# in the cluster exists to hold a credential. Demanded up front like the
|
||
# rest, because a rebuild that discovers it missing has already wiped the
|
||
# disks.
|
||
"ghcr-pull|GHCR pull token for the image mirror|pass show homelab/github/k8s-package-pull"
|
||
# Only the hcloudtest and hcloudclean phases ask for this one, by name, so a
|
||
# rebuild of the real cluster never demands it.
|
||
#
|
||
# Note the path: homelab/hetzner/, spelled correctly, while the two restore
|
||
# keys above live under homelab/hetztner/. Two folders, one typo, both real
|
||
# — do not "fix" one of these paths without moving the entry first.
|
||
"hcloud-token|Hetzner Cloud API token for the throwaway test cluster|pass show homelab/hetzner/cloud-api-secret"
|
||
)
|
||
|
||
# Collects all of them up front, into one directory, so everything after this
|
||
# point reads files and takes the same code path whether the run is on a laptop
|
||
# during a real recovery or in the in-cluster Job.
|
||
#
|
||
# The only difference between those two is where the material comes from, and it
|
||
# is confined to this function: on a laptop the commands in the list produce it
|
||
# from pass; in the Job the operator streams it in. Everything downstream cannot
|
||
# tell the difference — which is the point, because the recovery path must not be
|
||
# a path that only gets exercised during a recovery.
|
||
# require_secrets [name...]
|
||
#
|
||
# With no arguments, demands every entry in OPENBAO_REQUIRED_SECRETS. With
|
||
# names, demands only those — which is what lets the talos phase stand alone.
|
||
#
|
||
# That phase used to need nothing but the machines, and `bootstrap.sh talos` is
|
||
# documented as the part that can run unattended. Node registry auth changed
|
||
# that: the machine config now carries a pull credential, so the phase needs
|
||
# exactly one secret. Demanding all six there would drag in the OpenBao seal
|
||
# key, which deliberately never reaches CI — and demanding none, as before,
|
||
# left the phase to discover the missing file halfway through `talosctl gen`.
|
||
require_secrets() {
|
||
local entry name what how
|
||
local -a wanted=()
|
||
if [[ $# -gt 0 ]]; then
|
||
for entry in "${OPENBAO_REQUIRED_SECRETS[@]}"; do
|
||
for name in "$@"; do
|
||
[[ "${entry%%|*}" == "$name" ]] && wanted+=("$entry")
|
||
done
|
||
done
|
||
[[ ${#wanted[@]} -eq $# ]] \
|
||
|| fail "require_secrets asked for [$*] but the list defines only: ${wanted[*]%%|*}"
|
||
else
|
||
wanted=("${OPENBAO_REQUIRED_SECRETS[@]}")
|
||
fi
|
||
|
||
# Which branch: fetch from pass, or wait for someone to stream files in.
|
||
#
|
||
# The condition cannot be "is OPENBAO_SECRETS_DIR set", because THIS
|
||
# FUNCTION sets it. Once a subset call had run — hcloudtest asks for
|
||
# ghcr-pull and hcloud-token before anything else — every later call found
|
||
# the variable populated and took the wait branch, printing streaming
|
||
# instructions for a Job that does not exist on a workstation where pass was
|
||
# available all along. The run sat there while three nodes stayed in
|
||
# maintenance.
|
||
#
|
||
# SECRETS_SELF_FETCH records which world we are in, and it is decided once:
|
||
# an empty OPENBAO_SECRETS_DIR on entry means pass, an inherited one means
|
||
# the Job. Later calls follow that decision instead of re-deriving it from
|
||
# state this function has since changed.
|
||
if [[ -z "$OPENBAO_SECRETS_DIR" || "${SECRETS_SELF_FETCH:-0}" == "1" ]]; then
|
||
if [[ -z "$OPENBAO_SECRETS_DIR" ]]; then
|
||
OPENBAO_SECRETS_DIR=$(mktemp -d)
|
||
chmod 700 "$OPENBAO_SECRETS_DIR"
|
||
fi
|
||
SECRETS_SELF_FETCH=1
|
||
# Registered by name, not with a second `trap ... EXIT`: a second one
|
||
# REPLACES the first, and the first is the vmtest teardown. Setting it
|
||
# here left four VMs, their bridges and the sudo keepalive running after
|
||
# a workstation rehearsal — silently, and only on the laptop path, since
|
||
# in-cluster this branch is not taken.
|
||
SECRETS_TMPDIR="$OPENBAO_SECRETS_DIR"
|
||
echo -n "Collecting secrets... "
|
||
for entry in "${wanted[@]}"; do
|
||
name=${entry%%|*}; what=${entry#*|}; how=${what#*|}; what=${what%%|*}
|
||
# Already collected by an earlier call — a subset call fetches only
|
||
# what it asked for, so the full call that follows must fill the
|
||
# gaps rather than re-run every command. `pass show` on a locked
|
||
# store prompts, and prompting twice for the same key is how an
|
||
# unattended run turns into a hung one.
|
||
[[ -s "$OPENBAO_SECRETS_DIR/$name" ]] && continue
|
||
if ! eval "$how" > "$OPENBAO_SECRETS_DIR/$name" 2>"$OPENBAO_SECRETS_DIR/.err"; then
|
||
echo; cat "$OPENBAO_SECRETS_DIR/.err" >&2
|
||
fail "could not obtain the $what with: $how"
|
||
fi
|
||
[[ -s "$OPENBAO_SECRETS_DIR/$name" ]] \
|
||
|| fail "the $what came back empty from: $how"
|
||
done
|
||
ok
|
||
return 0
|
||
fi
|
||
|
||
mkdir -p "$OPENBAO_SECRETS_DIR"
|
||
local entry name what how missing
|
||
for i in $(seq 1 180); do
|
||
missing=()
|
||
for entry in "${wanted[@]}"; do
|
||
name=${entry%%|*}
|
||
[[ -s "$OPENBAO_SECRETS_DIR/$name" ]] || missing+=("$entry")
|
||
done
|
||
[[ ${#missing[@]} -eq 0 ]] && { echo "All secrets present."; return 0; }
|
||
if [[ $i -eq 1 ]]; then
|
||
echo -e "${YELLOW}Waiting for ${#missing[@]} secret(s). Stream each in with:${NC}" >&2
|
||
for entry in "${missing[@]}"; do
|
||
name=${entry%%|*}; what=${entry#*|}; how=${what#*|}; what=${what%%|*}
|
||
echo " # $what" >&2
|
||
echo " $how | kubectl -n $OPENBAO_NS_HINT exec -i \$POD -- sh -c 'cat > $OPENBAO_SECRETS_DIR/$name'" >&2
|
||
done
|
||
fi
|
||
sleep 10
|
||
[[ $i -eq 180 ]] && fail "still missing: ${missing[*]%%|*}"
|
||
done
|
||
}
|
||
OPENBAO_NS_HINT=vmtest
|
||
|
||
# Reads one secret by its name in the list above. In the Job that is the file the
|
||
# operator streamed in; on a workstation it runs the command the list names,
|
||
# which for these is `pass show <path>`.
|
||
#
|
||
# The list is the only place the paths appear. They used to sit here as
|
||
# constants as well, which is how the same value ends up defined twice and one
|
||
# copy goes stale.
|
||
read_secret() {
|
||
local name=$1
|
||
[[ -s "$OPENBAO_SECRETS_DIR/$name" ]] \
|
||
|| fail "$name is not in $OPENBAO_SECRETS_DIR — require_secrets runs first and should have caught this"
|
||
cat "$OPENBAO_SECRETS_DIR/$name"
|
||
}
|
||
|
||
# Provision a throwaway cluster on Hetzner Cloud, then run the Talos phase
|
||
# against it unchanged — same shape as the vmtest phase below, and for the same
|
||
# reason: a rehearsal that runs different code proves less than one that does
|
||
# not.
|
||
#
|
||
# WHY THIS EXISTS, given vmtest already rehearses a rebuild locally.
|
||
#
|
||
# The one thing vmtest cannot test is the registry fallback. The nodes now carry
|
||
# machine.registries.mirrors with gitea.renner.uno first and ghcr.io second, and
|
||
# the whole point of the second endpoint is the window where the first does not
|
||
# exist. At home it always exists — the production cluster is running and
|
||
# answers on the LAN — so containerd never reaches the fallback and it stays
|
||
# unproven. From Hetzner, Gitea does not answer at all: it has no external
|
||
# ingress.
|
||
#
|
||
# WHAT IT COSTS, because this one bills by the hour.
|
||
#
|
||
# Teardown is by LABEL, not by remembered ID, and runs from the single EXIT
|
||
# trap. A run that dies between `server create` and its own bookkeeping is
|
||
# therefore still cleanable — by the next run, or by `bootstrap.sh hcloudclean`,
|
||
# which does nothing but the sweep. Anything this phase creates carries
|
||
# HCLOUD_LABEL so that sweep can find it without being told.
|
||
#
|
||
# WHAT IT DOES NOT COVER.
|
||
#
|
||
# Everything downstream of Multus. The provider is not the limit — a cloud
|
||
# server takes up to 16 volumes and 3 private networks — but ceph-san binds to
|
||
# the kernel link name enp2s0f1np1, and that name cannot be manufactured here
|
||
# without assuming a Hetzner private network passes VLAN tags. See
|
||
# talos/patches/site-hcloud.yaml. Nor Flux and everything after it: that phase
|
||
# needs the OpenBao seal key, which deliberately never reaches CI, and this
|
||
# test is meant to run unattended.
|
||
# Talos version and Image Factory schematic, read out of the installer image in
|
||
# cluster.yaml. Both throwaway-cluster phases need them and neither may state
|
||
# them: a second copy is a copy that goes stale, and the day it did the test
|
||
# would quietly stop booting the image a rebuild installs.
|
||
talos_image_coords() {
|
||
command -v yq >/dev/null || fail "yq not found"
|
||
local image
|
||
image=$(yq -r '.machine.install.image' "$TALOS_DIR/patches/cluster.yaml")
|
||
VM_SCHEMATIC=${image#*metal-installer/}
|
||
VM_SCHEMATIC=${VM_SCHEMATIC%%:*}
|
||
VM_VERSION=${image##*:}
|
||
[[ ${#VM_SCHEMATIC} -eq 64 && "$VM_VERSION" == v* ]] \
|
||
|| fail "could not read a schematic and version out of '$image'"
|
||
}
|
||
|
||
HCLOUD_LABEL="managed-by=bootstrap-hcloudtest"
|
||
|
||
# The disks a machine has are stated in its Talos patch and read from there, so
|
||
# the provisioner carries no second copy. Emits one size in whole GB per raw
|
||
# volume the patch declares, in order.
|
||
hcloud_volumes_for_node() {
|
||
local patch="$TALOS_DIR/patches/$1.yaml" size gib
|
||
[[ -f "$patch" ]] || fail "$patch not found — the machine config is the list"
|
||
while read -r size; do
|
||
[[ -n "$size" && "$size" != "null" ]] || continue
|
||
# Talos states GiB; the API bills GB. Round up, so the volume can never
|
||
# come in under the minSize the machine config asked for.
|
||
gib=${size%GiB}
|
||
[[ "$gib" =~ ^[0-9]+$ ]] || fail "$patch: cannot read volume size '$size' — state it as <n>GiB"
|
||
echo $(( (gib * 1074 + 999) / 1000 ))
|
||
done < <(yq -r 'select(.kind == "RawVolumeConfig") | .provisioning.minSize' "$patch")
|
||
}
|
||
|
||
# Deletes everything this phase created, and NOTHING else.
|
||
#
|
||
# That distinction is not theoretical: the project this runs in also holds
|
||
# netbird.renner.uno, which has been up for months and is the VPN way in. The
|
||
# label is the only thing separating a throwaway server from it — and
|
||
# `hcloud server list -l ""` lists EVERY server, so an unset HCLOUD_LABEL would
|
||
# turn this function into a project wipe. Hence the guard: the label is checked
|
||
# before it is used, and every candidate must also carry the name prefix this
|
||
# phase assigns. Two independent conditions, because one of them is a variable.
|
||
#
|
||
# A separate Hetzner project, with a token scoped to it, would make this
|
||
# structural rather than defensive. Worth doing before this runs unattended.
|
||
HCLOUD_NAME_PREFIX="hcloud"
|
||
|
||
cleanup_hcloudtest() {
|
||
command -v hcloud >/dev/null || return 0
|
||
[[ -n "${HCLOUD_TOKEN:-}" ]] || return 0
|
||
[[ -n "${HCLOUD_LABEL:-}" ]] \
|
||
|| { echo " REFUSING to sweep: HCLOUD_LABEL is empty, which would match everything" >&2; return 0; }
|
||
|
||
local id name
|
||
# Servers first: a firewall still attached to one cannot be deleted.
|
||
while read -r id name; do
|
||
[[ -n "$id" ]] || continue
|
||
case "$name" in
|
||
"$HCLOUD_NAME_PREFIX"-*) ;;
|
||
*) echo " REFUSING to delete $name ($id): carries the label but not the name prefix" >&2; continue ;;
|
||
esac
|
||
echo " deleting server $name"
|
||
hcloud server delete "$id" >/dev/null \
|
||
|| echo " WARNING: server $name not deleted — check the console" >&2
|
||
done < <(hcloud server list -l "$HCLOUD_LABEL" -o noheader -o columns=id,name)
|
||
|
||
for id in $(hcloud firewall list -l "$HCLOUD_LABEL" -o noheader -o columns=id); do
|
||
echo " deleting firewall $id"
|
||
hcloud firewall delete "$id" >/dev/null \
|
||
|| echo " WARNING: firewall $id not deleted — check the console" >&2
|
||
done
|
||
for id in $(hcloud volume list -l "$HCLOUD_LABEL" -o noheader -o columns=id); do
|
||
echo " deleting volume $id"
|
||
hcloud volume delete "$id" >/dev/null \
|
||
|| echo " WARNING: volume $id not deleted — check the console" >&2
|
||
done
|
||
# Last: a network with a server still attached cannot be deleted, and a
|
||
# leftover one takes its name with it, so the next run cannot recreate it.
|
||
for id in $(hcloud network list -l "$HCLOUD_LABEL" -o noheader -o columns=id); do
|
||
echo " deleting network $id"
|
||
hcloud network delete "$id" >/dev/null \
|
||
|| echo " WARNING: network $id not deleted — check the console" >&2
|
||
done
|
||
}
|
||
|
||
# Sweep only. For the case where a previous run was killed hard enough that its
|
||
# own trap never ran, and for a cron job to call before it starts.
|
||
if [[ "$PHASE" == "hcloudclean" ]]; then
|
||
echo -e "${YELLOW}=== Phase: Hetzner cleanup ===${NC}"
|
||
command -v hcloud >/dev/null || fail "hcloud not found"
|
||
require_secrets hcloud-token
|
||
HCLOUD_TOKEN=$(read_secret hcloud-token); export HCLOUD_TOKEN
|
||
cleanup_hcloudtest
|
||
ok
|
||
exit 0
|
||
fi
|
||
|
||
if [[ "$PHASE" == "hcloudtest" ]]; then
|
||
echo -e "${YELLOW}=== Phase: Hetzner rebuild test ===${NC}"
|
||
command -v hcloud >/dev/null || fail "hcloud not found — https://github.com/hetznercloud/cli"
|
||
command -v talosctl >/dev/null || fail "talosctl not found"
|
||
command -v yq >/dev/null || fail "yq not found"
|
||
command -v jq >/dev/null || fail "jq not found (needed to read the SAN pool and server attachments)"
|
||
command -v curl >/dev/null || fail "curl not found (needed to learn this host's public address)"
|
||
|
||
# Three control planes, all carrying workloads — the production shape. One
|
||
# machine is a node, not a cluster: etcd has no quorum to lose and nothing
|
||
# schedules elsewhere, so a rebuild that only ever ran on a single box
|
||
# proves nothing about the one that has three.
|
||
HCLOUD_NODES=(hcloud-1 hcloud-2 hcloud-3)
|
||
for n in "${HCLOUD_NODES[@]}"; do
|
||
[[ -f "$TALOS_DIR/patches/$n.yaml" ]] || fail "$TALOS_DIR/patches/$n.yaml not found"
|
||
done
|
||
[[ -f "$TALOS_DIR/patches/site-hcloud.yaml" ]] || fail "site-hcloud.yaml not found"
|
||
|
||
require_secrets hcloud-token ghcr-pull
|
||
HCLOUD_TOKEN=$(read_secret hcloud-token); export HCLOUD_TOKEN
|
||
|
||
# No sweep before creating: machines from an earlier run are the input to
|
||
# this one, adopted below. The names are fixed, so duplicates cannot
|
||
# accumulate. `hcloudclean` is what deletes.
|
||
|
||
# The Talos snapshot is a prerequisite, not something this builds: making it
|
||
# means booting a server into rescue and writing the raw image, which is a
|
||
# one-off and needs a different set of permissions. Fail with the recipe
|
||
# rather than silently booting Debian and timing out on the Talos API.
|
||
step 0 "Looking up the Talos snapshot..."
|
||
talos_image_coords
|
||
HCLOUD_IMAGE=$(hcloud image list --type snapshot -l os=talos -o noheader -o columns=id | head -1)
|
||
[[ -n "$HCLOUD_IMAGE" ]] || fail "no snapshot labelled os=talos. Create one once — boot any server into rescue, then:
|
||
wget -O /tmp/talos.raw.xz https://factory.talos.dev/image/$VM_SCHEMATIC/$VM_VERSION/hcloud-amd64.raw.xz
|
||
xz -d -c /tmp/talos.raw.xz | dd of=/dev/sda && sync && shutdown -h now
|
||
then snapshot that server in the console and label the snapshot os=talos.
|
||
The schematic and version above are this cluster's, read from cluster.yaml."
|
||
echo " snapshot $HCLOUD_IMAGE"
|
||
|
||
# The Talos API is unauthenticated in maintenance mode, which is how
|
||
# apply-config reaches a node that has no config yet. On a public address
|
||
# that has to be closed to everyone but this host for the minutes it takes.
|
||
step 0 "Creating a firewall for this host only..."
|
||
# Both families, and each with its own prefix length. The first version
|
||
# asked ifconfig.me once and appended /32: this host answered over IPv6, and
|
||
# Hetzner rejected `2003:d6:…:86d2/32` because /32 of that address is not a
|
||
# network boundary. A single-address rule is /32 for v4 and /128 for v6.
|
||
#
|
||
# Both are added rather than whichever happened to answer, because the rule
|
||
# has to admit the address talosctl actually leaves from, and that is chosen
|
||
# per connection by the resolver — not by whatever this lookup returned.
|
||
# No `local` here: this phase runs in the script body, not in a function,
|
||
# and `local` is a runtime error there — one `bash -n` does not catch.
|
||
# One family failing is ordinary — a host without IPv6 is not broken — so
|
||
# neither attempt aborts the run on its own. Their stderr is kept rather
|
||
# than discarded, and reported if BOTH fail: at that point the reason is the
|
||
# diagnosis, and "could not determine this host's address" without it sends
|
||
# you looking at the wrong thing.
|
||
fw_sources=()
|
||
fw_err=$(mktemp)
|
||
v4=$(curl -4 -fsS --max-time 10 https://ifconfig.me 2>>"$fw_err") && [[ -n "$v4" ]] && fw_sources+=("$v4/32")
|
||
v6=$(curl -6 -fsS --max-time 10 https://ifconfig.me 2>>"$fw_err") && [[ -n "$v6" ]] && fw_sources+=("$v6/128")
|
||
if [[ ${#fw_sources[@]} -eq 0 ]]; then
|
||
cat "$fw_err" >&2
|
||
rm -f "$fw_err"
|
||
fail "could not determine this host's public address over either IPv4 or IPv6"
|
||
fi
|
||
rm -f "$fw_err"
|
||
fw_src=$(IFS=,; echo "${fw_sources[*]}")
|
||
|
||
hcloud firewall create --name "hcloudtest-$$" --label "$HCLOUD_LABEL" >/dev/null \
|
||
|| fail "could not create the firewall"
|
||
hcloud firewall add-rule "hcloudtest-$$" --direction in --protocol tcp --port 50000 \
|
||
--source-ips "$fw_src" --description "Talos API, this host only" >/dev/null \
|
||
|| fail "could not add the Talos API rule"
|
||
hcloud firewall add-rule "hcloudtest-$$" --direction in --protocol tcp --port 6443 \
|
||
--source-ips "$fw_src" --description "Kubernetes API, this host only" >/dev/null \
|
||
|| fail "could not add the Kubernetes API rule"
|
||
echo " open to $fw_src only"
|
||
ok
|
||
|
||
# cx43 — 16 GB, 8 vCPU. Sized from what the workload needs, not from the
|
||
# Talos guide, which names cx22 as a minimal example (and that type no
|
||
# longer exists; `server create` rejects it outright).
|
||
#
|
||
# cx23 was tried first, on the reasoning that it is cx22's successor. It has
|
||
# 4 GB, 3.2 GB of it allocatable, and the three nodes sat at 59-78% of that
|
||
# with the infrastructure skeleton alone — no Ceph OSDs, no OpenBao running,
|
||
# not one app. Limits were already at 134-207% of allocatable and both
|
||
# kube-controller-manager and kube-scheduler had restarted twice.
|
||
#
|
||
# The local rehearsal is the reference: it runs a 16GiB control plane and
|
||
# three 8GiB workers, 40GiB in total, and needed every step of the way up
|
||
# from 6GiB to get there. Three nodes here carry the same workload plus a
|
||
# control plane each, so 48GiB total is the comparable figure.
|
||
#
|
||
# Constraints on the choice: cpx11/cpx21 are US-only and cax* are ARM, which
|
||
# would not match the amd64 schematic this cluster installs. That leaves the
|
||
# cx*/cpx*2 families in the German locations.
|
||
# Two segments only, unlike the homelab's split in
|
||
# NETWORK-traffic-classes.md: here the node address carries management,
|
||
# cluster-internal and external alike, and Ceph replication has its own.
|
||
#
|
||
# The order is load-bearing: it decides which kernel interface each segment
|
||
# becomes, and the machine config names those interfaces.
|
||
HCLOUD_SEGMENTS=(cluster storage)
|
||
# Segment ranges are read from what the cluster declares, never invented
|
||
# here — the disks' rule applied to networks:
|
||
# cluster the subnet the site patch pins node addresses to.
|
||
# storage the SAN pool the ceph-san attachment allocates from, widened
|
||
# to the containing /24 because the fabric wants a subnet.
|
||
# The fabric reserves the first address of a network range as its gateway.
|
||
# For the storage net that is the SAN pool's first address — a collision
|
||
# that matters only if cross-node SAN traffic on this site is ever
|
||
# attempted, and is left unresolved until it is.
|
||
seg_range() {
|
||
case "$1" in
|
||
cluster)
|
||
yq -r '.machine.kubelet.nodeIP.validSubnets[0]' "$TALOS_DIR/patches/site-hcloud.yaml" ;;
|
||
storage)
|
||
local pool
|
||
pool=$(yq -r '.spec.config' "$REPO_ROOT/kubernetes/infrastructure/rook-ceph-cluster/san-network-attachment.yaml" | jq -r '.ipam.range')
|
||
[[ "$pool" == *.0/2[4-9] ]] \
|
||
|| fail "cannot derive a /24 from the SAN pool '$pool'"
|
||
printf '%s/24' "${pool%.*}.0" ;;
|
||
esac
|
||
}
|
||
# Read from the location rather than stated a second time.
|
||
HCLOUD_ZONE=$(hcloud location describe "${HCLOUD_LOCATION:-fsn1}" -o format='{{.NetworkZone}}') \
|
||
|| fail "could not read the network zone of ${HCLOUD_LOCATION:-fsn1}"
|
||
[[ -n "$HCLOUD_ZONE" ]] \
|
||
|| fail "${HCLOUD_LOCATION:-fsn1} reported no network zone"
|
||
|
||
step 0 "Creating ${#HCLOUD_SEGMENTS[@]} private network(s) in $HCLOUD_ZONE..."
|
||
for seg in "${HCLOUD_SEGMENTS[@]}"; do
|
||
net="hcloudtest-$seg"
|
||
want_range=$(seg_range "$seg")
|
||
[[ -n "$want_range" && "$want_range" != null* ]] \
|
||
|| fail "could not derive the $seg range from the cluster config"
|
||
# Reused only if the range matches. A stale one fails later, at the
|
||
# attachment, naming the wrong thing.
|
||
if have_range=$(hcloud network describe "$net" -o format='{{.IPRange}}'); then
|
||
if [[ "$have_range" == "$want_range" ]]; then
|
||
echo " $net (already there)"
|
||
continue
|
||
fi
|
||
echo " $net carries $have_range, this run wants $want_range — replacing"
|
||
hcloud network delete "$net" >/dev/null \
|
||
|| fail "could not replace $net — detach its servers first, or run hcloudclean"
|
||
fi
|
||
hcloud network create --name "$net" --ip-range "$want_range" \
|
||
--label "$HCLOUD_LABEL" >/dev/null \
|
||
|| fail "could not create network $net"
|
||
hcloud network add-subnet "$net" --network-zone "$HCLOUD_ZONE" \
|
||
--type cloud --ip-range "$want_range" >/dev/null \
|
||
|| fail "could not add a subnet to $net"
|
||
echo " $net $want_range"
|
||
done
|
||
ok
|
||
|
||
# From here on machines exist and are billed; on_exit says so.
|
||
HCLOUD_PROVISIONED=1
|
||
step 0 "Creating ${#HCLOUD_NODES[@]} server(s)..."
|
||
NODE_ADDRS=()
|
||
# Servers this test already holds. Capacity is the binding constraint, so a
|
||
# machine in hand is worth keeping across runs.
|
||
#
|
||
# Listed by label: a same-named server without it falls through to the
|
||
# create path and fails on "name already used", rather than being adopted
|
||
# and wiped.
|
||
existing_servers=$(hcloud server list -l "$HCLOUD_LABEL" -o noheader -o columns=name)
|
||
for n in "${HCLOUD_NODES[@]}"; do
|
||
if grep -qx "$n" <<<"$existing_servers"; then
|
||
# Rebuilt, not reused as-is: a configured Talos rejects a fresh
|
||
# apply-config, and `talosctl reset` needs the previous run's PKI,
|
||
# which is gone. Rebuilding from the snapshot returns the node to
|
||
# maintenance mode, reachable without any old secret.
|
||
echo " $n: reusing the server we already hold"
|
||
hcloud server rebuild "$n" --image "$HCLOUD_IMAGE" >/dev/null \
|
||
|| fail "could not rebuild $n back into maintenance mode"
|
||
# The firewall is named per run (hcloudtest-$$), so an adopted
|
||
# server is still attached to the previous run's. Attach this run's
|
||
# too — without it the Talos API is unreachable once the old one is
|
||
# cleaned up, and the addresses in it are last run's anyway.
|
||
hcloud firewall apply-to-resource "hcloudtest-$$" --type server --server "$n" >/dev/null \
|
||
|| fail "could not attach this run's firewall to $n"
|
||
else
|
||
# Retried, because "error during placement (resource_unavailable)"
|
||
# is the provider being momentarily out of that type in that
|
||
# location, not a wrong request. It happened on the first cx43 run
|
||
# and the same type in the same location created fine minutes later.
|
||
# For a test meant to run on a schedule that distinction matters:
|
||
# without the retry every capacity blip is a red run someone has to
|
||
# come and look at.
|
||
create_err=""
|
||
for attempt in $(seq 1 30); do
|
||
create_err=$(hcloud server create --name "$n" --image "$HCLOUD_IMAGE" \
|
||
--type "${HCLOUD_TYPE:-cx43}" --location "${HCLOUD_LOCATION:-fsn1}" \
|
||
--firewall "hcloudtest-$$" --label "$HCLOUD_LABEL" 2>&1) && break
|
||
grep -qi 'resource_unavailable\|placement' <<<"$create_err" \
|
||
|| fail "could not create $n: ${create_err//$'\n'/ | }"
|
||
[[ $attempt -eq 1 ]] && echo " $n: no capacity yet, retrying"
|
||
sleep 20
|
||
[[ $attempt -eq 30 ]] && fail "no capacity for ${HCLOUD_TYPE:-cx43} in ${HCLOUD_LOCATION:-fsn1} after 10 minutes"
|
||
done
|
||
fi
|
||
addr=$(hcloud server ip "$n") || fail "could not read the address of $n"
|
||
[[ -n "$addr" ]] || fail "$n has no public address"
|
||
|
||
# Attached in HCLOUD_SEGMENTS order; the interface names follow from it.
|
||
# An adopted server keeps its attachments through a rebuild, and
|
||
# attaching twice is an error, so an existing one is skipped.
|
||
for seg in "${HCLOUD_SEGMENTS[@]}"; do
|
||
net="hcloudtest-$seg"
|
||
net_id=$(hcloud network describe "$net" -o json | jq -r '.id')
|
||
if hcloud server describe "$n" -o json \
|
||
| jq -e --argjson id "$net_id" 'any(.private_net[]; .network == $id)' >/dev/null; then
|
||
continue
|
||
fi
|
||
hcloud server attach-to-network "$n" --network "$net" >/dev/null \
|
||
|| fail "could not attach $n to $net"
|
||
done
|
||
|
||
# The OSD device. Every node carries one, which is the production shape
|
||
# — k8s-1/2/3 all hold storage — and is only possible here because
|
||
# Hetzner attaches volumes to any server, control plane included. The
|
||
# qemu provisioner cannot, which is why that site has to split into one
|
||
# control plane and three workers.
|
||
#
|
||
#
|
||
# Neither formatted nor mounted. Rook needs the raw device, and an
|
||
# --automount volume arrives with a filesystem on it that makes Ceph
|
||
# skip the disk as already in use.
|
||
#
|
||
# Recreated rather than reused, even on an adopted server: a volume from
|
||
# the previous run carries a bluestore signature, and Rook adopts a
|
||
# device that already looks like an OSD. Volumes are not the scarce
|
||
# resource here; servers are.
|
||
if hcloud volume describe "$n-osd" >/dev/null; then
|
||
echo " $n: discarding the previous OSD volume"
|
||
# An unattached leftover is ordinary after an interrupted run, so
|
||
# this failure is tolerated while the delete below is not.
|
||
hcloud volume detach "$n-osd" >/dev/null || true
|
||
hcloud volume delete "$n-osd" >/dev/null \
|
||
|| fail "could not delete the previous OSD volume for $n"
|
||
fi
|
||
vol_size=$(hcloud_volumes_for_node "$n")
|
||
[[ -n "$vol_size" ]] || fail "$n declares no RawVolumeConfig — a node with no OSD device is not this cluster's shape"
|
||
hcloud volume create --name "$n-osd" --size "$vol_size" --server "$n" \
|
||
--label "$HCLOUD_LABEL" >/dev/null \
|
||
|| fail "could not create the OSD volume for $n"
|
||
|
||
echo " $n at $addr, with a ${vol_size}GB OSD volume"
|
||
NODE_ADDRS+=("$addr")
|
||
done
|
||
ok
|
||
|
||
# The nodes have to reach each other, and until now the firewall admitted
|
||
# only this host. One node did not care; three do — etcd peers on 2380, the
|
||
# apiserver, kubelet on 10250 and Cilium's VXLAN all run between them, and
|
||
# with the rules above alone etcd simply never forms a quorum.
|
||
#
|
||
# Added after creation because the addresses do not exist before it. Rules
|
||
# apply to an attached firewall immediately, so the ordering costs nothing.
|
||
#
|
||
# All ports between the nodes rather than an enumerated list: this is a
|
||
# throwaway cluster whose members are each other's only peers, and guessing
|
||
# the full port set of Talos plus Cilium is how a rebuild fails on the one
|
||
# port nobody wrote down. The outside still sees only 50000 and 6443, and
|
||
# only from this host.
|
||
step 0 "Opening the cluster to itself..."
|
||
node_src=$(IFS=,; printf '%s' "${NODE_ADDRS[*]/%//32}")
|
||
for proto in tcp udp; do
|
||
hcloud firewall add-rule "hcloudtest-$$" --direction in --protocol "$proto" \
|
||
--port any --source-ips "$node_src" \
|
||
--description "intra-cluster $proto" >/dev/null \
|
||
|| fail "could not open $proto between the nodes"
|
||
done
|
||
echo " $node_src"
|
||
ok
|
||
|
||
# Handover, and this list is the whole of it — the same four variables the
|
||
# vmtest phase sets. If it grows, the test is drifting away from production.
|
||
SITE=hcloud
|
||
NODES=("${HCLOUD_NODES[@]}")
|
||
WORKERS=()
|
||
ALL_NODES=("${NODES[@]}")
|
||
K8S_API_HOST="${NODE_ADDRS[0]}"
|
||
|
||
# Isolation from the production cluster. Both halves are required: the
|
||
# _PATH variables are derived before any phase runs, and the talos phase
|
||
# writes to those.
|
||
HCLOUD_DIR=$(mktemp -d)
|
||
export TALOSCONFIG="$HCLOUD_DIR/talosconfig"
|
||
export KUBECONFIG="$HCLOUD_DIR/kubeconfig"
|
||
TALOSCONFIG_PATH="$TALOSCONFIG"
|
||
KUBECONFIG_PATH="$KUBECONFIG"
|
||
|
||
PHASE=talos
|
||
fi
|
||
|
||
# Provision throwaway QEMU VMs, then run the Talos phase below against
|
||
# them unchanged. Deliberately not a copy of that phase: it sets the site
|
||
# variables and falls through, because a rehearsal that runs different code
|
||
# proves less than one that does not. Everything it changes is listed here — if
|
||
# that list grows, the test is drifting away from what it claims to exercise.
|
||
#
|
||
# What it cannot cover, and what therefore still has to be true on hardware:
|
||
# Ceph (the provisioner gives extra disks to workers only), the mgmt and v30iot
|
||
# VLANs and the Layer2 VIP (one flat NIC per VM), and Flux (its own phase).
|
||
#
|
||
# Needs root for the QEMU provisioner (CNI and KVM), and only for that step.
|
||
if [[ "$PHASE" == "vmtest" ]]; then
|
||
echo -e "${YELLOW}=== Phase: VM rebuild test ===${NC}"
|
||
command -v talosctl >/dev/null || fail "talosctl not found"
|
||
command -v yq >/dev/null || fail "yq not found"
|
||
command -v jq >/dev/null || fail "jq not found (needed to read OpenBao's seal status)"
|
||
# The node patches are the list; the VM count follows from them rather than
|
||
# being stated twice. vm-1 is the control plane, the rest are the workers
|
||
# that carry an OSD device — see the comment on NODES below for why.
|
||
VM_CP_NODES=(vm-1)
|
||
VM_WORKER_NODES=(vm-2 vm-3 vm-4)
|
||
VM_CPS=${#VM_CP_NODES[@]}
|
||
VM_WORKER_COUNT=${#VM_WORKER_NODES[@]}
|
||
for n in "${VM_CP_NODES[@]}" "${VM_WORKER_NODES[@]}"; do
|
||
[[ -f "$TALOS_DIR/patches/$n.yaml" ]] || fail "$TALOS_DIR/patches/$n.yaml not found"
|
||
done
|
||
[[ -f "$TALOS_DIR/patches/site-qemu.yaml" ]] || fail "site-qemu.yaml not found"
|
||
|
||
# Talos version and Image Factory schematic, from the installer image in
|
||
# cluster.yaml — see talos_image_coords above for why neither phase states
|
||
# them itself.
|
||
talos_image_coords
|
||
|
||
# sun_path caps a unix socket path at 108 bytes and the provisioner puts a
|
||
# qemu monitor socket under the state directory, so this has to stay short.
|
||
# A path under the repo or a long mktemp dir silently fails to boot.
|
||
# Overridable because in-cluster the VM disks belong on the mounted scratch
|
||
# volume, not on the container's own writable layer. Keep whatever is set
|
||
# SHORT for the reason above.
|
||
VM_STATE="${VM_STATE:-/tmp/bootstrap-vmtest}"
|
||
[[ ${#VM_STATE} -le 50 ]] \
|
||
|| fail "VM_STATE '$VM_STATE' is ${#VM_STATE} bytes; keep it under 50 so the qemu monitor socket fits in sun_path's 108"
|
||
# cleanup_vmtest runs `sudo rm -rf "$VM_STATE"`, so refuse anything that is
|
||
# not a nested absolute path.
|
||
[[ $VM_STATE == /*/?* ]] \
|
||
|| fail "VM_STATE '$VM_STATE' must be an absolute path at least two levels deep"
|
||
VM_CLUSTER=bootstrap-vmtest
|
||
VM_DIR=$(mktemp -d)
|
||
TALOSCTL_BIN=$(command -v talosctl)
|
||
|
||
# Hard isolation from the production cluster: talosctl and kubectl both
|
||
# honour these, and the phase below writes to them rather than to $HOME.
|
||
export TALOSCONFIG="$VM_DIR/talosconfig"
|
||
export KUBECONFIG="$VM_DIR/kubeconfig"
|
||
TALOSCONFIG_PATH="$TALOSCONFIG"
|
||
KUBECONFIG_PATH="$KUBECONFIG"
|
||
|
||
# The provisioner needs root for KVM and for the bridges and taps it builds.
|
||
# On a workstation that means sudo; in the Job that runs this in-cluster the
|
||
# process is already root and sudo is not installed, so the elevation is a
|
||
# variable rather than a literal.
|
||
SUDO_KEEPALIVE=""
|
||
if [[ $EUID -eq 0 ]]; then
|
||
SUDO=""
|
||
else
|
||
SUDO="sudo -E"
|
||
# A full run outlasts sudo's timestamp, and the teardown at the end then
|
||
# prompts for a password with nobody watching — the VMs survive and the
|
||
# next run collides with them. Take it once here and keep the timestamp
|
||
# warm for as long as the phase lasts.
|
||
sudo -v || fail "the QEMU provisioner needs root (CNI and KVM)"
|
||
( while sudo -n true; do sleep 50; done ) &
|
||
SUDO_KEEPALIVE=$!
|
||
fi
|
||
|
||
cleanup_vmtest() {
|
||
[[ -n "$SUDO_KEEPALIVE" ]] && kill "$SUDO_KEEPALIVE" >/dev/null || true
|
||
# Diagnosing anything inside the VMs is impossible once they are gone,
|
||
# and the teardown fires the moment a run ends — including when it ends
|
||
# badly, which is exactly when the VMs are worth looking at. VMTEST_KEEP
|
||
# holds them, and the pod, until someone deletes the Job.
|
||
if [[ "${VMTEST_KEEP:-0}" == "1" ]]; then
|
||
echo -e "${YELLOW}VMTEST_KEEP=1 — leaving the VMs up.${NC}"
|
||
echo " talosctl --talosconfig $TALOSCONFIG -n 10.5.0.2 ..."
|
||
echo " delete the Job when finished; nothing else reclaims this."
|
||
sleep infinity
|
||
fi
|
||
echo -n "Destroying throwaway VMs... "
|
||
$SUDO "$TALOSCTL_BIN" cluster destroy --name "$VM_CLUSTER" --state "$VM_STATE" >/dev/null || true
|
||
# destroy removes the cluster's own directory but leaves the state root.
|
||
$SUDO rm -rf "$VM_STATE"
|
||
rm -rf "$VM_DIR"
|
||
echo "done"
|
||
}
|
||
# No `trap ... EXIT` here — on_exit calls cleanup_vmtest once it is defined.
|
||
|
||
# A run killed hard never reaches its EXIT trap, so the previous cluster can
|
||
# still be holding the provisioner's CIDR. Clear it first rather than
|
||
# failing on a collision that looks like a networking problem.
|
||
if [[ -d "$VM_STATE/$VM_CLUSTER" ]]; then
|
||
echo -n "Removing a cluster left behind by an earlier run... "
|
||
$SUDO "$TALOSCTL_BIN" cluster destroy --name "$VM_CLUSTER" --state "$VM_STATE" >/dev/null || true
|
||
$SUDO rm -rf "$VM_STATE"
|
||
echo "done"
|
||
fi
|
||
|
||
step 0 "Creating $VM_CPS control plane and $VM_WORKER_COUNT workers in maintenance mode..."
|
||
# maintenance means the provisioner applies no configuration of its own, so
|
||
# every byte of machine config below comes from talos/patches.
|
||
#
|
||
# The control plane gets 6GiB. At 2560MiB it ran with ~250MiB available and
|
||
# its page cache squeezed to 386MiB, so every etcd fsync hit a system in
|
||
# permanent reclaim: linearized reads took 5-20s against an expected 100ms,
|
||
# the apiserver became unreachable, and the run stalled with no error to
|
||
# read. That failure says nothing about whether the repo can rebuild a
|
||
# cluster, so it is bought off with memory rather than tuned around.
|
||
#
|
||
# The workers get 8GiB for the same reason. At 4GiB a storage node sat at
|
||
# 99% of its memory requests once it carried an OSD, a mon and a mgr, and
|
||
# what could not be scheduled any more was kube-multus-ds and whereabouts —
|
||
# the CNI itself. Without Multus the OSDs came up with no ceph-san
|
||
# interface and crash-looped on "unable to find any IPv4 address in
|
||
# networks 10.99.0.0/29", which reads like an addressing bug and is a
|
||
# memory one. Sizing the VMs to the workload they actually run is cheaper
|
||
# than shrinking the Ceph requests, which would test something other than
|
||
# what production runs.
|
||
#
|
||
# Workers get 4 CPUs, one constraint later: with memory solved they sat at
|
||
# 69% of CPU requests, and what could no longer schedule was the
|
||
# rook-ceph-operator itself. Nothing was left driving the cluster — two
|
||
# OSDs crash-looped unattended and the PVCs waited on a provisioner whose
|
||
# operator was Pending.
|
||
#
|
||
# The disks are sized the same way. At 10GiB the system disk yielded an
|
||
# 8.36GB /var, which the infrastructure image set filled to 82%: kubelet
|
||
# tainted all four nodes node.kubernetes.io/disk-pressure, no pod could be
|
||
# scheduled any more, and Ceph's own monitor sat Pending while the symptom
|
||
# surfaced three layers away as a Cilium HelmRelease stuck InProgress. The
|
||
# second disk is the Ceph OSD; at 8GiB, three of them replicated 3x left
|
||
# 8GiB usable, less than OpenBao's two PVCs alone ask for. Both are raw
|
||
# sparse files, so the larger geometry costs only what is actually written.
|
||
#
|
||
# The control plane gets 16GiB. 6 was not enough and neither was 8: at both
|
||
# it reached 99% of allocatable memory once the infrastructure layer was up,
|
||
# and a control plane with nothing left over does not stay a control plane.
|
||
# The kubelet admits the node-critical CNI DaemonSets by evicting whatever
|
||
# ranks below them, and kube-apiserver — a static pod at
|
||
# system-cluster-critical — is what ranks below them:
|
||
#
|
||
# message: Pod was preempted by Kubelet to accommodate a critical pod.
|
||
# reason: Preempting
|
||
#
|
||
# The priority ordering is not the defect and must not be tuned away. It is
|
||
# deliberate: a node without its CNI cannot run anything, so the CNI wins.
|
||
# What the eviction reports is the shortfall underneath it, and the only
|
||
# honest fix is to remove the shortfall. Production does this by having
|
||
# ~95GiB per node; the throwaway cluster does it here.
|
||
#
|
||
# This is not a spare-capacity argument for the workers. vm-1 is the only
|
||
# control plane and still takes workload, so it carries the static pods, the
|
||
# CNI, and its share of the infrastructure layer at once — which is the
|
||
# asymmetry with production, where every node is a control plane and none of
|
||
# them is small.
|
||
$SUDO "$TALOSCTL_BIN" cluster create qemu \
|
||
--name "$VM_CLUSTER" \
|
||
--state "$VM_STATE" \
|
||
--presets iso,maintenance \
|
||
--controlplanes "$VM_CPS" \
|
||
--workers "$VM_WORKER_COUNT" \
|
||
--talos-version "$VM_VERSION" \
|
||
--schematic-id "$VM_SCHEMATIC" \
|
||
--memory-controlplanes 16GiB \
|
||
--memory-workers 8GiB \
|
||
--cpus-controlplanes 2 \
|
||
--cpus-workers 4 \
|
||
--disks virtio:40GiB,virtio:24GiB \
|
||
--talosconfig-destination "$TALOSCONFIG" \
|
||
|| fail "could not create the throwaway VMs"
|
||
$SUDO chown -R "$(id -u):$(id -g)" "$VM_STATE" "$TALOSCONFIG"
|
||
ok
|
||
|
||
# The provisioner hands out its CIDR sequentially from .2; .1 is the
|
||
# gateway and the load balancer it puts in front of the API. If that ever
|
||
# changes, the apply loop below reports which address stopped answering
|
||
# rather than failing silently.
|
||
# One control plane and three workers, not three control planes: the
|
||
# provisioner gives the second disk only to workers ("disks after the first
|
||
# one are added only to worker machines"), and an OSD needs it. Three of
|
||
# them because the CephCluster runs three mons with allowMultiplePerNode
|
||
# false over pools that are replica 3 across failureDomain host.
|
||
NODES=("${VM_CP_NODES[@]}")
|
||
WORKERS=("${VM_WORKER_NODES[@]}")
|
||
ALL_NODES=("${NODES[@]}" "${WORKERS[@]}")
|
||
NODE_ADDRS=()
|
||
for i in $(seq 0 $(( ${#ALL_NODES[@]} - 1 ))); do
|
||
NODE_ADDRS+=("10.5.0.$(( i + 2 ))")
|
||
done
|
||
SITE=qemu
|
||
K8S_API_HOST=10.5.0.1
|
||
VMTEST=1
|
||
|
||
# Run the real phases, not a reimplementation of them.
|
||
#
|
||
# Default is the Talos phase alone: that needs nothing but the machines, so
|
||
# it is the part that can run unattended. VMTEST_FULL=1 continues into flux
|
||
# and wait-apps — the whole bootstrap against VMs — which additionally needs
|
||
# GITEA_TOKEN and a FLUX_BRANCH to write to, because `flux bootstrap`
|
||
# commits its sync manifests into the branch it reconciles.
|
||
if [[ "${VMTEST_FULL:-0}" == "1" ]]; then
|
||
[[ "$FLUX_BRANCH" != "master" ]] \
|
||
|| fail "VMTEST_FULL would have flux bootstrap commit into master — point FLUX_BRANCH at a throwaway branch that already exists"
|
||
echo -e "${YELLOW}Continuing into the flux phase against branch ${FLUX_BRANCH}.${NC}"
|
||
PHASE=all
|
||
else
|
||
PHASE=talos
|
||
fi
|
||
fi
|
||
|
||
# Before anything destructive, and after the vmtest block has decided which
|
||
# phases will run. A rebuild that will need these has no business wiping disks
|
||
# first, only to discover an hour later that pass is locked or a token is unset —
|
||
# by then the old cluster is gone and the new one cannot be finished.
|
||
#
|
||
# In the Job this is where the file-waiting branch blocks, which is the right
|
||
# moment too: the operator has just started the run and is watching it.
|
||
if phase_runs flux; then
|
||
require_secrets
|
||
fi
|
||
|
||
if phase_runs talos; then
|
||
echo -e "${YELLOW}=== Phase: Talos bootstrap ===${NC}"
|
||
command -v talosctl >/dev/null || fail "talosctl not found"
|
||
command -v kubectl >/dev/null || fail "kubectl not found"
|
||
command -v helm >/dev/null || fail "helm not found (needed to install the CNI)"
|
||
command -v yq >/dev/null || fail "yq not found (needed to read the Cilium HelmRelease)"
|
||
[[ -f "$TALOS_DIR/patches/cluster.yaml" ]] || fail "cluster.yaml patch not found"
|
||
[[ -f "$TALOS_DIR/patches/containerd-layer-retention.yaml" ]] || fail "containerd-layer-retention.yaml patch not found"
|
||
[[ -f "$CILIUM_HR" ]] || fail "cilium helmrelease not found at $CILIUM_HR"
|
||
|
||
step 0 "Generating Talos configs..."
|
||
TALOS_GEN_DIR=$(mktemp -d)
|
||
talosctl gen config homelab "https://${K8S_API_HOST}:6443" \
|
||
--output "$TALOS_GEN_DIR" \
|
||
--force
|
||
mkdir -p "$(dirname "$TALOSCONFIG_PATH")"
|
||
cp "$TALOS_GEN_DIR/talosconfig" "$TALOSCONFIG_PATH"
|
||
|
||
# Lets a node pull the images it needs before the registry that normally
|
||
# serves them exists.
|
||
#
|
||
# Several apps need an image while the cluster is still being built —
|
||
# paperless's fetch-bridge-cert init container, zigbee2mqtt's seed-data init
|
||
# container, the trmnl renderer, and the CNI plugins every pod depends on.
|
||
# They are referenced at gitea.renner.uno, which is correct for normal
|
||
# operation and useless during a rebuild, because Gitea runs inside the
|
||
# cluster being rebuilt.
|
||
#
|
||
# `mirrors` gives containerd an ordered list to try. Gitea is named first, so
|
||
# nothing changes once the cluster is up: pulls stay local and never leave
|
||
# the network. ghcr.io is second and only answers when the first cannot,
|
||
# which is exactly the rebuild window. The manifests therefore never name an
|
||
# external registry — the fallback is a property of the node, not of the
|
||
# deployment.
|
||
#
|
||
# Containerd keeps the repository path when it falls through, which is why
|
||
# the images are published under konairius/ on BOTH registries
|
||
# (.woodpecker/build-ci-images.yml). Verified equal: the same build pushes
|
||
# one digest to both paths.
|
||
#
|
||
# The ghcr namespace is private, so it needs a credential — and the usual
|
||
# place for one, an imagePullSecret, fails here twice over: it would have to
|
||
# exist in five namespaces before any of them exists, and it would come from
|
||
# OpenBao, which needs Ceph, which needs these very images.
|
||
# machine.registries.config is containerd's own auth, in place before the
|
||
# first pod is scheduled and applying to every pull on the node regardless
|
||
# of namespace. Field names follow .docker/config.json
|
||
# (pkg/machinery/config/types/v1alpha1, RegistryAuthConfig).
|
||
#
|
||
# The patch is written into the in-memory secrets dir, never into the repo
|
||
# or the generated config directory, and dies with the Job like the material
|
||
# it carries.
|
||
registry_auth_patch() {
|
||
local out="$OPENBAO_SECRETS_DIR/registry-auth.yaml" token
|
||
token=$(read_secret ghcr-pull) || fail "could not read the GHCR pull token"
|
||
( umask 077; cat > "$out" <<EOF
|
||
machine:
|
||
registries:
|
||
mirrors:
|
||
gitea.renner.uno:
|
||
endpoints:
|
||
- https://gitea.renner.uno
|
||
- https://ghcr.io
|
||
config:
|
||
ghcr.io:
|
||
auth:
|
||
username: "${GHCR_PULL_USER}"
|
||
password: "${token}"
|
||
EOF
|
||
) || fail "could not write the registry auth patch"
|
||
printf '%s' "$out"
|
||
}
|
||
# OUTSIDE the command substitution below, and that placement is the fix for
|
||
# a bug this had: require_secrets prints "All secrets present." on stdout,
|
||
# and calling it inside registry_auth_patch made that line part of the
|
||
# captured path. talosctl then reported
|
||
# open All secrets present. | /tmp/…/registry-auth.yaml: no such file
|
||
# and retried 60 times while the node sat in maintenance. A function whose
|
||
# value is its stdout must be the only thing writing there.
|
||
#
|
||
# This phase can run on its own (`bootstrap.sh talos`), in which case
|
||
# nothing has demanded the secrets yet — the full require_secrets is gated
|
||
# on the flux phase. Ask for the one this needs, and only that one: pulling
|
||
# in the whole list would make an unattended talos run depend on the OpenBao
|
||
# seal key, which never reaches CI.
|
||
require_secrets ghcr-pull
|
||
REGISTRY_PATCH=$(registry_auth_patch)
|
||
[[ -s "$REGISTRY_PATCH" ]] \
|
||
|| fail "the registry auth patch was not written to a readable file: '$REGISTRY_PATCH'"
|
||
|
||
step 0 "Applying Talos configs to nodes..."
|
||
apply_node_config() {
|
||
local node=$1 base_config=$2 fixed_addr=${3:-}
|
||
local fqdn="${node}.${DOMAIN}"
|
||
echo " ${fixed_addr:-$fqdn}:"
|
||
for i in $(seq 1 60); do
|
||
# A fixed address skips DNS. Real hardware has a name to resolve;
|
||
# a throwaway VM has an address and nothing else.
|
||
if [[ -n "$fixed_addr" ]]; then
|
||
node_ip="$fixed_addr"
|
||
else
|
||
node_ip=$(resolve_node_ip "$fqdn")
|
||
fi
|
||
if [[ -z "$node_ip" ]]; then
|
||
echo " attempt $i/60: DNS returned no IP"
|
||
sleep 10
|
||
continue
|
||
fi
|
||
if err=$(talosctl apply-config --insecure -n "$node_ip" \
|
||
--file "$base_config" \
|
||
--config-patch "@$TALOS_DIR/patches/cluster.yaml" \
|
||
--config-patch "@$TALOS_DIR/patches/site-${SITE}.yaml" \
|
||
--config-patch "@$TALOS_DIR/patches/containerd-layer-retention.yaml" \
|
||
--config-patch "@$TALOS_DIR/patches/${node}.yaml" \
|
||
--config-patch "@$REGISTRY_PATCH" 2>&1); then
|
||
echo -e " applied via $node_ip ${GREEN}(OK)${NC}"
|
||
return 0
|
||
fi
|
||
echo " attempt $i/60: $node_ip - ${err//$'\n'/ | }"
|
||
sleep 10
|
||
done
|
||
fail "Timeout waiting for ${fixed_addr:-$fqdn}"
|
||
}
|
||
|
||
# One loop over ALL_NODES rather than one per role, so NODE_ADDRS can be a
|
||
# single list in the same order. Control planes come first in ALL_NODES,
|
||
# which is what decides the base config each node gets.
|
||
for idx in "${!ALL_NODES[@]}"; do
|
||
if [[ $idx -lt ${#NODES[@]} ]]; then
|
||
base="$TALOS_GEN_DIR/controlplane.yaml"
|
||
else
|
||
base="$TALOS_GEN_DIR/worker.yaml"
|
||
fi
|
||
apply_node_config "${ALL_NODES[$idx]}" "$base" "${NODE_ADDRS[$idx]:-}"
|
||
done
|
||
|
||
echo -n "Configuring talosctl... "
|
||
# `gen config` leaves talosconfig endpoints empty; they are the Talos API
|
||
# addresses and unrelated to cluster.controlPlane.endpoint.
|
||
if [[ ${#NODE_ADDRS[@]} -gt 0 ]]; then
|
||
talosctl config endpoint "${NODE_ADDRS[@]}"
|
||
talosctl config node "${NODE_ADDRS[0]}"
|
||
else
|
||
talosctl config endpoint "${NODES[@]/%/.${DOMAIN}}"
|
||
talosctl config node "${NODES[0]}.${DOMAIN}"
|
||
fi
|
||
ok
|
||
|
||
step 0 "Waiting for every node to come back configured..."
|
||
# Asks each machine directly, by address, and expects it to answer with the
|
||
# hostname its patch assigns. That is what has to be true before etcd is
|
||
# bootstrapped: the config took, the node rebooted into it, and the machine
|
||
# at that address is the one we think it is.
|
||
#
|
||
# This used to wait on `talosctl get members`, which was wrong twice over.
|
||
# It queried through the configured endpoint list rather than each node, so
|
||
# apid refused it with `PermissionDenied: no request forwarding` — the same
|
||
# thing that broke the bootstrap call. And membership comes from the
|
||
# discovery service, because the Kubernetes registry is disabled: a cold
|
||
# rebuild would have been waiting on discovery.talos.dev being reachable,
|
||
# before the cluster it is trying to rebuild even exists.
|
||
#
|
||
# Each node installs to disk and reboots first, so the window is generous.
|
||
for idx in "${!ALL_NODES[@]}"; do
|
||
want="${ALL_NODES[$idx]}"
|
||
addr="${NODE_ADDRS[$idx]:-${want}.${DOMAIN}}"
|
||
echo -n " $want ($addr)... "
|
||
node_err=""
|
||
for i in $(seq 1 60); do
|
||
got=$(talosctl -n "$addr" -e "$addr" get hostname \
|
||
-o jsonpath='{.spec.hostname}' 2>"$TALOS_GEN_DIR/hostname.err") || true
|
||
node_err=$(cat "$TALOS_GEN_DIR/hostname.err")
|
||
[[ "$got" == "$want" ]] && break
|
||
sleep 10
|
||
if [[ $i -eq 60 ]]; then
|
||
echo
|
||
[[ -n "$node_err" ]] && echo " last talosctl error: $node_err" >&2
|
||
fail "$addr never came back as $want (last answer: '${got:-none}')"
|
||
fi
|
||
done
|
||
ok
|
||
done
|
||
|
||
echo -n "Bootstrapping Kubernetes... "
|
||
# -n AND -e on one control plane, deliberately. etcd bootstrap is the one
|
||
# call apid refuses to forward: reached through any other node it fails with
|
||
# `PermissionDenied: no request forwarding`. Without them talosctl picks an
|
||
# endpoint from the configured list — with more than one control plane, or a
|
||
# list that includes workers, that is a coin flip.
|
||
if [[ ${#NODE_ADDRS[@]} -gt 0 ]]; then
|
||
bootstrap_target="${NODE_ADDRS[0]}"
|
||
else
|
||
bootstrap_target="${NODES[0]}.${DOMAIN}"
|
||
fi
|
||
# Re-running against an already-bootstrapped cluster is not an error — that
|
||
# is why this does not simply fail on a non-zero exit. Anything that is not
|
||
# "already exists" is shown rather than swallowed, because a silent failure
|
||
# here surfaces much later as an API that never answers.
|
||
#
|
||
# And it RETRIES, because "bootstrap is not available yet" is a moment, not
|
||
# a fault. The wait above only establishes that the node answers with its
|
||
# hostname; etcd accepting the bootstrap call comes later, and how much
|
||
# later depends on the machine. On qemu the gap is short enough to miss. On
|
||
# a Hetzner cloud server it is not: the single attempt failed with
|
||
# FailedPrecondition, the run carried on regardless, and then sat out its
|
||
# full timeout waiting for an apiserver that could never start because etcd
|
||
# had never been bootstrapped.
|
||
bootstrap_err=""
|
||
for i in $(seq 1 60); do
|
||
if bootstrap_err=$(talosctl bootstrap -n "$bootstrap_target" -e "$bootstrap_target" 2>&1); then
|
||
break
|
||
fi
|
||
if grep -qi 'already\|AlreadyExists' <<<"$bootstrap_err"; then
|
||
bootstrap_err=""
|
||
break
|
||
fi
|
||
# Not yet ready is the expected answer for the first attempts; anything
|
||
# else is reported once and then retried anyway, since the alternative
|
||
# is failing on a condition that may still clear.
|
||
if ! grep -qi 'not available yet\|connection refused\|FailedPrecondition' <<<"$bootstrap_err"; then
|
||
[[ $i -eq 1 ]] && echo -e "\n${YELLOW}bootstrap reported:${NC} ${bootstrap_err//$'\n'/ | }"
|
||
fi
|
||
sleep 10
|
||
[[ $i -eq 60 ]] && { echo; echo "${bootstrap_err//$'\n'/ | }" >&2; fail "etcd never accepted the bootstrap call"; }
|
||
done
|
||
ok
|
||
|
||
echo -n "Getting kubeconfig... "
|
||
kubeconfig_err=""
|
||
for i in $(seq 1 30); do
|
||
mkdir -p "$(dirname "$KUBECONFIG_PATH")"
|
||
if kubeconfig_err=$(talosctl kubeconfig "$KUBECONFIG_PATH" --force 2>&1); then
|
||
sed -i "s|server:.*|server: https://${K8S_API_HOST}:6443|" "$KUBECONFIG_PATH"
|
||
break
|
||
fi
|
||
sleep 5
|
||
# Held back while retrying — the first attempts fail by design — but
|
||
# printed if it never works, because that message is the diagnosis.
|
||
[[ $i -eq 30 ]] && { echo; echo "$kubeconfig_err" >&2; fail "Couldn't get kubeconfig"; }
|
||
done
|
||
ok
|
||
|
||
echo -n "Waiting for the Kubernetes API to answer... "
|
||
# Having a kubeconfig is not the same as having an apiserver. `talosctl
|
||
# kubeconfig` succeeds as soon as Talos can hand out credentials, which is
|
||
# before kube-apiserver is serving on the endpoint the file points at — so
|
||
# the very next command could fail with an EOF against a port nothing is
|
||
# listening on yet. On hardware the gap usually closes on its own; on a
|
||
# scratch cluster it does not, which is how this was found.
|
||
# `get --raw /version` needs the server; `kubectl version` would answer from
|
||
# the client alone and pass against nothing at all.
|
||
api_err=""
|
||
for i in $(seq 1 60); do
|
||
api_err=$(kubectl get --raw /version 2>&1) && break
|
||
sleep 5
|
||
if [[ $i -eq 60 ]]; then
|
||
echo "$api_err" >&2
|
||
fail "Kubernetes API at https://${K8S_API_HOST}:6443 never answered"
|
||
fi
|
||
done
|
||
ok
|
||
|
||
step 0 "Installing Cilium (CNI)..."
|
||
# Must run before the node-Ready wait below: with `cni: none` the kubelet
|
||
# reports NotReady until a CNI is present, so that loop would time out.
|
||
#
|
||
# Chart repo, version constraint and values all come out of the HelmRelease
|
||
# so this cannot drift from what Flux will reconcile minutes later. helm
|
||
# accepts the semver range verbatim and resolves it the same way Flux does.
|
||
#
|
||
# serviceMonitor is the one override, and it is a bootstrap constraint
|
||
# rather than a value: its CRD arrives with the monitoring stack much
|
||
# later, and the object would fail to apply. Nothing else is overridden,
|
||
# which is what lets helm-controller adopt this release instead of
|
||
# replacing it.
|
||
CILIUM_VALUES=$(mktemp)
|
||
yq 'select(.kind == "HelmRelease") | .spec.values' "$CILIUM_HR" > "$CILIUM_VALUES"
|
||
grep -q 'policyMapMax' "$CILIUM_VALUES" \
|
||
|| fail "extracted Cilium values look wrong (no policyMapMax) — check $CILIUM_HR"
|
||
|
||
helm repo add cilium "$(yq -r 'select(.kind == "HelmRepository") | .spec.url' "$CILIUM_HR")" >/dev/null
|
||
helm repo update cilium >/dev/null
|
||
helm upgrade --install cilium cilium/cilium \
|
||
--version "$(yq -r 'select(.kind == "HelmRelease") | .spec.chart.spec.version' "$CILIUM_HR")" \
|
||
--namespace kube-system \
|
||
-f "$CILIUM_VALUES" \
|
||
--set prometheus.serviceMonitor.enabled=false \
|
||
--wait --timeout 10m
|
||
rm -f "$CILIUM_VALUES"
|
||
ok
|
||
|
||
step 0 "Waiting for K8s nodes..."
|
||
expected=${#ALL_NODES[@]}
|
||
# Before the loop, because 0 == 0 would satisfy its exit condition on the
|
||
# first pass and report a complete Talos phase for a cluster with no nodes
|
||
# in it. `expected` comes from ALL_NODES, which a provisioning phase hands
|
||
# over — an empty one is a bug in that phase, not a cluster that is ready.
|
||
[[ "$expected" -gt 0 ]] \
|
||
|| fail "ALL_NODES is empty, so this wait would pass against any cluster, including one with no nodes"
|
||
nodes_err=""
|
||
for i in $(seq 1 60); do
|
||
# The STATUS column, matched whole. `grep -c Ready` counted NotReady
|
||
# too — the substring is in it — so this waited for nodes to register
|
||
# rather than to become ready, and passed while every one of them was
|
||
# still NotReady.
|
||
nodes_out=$(kubectl get nodes --no-headers 2>"$TALOS_GEN_DIR/nodes.err") || true
|
||
nodes_err=$(cat "$TALOS_GEN_DIR/nodes.err")
|
||
ready=$(awk '$2 == "Ready"' <<<"$nodes_out" | grep -c . || true)
|
||
if [[ "$ready" -eq "$expected" ]]; then
|
||
# Say what was accepted. Silence on success made this step
|
||
# unauditable: a run that printed "Waiting for K8s nodes..." and
|
||
# then "Talos phase complete" was indistinguishable from one that
|
||
# matched 0 against 0, and the log gave no way to tell which had
|
||
# happened. A count that is also the pass condition is the one
|
||
# number worth printing.
|
||
echo "$ready/$expected Ready"
|
||
break
|
||
fi
|
||
sleep 5
|
||
if [[ $i -eq 60 ]]; then
|
||
echo
|
||
[[ -n "$nodes_err" ]] && echo "last kubectl error: $nodes_err" >&2
|
||
echo "${nodes_out:- (no nodes listed)}" >&2
|
||
fail "Not all nodes ready (got $ready/$expected)"
|
||
fi
|
||
done
|
||
|
||
# Stash configs for subsequent phases / export-access step. Skipped under
|
||
# vmtest: those are a throwaway cluster's credentials, and writing them here
|
||
# would leave the flux phase pointed at VMs that no longer exist.
|
||
if [[ "${VMTEST:-0}" == "1" ]]; then
|
||
# Nodes being Ready says nothing about storage. The CephCluster picks
|
||
# OSD nodes by a label out of the machine config and their device by the
|
||
# partition label RawVolumeConfig creates, so assert both here — running
|
||
# it by hand afterwards races the teardown, and a test that does not
|
||
# check its own premise is worth little.
|
||
step 0 "Checking the OSD prerequisites Ceph selects on..."
|
||
for idx in $(seq "${#NODES[@]}" $(( ${#ALL_NODES[@]} - 1 ))); do
|
||
vm_node="${ALL_NODES[$idx]}"
|
||
vm_addr="${NODE_ADDRS[$idx]}"
|
||
vols=$(talosctl -n "$vm_addr" -e "$vm_addr" get discoveredvolumes 2>&1) \
|
||
|| fail "could not read volumes from $vm_node ($vm_addr): ${vols//$'\n'/ | }"
|
||
awk '$NF == "r-osd-data" {found=1} END {exit !found}' <<<"$vols" \
|
||
|| fail "$vm_node has no r-osd-data partition — RawVolumeConfig did not take"
|
||
kubectl get node "$vm_node" \
|
||
-o jsonpath='{.metadata.labels.storage\.renner\.uno/ceph-osd}' \
|
||
| grep -q true \
|
||
|| fail "$vm_node is missing the storage.renner.uno/ceph-osd label"
|
||
echo -e " $vm_node: partition and label ${GREEN}(OK)${NC}"
|
||
done
|
||
|
||
echo -e "${GREEN}VM rebuild test passed.${NC} ${#ALL_NODES[@]} nodes Ready on the repo's own config."
|
||
else
|
||
mkdir -p "$ACCESS_STAGING"
|
||
cp "$TALOSCONFIG_PATH" "$ACCESS_STAGING/talosconfig"
|
||
cp "$KUBECONFIG_PATH" "$ACCESS_STAGING/kubeconfig"
|
||
echo -e "${GREEN}Talos phase complete.${NC} Access files in $ACCESS_STAGING/"
|
||
fi
|
||
fi
|
||
|
||
# Helper: load staged access files into home dir (for later CI steps)
|
||
# Bring OpenBao up far enough that External Secrets can read from it.
|
||
#
|
||
# This was eight commands typed by hand, and it sits in the middle of the cold
|
||
# rebuild: infrastructure-crds waits on the ClusterSecretStore, which needs an
|
||
# unsealed OpenBao with Kubernetes auth configured, and every app Kustomization
|
||
# waits on infrastructure-crds. A rebuild could not reach a reconciled state
|
||
# without someone at a keyboard.
|
||
#
|
||
# The seal key stays where it was — the operator's `pass` store — and is read
|
||
# here at run time. It is never written to the repo and never reaches CI, which
|
||
# is the reason this was left manual in the first place. It reaches the cluster
|
||
# as the openbao-seal-key Secret, created by the flux phase before Flux starts
|
||
# OpenBao, and from then on every replica unseals itself.
|
||
#
|
||
# Everything below is idempotent: it is safe to re-run against an OpenBao that
|
||
# is already unsealed and configured, which is what makes it usable as part of
|
||
# `bootstrap.sh all` rather than a one-shot.
|
||
|
||
bao_exec() {
|
||
local pod=$1; shift
|
||
kubectl -n "$OPENBAO_NS" exec -i "$pod" -- "$@"
|
||
}
|
||
|
||
# Where the OpenBao raft snapshots already are: inside the Velero backup, in the
|
||
# kopia repository the data mover writes to. Nothing new is produced for the
|
||
# rebuild — the nightly snapshot CronJob and the openbao-daily Velero Schedule
|
||
# have been doing this all along.
|
||
#
|
||
# Reading them needs three things, all of which live OUTSIDE the cluster, which
|
||
# is the point: bucket credentials from pass (their own key, not Velero's),
|
||
# kopia, and the repository password. The last is not a secret — Velero
|
||
# hardcodes `static-passw0rd` in its source. Worth knowing for what it means:
|
||
# the offsite backup's confidentiality rests on bucket access alone.
|
||
#
|
||
# This is what breaks the circle. Velero's own credentials come from an
|
||
# ExternalSecret, which needs the ClusterSecretStore, which needs an unsealed
|
||
# OpenBao — so Velero cannot be the thing that restores OpenBao.
|
||
OPENBAO_KOPIA_BUCKET="renner-uno-k8s-velero"
|
||
OPENBAO_KOPIA_PREFIX="kopia/openbao/"
|
||
OPENBAO_S3_ENDPOINT="fsn1.your-objectstorage.com"
|
||
OPENBAO_S3_REGION="fsn1"
|
||
# Velero's hardcoded backup repository password (pkg/repository/keys/keys.go).
|
||
OPENBAO_KOPIA_PASSWORD="static-passw0rd"
|
||
|
||
restore_openbao() {
|
||
local pod=$1
|
||
step 0 "Restoring OpenBao from the offsite backup..."
|
||
command -v kopia >/dev/null || fail "kopia not found — needed to read the Velero backup repository without Velero"
|
||
|
||
local work kopia_err
|
||
work=$(mktemp -d)
|
||
export KOPIA_CONFIG_PATH="$work/repository.config"
|
||
export KOPIA_CACHE_DIRECTORY="$work/cache"
|
||
export KOPIA_LOG_DIR="$work/logs"
|
||
export KOPIA_CHECK_FOR_UPDATES=false
|
||
export KOPIA_PASSWORD="$OPENBAO_KOPIA_PASSWORD"
|
||
|
||
# Assigned first, not interpolated into the argument list. `fail` inside
|
||
# $( ) exits only the subshell, and a failed substitution in an argument does
|
||
# not stop the outer command — so a missing key produced an EMPTY
|
||
# --access-key and an authentication error instead of the real reason.
|
||
local s3_access s3_secret
|
||
s3_access=$(read_secret s3-access) || fail "no s3-access secret"
|
||
s3_secret=$(read_secret s3-secret) || fail "no s3-secret secret"
|
||
|
||
# Nothing here is expected to fail, so nothing here discards its error. This
|
||
# is the path that only runs during a genuine disaster; it is the last place
|
||
# to be guessing why something did not work.
|
||
echo -n " connecting to the backup repository (read-only)... "
|
||
if ! kopia_err=$(kopia repository connect s3 \
|
||
--bucket "$OPENBAO_KOPIA_BUCKET" \
|
||
--prefix "$OPENBAO_KOPIA_PREFIX" \
|
||
--endpoint "$OPENBAO_S3_ENDPOINT" \
|
||
--region "$OPENBAO_S3_REGION" \
|
||
--access-key "$s3_access" \
|
||
--secret-access-key "$s3_secret" \
|
||
--readonly 2>&1); then
|
||
echo; echo "$kopia_err" >&2
|
||
fail "could not open the backup repository at $OPENBAO_S3_ENDPOINT/$OPENBAO_KOPIA_BUCKET/$OPENBAO_KOPIA_PREFIX"
|
||
fi
|
||
ok
|
||
|
||
# Newest kopia snapshot, then the newest .snap inside it. The PVC holds a
|
||
# week of them, so the last line is the most recent.
|
||
local ksnap snapfile listing
|
||
listing=$(kopia snapshot list --all 2>&1) \
|
||
|| { echo "$listing" >&2; fail "could not list snapshots in the backup repository"; }
|
||
ksnap=$(tail -1 <<<"$listing" | awk '{print $4}')
|
||
[[ -n "$ksnap" ]] \
|
||
|| { echo "$listing" >&2; fail "could not read a snapshot id out of the listing above"; }
|
||
snapfile=$(kopia ls "$ksnap" 2>&1 | grep -o 'openbao-raft-[0-9-]*\.snap' | sort | tail -1) || true
|
||
[[ -n "$snapfile" ]] || fail "no openbao-raft-*.snap in kopia snapshot $ksnap"
|
||
echo " newest snapshot: $snapfile"
|
||
|
||
if ! kopia_err=$(kopia restore "$ksnap/$snapfile" "$work/restore.snap" 2>&1); then
|
||
echo "$kopia_err" >&2
|
||
fail "could not extract $snapfile from the backup"
|
||
fi
|
||
[[ -s "$work/restore.snap" ]] || fail "$snapfile came out empty"
|
||
echo " extracted $(stat -c%s "$work/restore.snap") bytes"
|
||
|
||
# A snapshot can only be loaded into a running OpenBao, so the fresh one is
|
||
# initialised first. Under the static seal, init hands out a recovery key
|
||
# rather than an unseal key and the server unseals itself; the only thing
|
||
# used from its output is the root token, for the next few seconds, and
|
||
# then it stops existing: restoring replaces the keyring with the
|
||
# snapshot's, after which the root token and recovery key from pass apply.
|
||
#
|
||
# No -force. Without it OpenBao checks that the snapshot was written under
|
||
# the seal key this server runs with, and refuses a mismatch by name
|
||
# ("possibly the snapshot is using a different autoseal key"). That check
|
||
# is the one guard against restoring under the wrong seal-key Secret;
|
||
# -force would skip it and leave a server that can never unseal.
|
||
#
|
||
# The init output is not echoed on a parse failure: it carries the root
|
||
# token and the recovery key of a server that is about to be overwritten,
|
||
# but a log line is a log line.
|
||
echo -n " initialising the empty server (throwaway recovery key)... "
|
||
local init_json root
|
||
init_json=$(bao_exec "$pod" bao operator init -recovery-shares=1 -recovery-threshold=1 -format=json 2>&1) \
|
||
|| { echo; echo "$init_json" >&2; fail "could not initialise OpenBao"; }
|
||
root=$(jq -r '.root_token // empty' <<<"$init_json")
|
||
[[ -n "$root" ]] || fail "init output carried no root token"
|
||
ok
|
||
|
||
# Sealed after init means the static seal is not working — the Secret is
|
||
# missing or malformed — and the restore below cannot run against it.
|
||
echo -n " waiting for it to unseal itself"
|
||
for i in $(seq 1 30); do
|
||
if bao_exec "$pod" bao status -format=json 2>"$OPENBAO_SECRETS_DIR/.baoerr" \
|
||
| jq -e '.sealed == false' >/dev/null; then
|
||
echo; break
|
||
fi
|
||
printf '.'
|
||
sleep 2
|
||
if [[ $i -eq 30 ]]; then
|
||
echo
|
||
cat "$OPENBAO_SECRETS_DIR/.baoerr" >&2
|
||
fail "$pod did not unseal itself after init — check the openbao-seal-key Secret and the pod log"
|
||
fi
|
||
done
|
||
|
||
echo -n " loading the snapshot... "
|
||
kubectl -n "$OPENBAO_NS" cp "$work/restore.snap" "$pod:/tmp/restore.snap" >/dev/null \
|
||
|| fail "could not copy the snapshot into $pod"
|
||
local out
|
||
out=$(bao_exec "$pod" env BAO_TOKEN="$root" bao operator raft snapshot restore /tmp/restore.snap 2>&1) \
|
||
|| { echo; echo "$out" >&2; fail "snapshot restore rejected"; }
|
||
ok
|
||
|
||
rm -rf "$work"
|
||
# The server seals itself against the keyring that came with the snapshot
|
||
# and unseals again from the static seal — the same key the snapshot was
|
||
# written under. bring_up_openbao waits for that on every replica.
|
||
echo " restored — the server re-seals and unseals itself from the seal key"
|
||
}
|
||
|
||
bring_up_openbao() {
|
||
step 0 "Bringing OpenBao up..."
|
||
command -v jq >/dev/null || fail "jq not found (needed to read OpenBao's seal status)"
|
||
|
||
# OpenBao is deployed by the infrastructure Kustomization. What this step
|
||
# depends on is therefore that Flux has applied it — not that some number of
|
||
# minutes have gone by. A guessed duration is not a condition: the first
|
||
# version failed after ten minutes on a slow cluster, and raising the number
|
||
# only moves the guess.
|
||
#
|
||
# So the loop ends on one of two facts, both read from the cluster: the
|
||
# StatefulSet exists, or Flux reports the Kustomization has failed. The
|
||
# elapsed-time bound below is a backstop against hanging forever, not the
|
||
# mechanism — it is deliberately far longer than any real reconcile.
|
||
local pods=() ks_state ks_bad=0
|
||
echo -n " waiting for Flux to apply the openbao StatefulSet"
|
||
for i in $(seq 1 720); do
|
||
if kubectl -n "$OPENBAO_NS" get statefulset openbao >/dev/null 2>&1; then
|
||
echo
|
||
break
|
||
fi
|
||
ks_state=$(ks_ready_condition infrastructure)
|
||
# Only a persistent failure counts. The first version matched any message
|
||
# containing "error" or "failed", which fires on `failed to download
|
||
# artifact from source-controller` — a routine transient in the minutes
|
||
# after bootstrap that Flux retries on its own, and one that aborted the
|
||
# rebuild. A wait built on cluster facts is no better than a guessed
|
||
# timeout if it reacts to a fact that is not the one it means.
|
||
if [[ "${ks_state%%|*}" == "False" ]] && grep -qiE 'failed|error' <<<"${ks_state#*|}"; then
|
||
(( ks_bad++ )) || true
|
||
else
|
||
ks_bad=0
|
||
fi
|
||
if (( ks_bad >= 30 )); then
|
||
echo
|
||
echo " infrastructure Ready=${ks_state%%|*} — ${ks_state#*|}" >&2
|
||
fail "the infrastructure Kustomization has been failing for five minutes — OpenBao will not appear"
|
||
fi
|
||
(( i % 18 == 0 )) && { echo; echo " infrastructure Ready=${ks_state%%|*} — ${ks_state#*|}"; echo -n " waiting"; }
|
||
printf '.'
|
||
sleep 10
|
||
[[ $i -eq 720 ]] && { echo; fail "openbao StatefulSet never appeared, and Flux never reported a failure — something is wedged"; }
|
||
done
|
||
|
||
# The StatefulSet exists; its pods follow.
|
||
for i in $(seq 1 60); do
|
||
mapfile -t pods < <(kubectl -n "$OPENBAO_NS" get pods -l app.kubernetes.io/name=openbao \
|
||
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true)
|
||
[[ ${#pods[@]} -gt 0 ]] && break
|
||
sleep 10
|
||
[[ $i -eq 60 ]] && fail "the openbao StatefulSet exists but produced no pods"
|
||
done
|
||
echo " found ${#pods[@]} pod(s): ${pods[*]}"
|
||
|
||
# A pod object existing is not a pod that can answer. Waiting only for the
|
||
# name meant `bao status` ran against a container still pulling or in Init,
|
||
# its error was thrown away, and an empty answer parsed as "not initialised"
|
||
# — which routes into the restore branch, the one branch that must never be
|
||
# entered by mistake. Wait for the container to be running first.
|
||
echo -n " waiting for ${pods[0]} to answer"
|
||
local status="" status_err=""
|
||
for i in $(seq 1 90); do
|
||
if status=$(bao_exec "${pods[0]}" bao status -format=json 2>"$OPENBAO_SECRETS_DIR/.baoerr"); then
|
||
echo; break
|
||
fi
|
||
status_err=$(cat "$OPENBAO_SECRETS_DIR/.baoerr")
|
||
# `bao status` exits 2 when sealed and still prints valid JSON — that is
|
||
# an answer, not a failure.
|
||
#
|
||
# Ask whether the field is PRESENT, not whether it is true. `jq -e
|
||
# '.initialized'` tests the value, so a brand-new OpenBao answering
|
||
# {"initialized": false, "sealed": true} — a complete and correct
|
||
# answer, and the only one a cold rebuild can give — read as no answer
|
||
# at all. The wait then ran its full 90 rounds against a healthy pod and
|
||
# failed with "never answered bao status", one step after the cluster
|
||
# had finally come up on its own.
|
||
if jq -e 'has("initialized")' >/dev/null 2>&1 <<<"$status"; then echo; break; fi
|
||
printf '.'
|
||
sleep 10
|
||
if [[ $i -eq 90 ]]; then
|
||
echo
|
||
echo " last error: $status_err" >&2
|
||
fail "${pods[0]} never answered bao status — refusing to guess whether it is sealed or uninitialised"
|
||
fi
|
||
done
|
||
|
||
# `bao status` reports Initialized either way, so it distinguishes "needs a
|
||
# key" from "needs initialising". Those must not be confused: initialising an
|
||
# OpenBao that already holds data is how the data gets lost.
|
||
# Read presence separately from value. jq's `//` treats false as absent, so
|
||
# `.initialized // empty` on the one answer a rebuild always gives —
|
||
# {"initialized": false} — yields an empty string, and the guard below then
|
||
# reports that OpenBao's state could not be read at all. Same mistake as the
|
||
# `jq -e` in the wait above, one line apart: false is a value, not a gap.
|
||
local initialized
|
||
initialized=$(jq -r 'if has("initialized") then (.initialized | tostring) else "" end' <<<"$status")
|
||
[[ -n "$initialized" ]] \
|
||
|| { echo "$status" >&2; fail "could not read initialised state from bao status"; }
|
||
|
||
if [[ "$initialized" != "true" ]]; then
|
||
# An uninitialised OpenBao is restored from the offsite snapshot, never
|
||
# initialised fresh: initialising over an existing keyring is how the
|
||
# data is lost, and a genuinely new OpenBao is a decision rather than a
|
||
# step (MIGRATION-sops-openbao.md).
|
||
restore_openbao "${pods[0]}"
|
||
fi
|
||
|
||
# Wait for every replica the StatefulSet wants, not the ones that happen to
|
||
# exist right now. On a cold start only openbao-0 exists when this runs:
|
||
# podManagementPolicy is OrderedReady, so openbao-1 is created once
|
||
# openbao-0 passes its readiness probe and openbao-2 after that. Each
|
||
# unseals itself from the static seal; nothing here types a key.
|
||
#
|
||
# Waiting for the whole chain rather than the first pod matters because the
|
||
# openbao Service sets publishNotReadyAddresses — the chart does this so
|
||
# replicas can reach each other to join raft before any of them is Ready —
|
||
# so a replica that is still starting is a live backend on the service IP.
|
||
# Clients balanced onto it get `dial tcp <svc>:8200: connect: no route to
|
||
# host`, and the failure surfaces nowhere near here: external-secrets'
|
||
# ClusterSecretStore flaps, every ExternalSecret reports "could not get
|
||
# secret data from provider", and infrastructure-crds — which waits on those
|
||
# — never goes Ready, taking all twenty app Kustomizations with it.
|
||
local want
|
||
want=$(kubectl -n "$OPENBAO_NS" get statefulset openbao -o jsonpath='{.spec.replicas}') \
|
||
|| fail "could not read the openbao StatefulSet's replica count"
|
||
[[ "$want" =~ ^[0-9]+$ ]] || fail "openbao StatefulSet reported a non-numeric replica count: '$want'"
|
||
echo " waiting for $want replica(s) to unseal themselves"
|
||
|
||
local unsealed=0 round pod
|
||
local -A seen=()
|
||
for round in $(seq 1 180); do
|
||
mapfile -t pods < <(kubectl -n "$OPENBAO_NS" get pods -l app.kubernetes.io/name=openbao \
|
||
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')
|
||
unsealed=0
|
||
for pod in "${pods[@]}"; do
|
||
if bao_exec "$pod" bao status -format=json 2>"$OPENBAO_SECRETS_DIR/.baoerr" \
|
||
| jq -e '.sealed == false' >/dev/null; then
|
||
[[ -n "${seen[$pod]:-}" ]] || { echo -e " $pod: ${GREEN}unsealed${NC}"; seen[$pod]=1; }
|
||
unsealed=$(( unsealed + 1 ))
|
||
fi
|
||
done
|
||
(( unsealed >= want )) && break
|
||
sleep 10
|
||
if [[ $round -eq 180 ]]; then
|
||
# A replica that is running and still sealed did not get a working
|
||
# seal. Say which, with its own answer, rather than "timed out".
|
||
for pod in "${pods[@]}"; do
|
||
[[ -n "${seen[$pod]:-}" ]] && continue
|
||
echo " $pod:" >&2
|
||
bao_exec "$pod" bao status 2>&1 | sed 's/^/ /' >&2 || true
|
||
done
|
||
fail "only $unsealed of $want OpenBao replicas unsealed after 30 minutes — a sealed one means the static seal failed (openbao-seal-key Secret, pod log)"
|
||
fi
|
||
done
|
||
ok
|
||
}
|
||
|
||
load_staged_access() {
|
||
[[ -f "$ACCESS_STAGING/talosconfig" && -f "$ACCESS_STAGING/kubeconfig" ]] \
|
||
|| fail "access-staging/ missing talosconfig or kubeconfig (run talos phase first)"
|
||
# To the paths talosctl and kubectl will actually read, not to $HOME. An
|
||
# operator with KUBECONFIG pointing somewhere else — an ordinary setup — got
|
||
# the staged config written where nothing would look at it, and every step
|
||
# after this ran against whatever cluster the old KUBECONFIG named. Wrong
|
||
# target, no error.
|
||
mkdir -p "$(dirname "$TALOSCONFIG_PATH")" "$(dirname "$KUBECONFIG_PATH")"
|
||
cp "$ACCESS_STAGING/talosconfig" "$TALOSCONFIG_PATH"
|
||
cp "$ACCESS_STAGING/kubeconfig" "$KUBECONFIG_PATH"
|
||
}
|
||
|
||
if phase_runs flux; then
|
||
echo -e "${YELLOW}=== Phase: FluxCD bootstrap ===${NC}"
|
||
command -v kubectl >/dev/null || fail "kubectl not found"
|
||
command -v flux >/dev/null || fail "flux not found"
|
||
# In the in-cluster Job neither the SOPS key nor the Gitea token is present,
|
||
# and neither should be baked in. Both are streamed into the secrets
|
||
# directory when the run asks for them, the same way the seal key is.
|
||
# Unconditional: the same collection step on a laptop and in the Job, so the
|
||
# recovery path is the one that gets exercised.
|
||
require_secrets
|
||
SOPS_KEY="$OPENBAO_SECRETS_DIR/sops-age.key"
|
||
[[ -f "$SOPS_KEY" ]] || fail "SOPS age key not found at $SOPS_KEY"
|
||
# One collected token, handed to whichever variable the chosen source reads.
|
||
# `flux bootstrap gitea` and `flux bootstrap github` each take theirs from
|
||
# the environment.
|
||
case "$FLUX_SOURCE" in
|
||
gitea) GITEA_TOKEN=$(read_secret git-token); export GITEA_TOKEN ;;
|
||
github) GITHUB_TOKEN=$(read_secret git-token); export GITHUB_TOKEN ;;
|
||
*) fail "Unknown FLUX_SOURCE '$FLUX_SOURCE' (want gitea|github)" ;;
|
||
esac
|
||
[[ "$PHASE" == "flux" ]] && load_staged_access
|
||
|
||
echo -n "Creating SOPS age secret... "
|
||
kubectl create namespace flux-system --dry-run=client -o yaml | kubectl apply -f -
|
||
kubectl create secret generic sops-age -n flux-system \
|
||
--from-file=age.agekey="$SOPS_KEY" \
|
||
--dry-run=client -o yaml | kubectl apply -f -
|
||
ok
|
||
|
||
# The static seal key, before Flux brings OpenBao up: a pod whose env
|
||
# references a Secret that does not exist is never started, and OpenBao
|
||
# reads the key at start. Left alone when the cluster already holds the
|
||
# same value. A DIFFERENT value is refused rather than applied: the data
|
||
# is encrypted under whichever key OpenBao started with, and swapping the
|
||
# Secret would leave every replica sealed for good at its next restart.
|
||
# That is a migration, not a bootstrap step.
|
||
#
|
||
# --from-literal, not --from-file: `pass show` ends its output with a
|
||
# newline, the substitution strips it, and OpenBao rejects a key that is
|
||
# not exactly 32 bytes of base64. The key sits in this process's argument
|
||
# list for the length of the call, as it did in the pod's before.
|
||
echo -n "Creating OpenBao seal key secret... "
|
||
seal_key=$(read_secret seal-key) || fail "could not read the seal key"
|
||
kubectl create namespace "$OPENBAO_NS" --dry-run=client -o yaml | kubectl apply -f -
|
||
if existing=$(kubectl -n "$OPENBAO_NS" get secret openbao-seal-key \
|
||
-o go-template='{{index .data "key" | base64decode}}' 2>"$OPENBAO_SECRETS_DIR/.err"); then
|
||
[[ "$existing" == "$seal_key" ]] \
|
||
|| fail "openbao-seal-key in $OPENBAO_NS holds a different key than pass — not replacing it (OpenBao's data is written under the key it started with)"
|
||
echo "already present"
|
||
elif grep -q NotFound "$OPENBAO_SECRETS_DIR/.err"; then
|
||
kubectl -n "$OPENBAO_NS" create secret generic openbao-seal-key \
|
||
--from-literal=key="$seal_key" >/dev/null \
|
||
|| fail "could not create the openbao-seal-key Secret"
|
||
ok
|
||
else
|
||
echo; cat "$OPENBAO_SECRETS_DIR/.err" >&2
|
||
fail "could not read the openbao-seal-key Secret"
|
||
fi
|
||
unset seal_key existing
|
||
|
||
if [[ "$FLUX_SOURCE" == "github" ]]; then
|
||
step 0 "Bootstrapping FluxCD from the GitHub break-glass mirror..."
|
||
echo -e "${YELLOW}BREAK-GLASS: Flux source is github.com/${GITHUB_OWNER}/${GITHUB_REPO}, NOT in-cluster Gitea.${NC}"
|
||
echo -e "${YELLOW}Switch the source back to Gitea once it is serving again (see RUNBOOK-cold-rebuild.md).${NC}"
|
||
# GITHUB_TOKEN is read from the environment by `flux bootstrap github`.
|
||
#
|
||
# --secret-name, and this is what makes the break-glass path survive its
|
||
# own success. The default is `flux-system`, which is also the target of
|
||
# the flux-system-git-auth ExternalSecret in post-crds — that one carries
|
||
# the GITEA credentials, which is correct for the steady state where the
|
||
# source is in-cluster Gitea.
|
||
#
|
||
# Sharing the name means the moment infrastructure-crds reconciles and
|
||
# ESO resolves, the GitHub token is overwritten with Gitea credentials
|
||
# while the GitRepository still points at github.com. Flux then stops
|
||
# being able to read its own source:
|
||
#
|
||
# GitOperationFailed: unable to list remote for
|
||
# 'https://github.com/.../Homelab.git': authentication required:
|
||
# Invalid username or token
|
||
#
|
||
# The rebuild destroys its git source at the exact moment it starts
|
||
# working, which is why it stayed hidden: no earlier run ever got the
|
||
# ExternalSecrets to resolve at all.
|
||
#
|
||
# A separate name keeps the two credentials apart. `flux-system` still
|
||
# receives the Gitea credentials and is simply unused until the source is
|
||
# switched back to Gitea — the manual step the warning above names.
|
||
flux bootstrap github \
|
||
--owner="$GITHUB_OWNER" \
|
||
--repository="$GITHUB_REPO" \
|
||
--branch="$FLUX_BRANCH" \
|
||
--path="$FLUX_PATH" \
|
||
--secret-name=flux-system-breakglass \
|
||
--components-extra=image-reflector-controller,image-automation-controller \
|
||
--personal \
|
||
--token-auth --timeout 15m 2>&1
|
||
else
|
||
step 0 "Bootstrapping FluxCD from in-cluster Gitea..."
|
||
flux bootstrap gitea \
|
||
--hostname="$GITEA_HOST" \
|
||
--owner="$GITEA_OWNER" \
|
||
--repository="$GITEA_REPO" \
|
||
--branch="$FLUX_BRANCH" \
|
||
--path="$FLUX_PATH" \
|
||
--components-extra=image-reflector-controller,image-automation-controller \
|
||
--personal \
|
||
--token-auth --timeout 15m 2>&1
|
||
fi
|
||
|
||
bring_up_openbao
|
||
|
||
step 0 "Waiting for infrastructure..."
|
||
for i in $(seq 1 60); do
|
||
# The Ready condition, not the printed table: `grep -c True` used to
|
||
# match SUSPENDED=True as well, so a Kustomization Flux is not
|
||
# reconciling at all read as ready and let the run proceed.
|
||
ready=$(ks_ready_condition infrastructure)
|
||
if [[ "${ready%%|*}" == "True" ]]; then
|
||
break
|
||
fi
|
||
sleep 10
|
||
if [[ $i -eq 60 ]]; then
|
||
echo -e "${YELLOW}Warning: infrastructure not ready after 10m, continuing...${NC}"
|
||
flux get kustomizations 2>&1 | sed 's/^/ /' | head -12
|
||
fi
|
||
done
|
||
|
||
step 0 "Waiting for Ceph cluster..."
|
||
for i in $(seq 1 60); do
|
||
health=$(kubectl get cephcluster -n rook-ceph -o jsonpath='{.items[0].status.phase}' 2>/dev/null || true)
|
||
if [[ "$health" == "Ready" ]]; then
|
||
break
|
||
fi
|
||
sleep 10
|
||
if [[ $i -eq 60 ]]; then
|
||
# Advisory, so it continues — but a warning that says only "not ready"
|
||
# sends the reader back to the cluster for information this step
|
||
# already had.
|
||
echo -e "${YELLOW}Warning: Ceph not ready after 10m (phase: ${health:-none}), continuing...${NC}"
|
||
kubectl -n rook-ceph get cephcluster -o wide 2>&1 | sed 's/^/ /'
|
||
kubectl -n rook-ceph get pods --no-headers 2>&1 \
|
||
| awk '$3 != "Running" && $3 != "Completed"' | head -10 | sed 's/^/ /'
|
||
fi
|
||
done
|
||
|
||
step 0 "Waiting for Ceph Object Store..."
|
||
for i in $(seq 1 60); do
|
||
phase=$(kubectl get cephobjectstore -n rook-ceph migration -o jsonpath='{.status.phase}' 2>/dev/null || true)
|
||
if [[ "$phase" == "Ready" ]]; then
|
||
break
|
||
fi
|
||
sleep 10
|
||
if [[ $i -eq 60 ]]; then
|
||
echo -e "${YELLOW}Warning: Object Store not ready after 10m (phase: ${phase:-none}), continuing...${NC}"
|
||
kubectl -n rook-ceph get cephobjectstore 2>&1 | sed 's/^/ /'
|
||
fi
|
||
done
|
||
fi
|
||
|
||
if phase_runs wait-apps; then
|
||
echo -e "${YELLOW}=== Phase: Wait for apps ===${NC}"
|
||
command -v kubectl >/dev/null || fail "kubectl not found"
|
||
command -v flux >/dev/null || fail "flux not found"
|
||
[[ "$PHASE" == "wait-apps" ]] && load_staged_access
|
||
|
||
# Wait for a Flux Kustomization to become Ready=True. Prints a status line
|
||
# whenever the Ready/Message pair changes, plus a heartbeat every 60 s so a
|
||
# silent-but-progressing reconcile is still visible in CI output. Returns
|
||
# 0 on Ready, 1 on timeout (caller decides whether to fail or continue).
|
||
wait_kustomization() {
|
||
local name="$1" label="$2" max_iters="$3"
|
||
local interval=10 elapsed=0 prev_status=""
|
||
local timeout_min=$(( max_iters * interval / 60 ))
|
||
step 0 "Waiting for ${label} (timeout ${timeout_min}m)..."
|
||
for _ in $(seq 1 "$max_iters"); do
|
||
local cond ready msg status
|
||
cond=$(ks_ready_condition "$name")
|
||
ready="${cond%%|*}"
|
||
msg="${cond#*|}"
|
||
if [[ "$ready" == "True" ]]; then
|
||
printf " [%4ds] Ready ✓\n" "$elapsed"
|
||
return 0
|
||
fi
|
||
status="${ready:-?}: ${msg:-(no status yet)}"
|
||
# Print on first iteration, on state change, or as a 60-s heartbeat.
|
||
# 240 chars is enough to see the full Flux stalled-resource message.
|
||
if [[ "$prev_status" != "$status" ]] || (( elapsed > 0 && elapsed % 60 == 0 )); then
|
||
printf " [%4ds] %s\n" "$elapsed" "${status:0:240}"
|
||
prev_status="$status"
|
||
fi
|
||
sleep "$interval"
|
||
elapsed=$(( elapsed + interval ))
|
||
done
|
||
printf " [%4ds] TIMEOUT after %sm — full status follows\n" "$elapsed" "$timeout_min"
|
||
# Dump the full untruncated status + Events for the offending resources
|
||
# so the post-mortem doesn't need a second cluster trip.
|
||
flux get kustomization "$name" 2>&1 | sed 's/^/ /' || true
|
||
kubectl get events --sort-by=.lastTimestamp -A 2>/dev/null \
|
||
| grep -i -E "warning|error" | tail -20 | sed 's/^/ /' || true
|
||
echo -e "${YELLOW}Warning: ${label} not Ready, continuing anyway${NC}"
|
||
# 0, not 1. Called bare under `set -e`, a non-zero return ends the script
|
||
# immediately — so this said "continuing anyway" and then stopped, and
|
||
# every wait after it never ran. The message was the truth; the return
|
||
# value contradicted it.
|
||
return 0
|
||
}
|
||
|
||
# Each app KS now owns its CNPG database (+ any data PVCs) directly — the
|
||
# per-app `seed-<app>` KSes that used to bootstrap them were folded into the
|
||
# app KSes when the seed layer was retired. Each wait below gates on the app
|
||
# KS, whose readiness includes its CNPG cluster coming up (initdb on a fresh
|
||
# cluster; a cold-rebuild data restore is the separate RECOVERY.md runbook).
|
||
# Timeouts stay generous to cover first-time cluster bootstrap on fresh Ceph.
|
||
# apps waits on infrastructure-ceph-cluster (Ceph mon/mgr/OSD bring-up
|
||
# is genuinely 5–10 min on a fresh OSD-wiped cluster).
|
||
wait_kustomization apps "apps" 90
|
||
wait_kustomization gitea "Gitea (incl. data-gitea-0 PVC)" 90
|
||
wait_kustomization influxdb "InfluxDB (HR install + token Jobs)" 600
|
||
wait_kustomization woodpecker "Woodpecker CI (incl. wp-pg CNPG backend)" 600
|
||
wait_kustomization authentik "Authentik (incl. ak-pg CNPG backend)" 600
|
||
wait_kustomization monitoring "kube-prometheus-stack (Loki + Prom + AM + Grafana)" 180
|
||
wait_kustomization music-assistant "Music Assistant (incl. data PVC)" 300
|
||
wait_kustomization paperless "Paperless (incl. pprl-pg CNPG + data PVCs)" 600
|
||
wait_kustomization home-assistant "Home Assistant (HAOS backup → native restore)" 180
|
||
wait_kustomization audiobookshelf "Audiobookshelf (incl. library/metadata PVCs)" 900
|
||
wait_kustomization navidrome "Navidrome (incl. data + music library PVCs)" 600
|
||
wait_kustomization immich "Immich (incl. im-pg CNPG + library PVC)" 1800
|
||
|
||
# Wait for cert-manager to finish issuing every Certificate. Kustomization
|
||
# Ready only means the Ingress + Certificate manifests are applied; cert-
|
||
# manager then runs an async ACME DNS-01 challenge per Certificate (~30s–2m
|
||
# via Cloudflare). e2e's TLS check fails on the ingress-nginx default fake
|
||
# cert if any Certificate is still Issuing when the test runs.
|
||
wait_certificates() {
|
||
local max_iters=90 interval=10 elapsed=0 prev_status=""
|
||
local timeout_min=$(( max_iters * interval / 60 ))
|
||
step 0 "Waiting for all cert-manager Certificates Ready (timeout ${timeout_min}m)..."
|
||
for _ in $(seq 1 "$max_iters"); do
|
||
local total ready not_ready_names status
|
||
total=$(kubectl get certificate -A --no-headers 2>/dev/null | wc -l | tr -d ' ')
|
||
ready=$(kubectl get certificate -A --no-headers 2>/dev/null | awk '$3=="True"' | wc -l | tr -d ' ')
|
||
if [[ "$total" -gt 0 && "$total" == "$ready" ]]; then
|
||
printf " [%4ds] All %s Certificates Ready ✓\n" "$elapsed" "$total"
|
||
return 0
|
||
fi
|
||
not_ready_names=$(kubectl get certificate -A --no-headers 2>/dev/null \
|
||
| awk '$3!="True" {print $1"/"$2}' | head -5 | tr '\n' ' ')
|
||
status="${ready:-0}/${total:-?} Ready, waiting: ${not_ready_names:-(none)}"
|
||
if [[ "$prev_status" != "$status" ]] || (( elapsed > 0 && elapsed % 60 == 0 )); then
|
||
printf " [%4ds] %s\n" "$elapsed" "${status:0:140}"
|
||
prev_status="$status"
|
||
fi
|
||
sleep "$interval"
|
||
elapsed=$(( elapsed + interval ))
|
||
done
|
||
printf " [%4ds] TIMEOUT after %sm\n" "$elapsed" "$timeout_min"
|
||
echo -e "${YELLOW}Warning: not all Certificates Ready, continuing anyway${NC}"
|
||
kubectl get certificate -A --no-headers | awk '$3!="True"' | head -10
|
||
# 0 for the same reason as wait_kustomization: under `set -e` a non-zero
|
||
# return here ends the run despite the message saying otherwise.
|
||
return 0
|
||
}
|
||
wait_certificates
|
||
|
||
echo ""
|
||
echo -e "${YELLOW}=== Verification ===${NC}"
|
||
# Whole-field matches. `grep -c Ready` counts NotReady too — the substring is
|
||
# in it — so this block reported every node healthy on a cluster where none
|
||
# was. `grep -c True` on HelmReleases matched the SUSPENDED column and any
|
||
# message containing the word.
|
||
#
|
||
# awk also avoids grep's exit 1 on zero matches, which under pipefail ended
|
||
# the run here with no output at all.
|
||
echo -n "Nodes: "
|
||
kubectl get nodes --no-headers | awk '$2 == "Ready"' | wc -l | tr -d '\n'
|
||
echo "/${#ALL_NODES[@]}"
|
||
|
||
# Ready count against a denominator: "12 ready" reads as success whether the
|
||
# cluster has 12 HelmReleases or 40. Both numbers come from the objects, not
|
||
# from `flux get`'s table, whose columns shift when REVISION is empty.
|
||
echo -n "HelmReleases: "
|
||
hr_total=$(kubectl get helmrelease --all-namespaces --no-headers | wc -l)
|
||
hr_ready=$(kubectl get helmrelease --all-namespaces -o jsonpath=\
|
||
'{range .items[*]}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}' \
|
||
| grep -c '^True$') || true
|
||
echo "${hr_ready:-0}/${hr_total} ready"
|
||
|
||
echo -n "Ceph: "
|
||
kubectl get cephcluster -n rook-ceph -o jsonpath='{.items[0].status.ceph.health}' 2>/dev/null || echo "pending"
|
||
echo ""
|
||
|
||
echo -n "Ingress (local): "
|
||
curl -sk --connect-timeout 5 --resolve status.renner.uno:443:10.2.99.86 https://status.renner.uno -o /dev/null -w "%{http_code}" 2>/dev/null || echo "N/A"
|
||
echo ""
|
||
fi
|