*** THE BUG, AND IT WAS MINE. *** The placeholder guards were `case "$s3" in
""|TBD-*)` — a PREFIX glob. Two commits earlier (32d6766) I changed the
placeholder from `TBD-PROD-S3-BACKUP-ENDPOINT` to
`https://TBD-R2-ACCOUNT-ID.r2.cloudflarestorage.com`, which does NOT start with
`TBD-`. Verified: fnmatch('https://TBD-R2-...', 'TBD-*') is False. All three
guards silently stopped matching, so **step_resume would have resumed velero on
prod straight onto a nonexistent hostname** — velero installs, reports healthy,
and no backup exists. That is precisely the failure the guard was written to
prevent, disarmed by a value change in a DIFFERENT file with nothing tying the
two together. Replaced with substring-matching s3_is_placeholder in preflight,
s3check and resume.
s3check rewritten from a reachability ping into a real proof. Per credential, from
a throwaway in-cluster pod (pod egress is the only egress that matters — the
workstation sits on a different network side and a host-side reading has already
misled this project once):
1. the credential LOADS AND AUTHENTICATES in the exact shape its consumer reads
— Velero's INI mounted from the `cloud` key, CNPG's discrete env keys;
2. the bucket EXISTS and is LISTABLE under this environment's own prefix, via
ListObjectsV2 — the op Velero's BSL sync and Barman's WAL scan actually use.
ListBuckets deliberately avoided: a bucket-scoped R2 token may not grant it;
3. PUT -> GET -> sha256+length compare -> DELETE -> confirm-gone. A HEAD would
pass with a read-only token and with every write being rejected;
4. the R2 checksum trap probed ON PURPOSE by repeating the PUT with checksums
enabled, and named either way.
Buckets and prefixes are DERIVED (velero's from the authored HelmRelease via yq,
cnpg's parsed from CNPG_BACKUP_PATH), not restated.
Fails closed, with no "unknown, carrying on" arm left: no pod, unfinished pod,
empty log, missing/duplicate/malformed verdict, any failed assertion, a claimed
PASS with failures>0, an assertion count below the caller-computed minimum, an
OK-line count disagreeing with the claimed count, or a PASS from a pod that ended
Failed. Failures are classified by cause — no egress, NoSuchBucket,
InvalidAccessKeyId, AccessDenied (token not scoped to BOTH buckets),
InvalidArgument -> the checksum trap.
`amazon/aws-cli` pinned rather than `minio/mc`: mc lacks sed/awk (which already
broke one probe) AND cannot send checksum headers, so it cannot test for the trap.
TIER_ORDER now includes platform-config-cdi, between platform-config and the leaf
tiers. Without it `resume` returned success without ever waiting on the tier that
guarantees the CDI StorageProfiles — and apps-vms depends on them: unifi
provisions on longhorn-vm, where an unapplied profile means CDI infers RWO instead
of the authored RWX, permanently non-live-migratable.
CREDENTIALS: one key, four committed Secrets, two shapes — and NO manifest change
is needed, only encrypted values. platform/backup/set-r2-credentials.sh writes all
four with `sops set`, which encrypts IN PLACE, so no decrypted Secret ever exists
in the working tree to be committed by accident — strictly better than staging a
plaintext template. Prompts with `read -rs`, rejects anything outside the S3
credential charset (a paste that swallowed a newline authenticates nowhere), never
prints a value, and verifies by decrypting back and comparing fingerprints.
`--check` is read-only. The MinIO key is RETAINED — it is what reads the
pre-migration backups.
THE ACCOUNT ID IS ALREADY IN THE REPO: infra/tofu/backend.r2.hcl commits
https://7ce452a1b3d6c86daf2c020edcb82d37.r2.cloudflarestorage.com for the tofu
state bucket. If the backup buckets go in the same account, only the two token
values are needed. The placeholder is KEPT anyway — fail-closed beats convenience,
and the guard above just demonstrated why.
Corroborating the checksum fix independently: infra/tofu/backend.tf already carries
`region = "auto"` and `skip_s3_checksum = true # R2 rejects AWS trailing
(streaming) checksums`. This repo had learned both R2 facts for tofu state already.
VERSION FINDINGS, documented not measured against R2: plugin v1.14.2 documents
checksumAlgorithm defaulting to CRC32, so the authored "" is load-bearing;
Velero's kopia backend has NO checksum knob, so BSL metadata and PVC-data
compatibility are SEPARATE questions; and in the live CNPG 16.4 image,
boto3/botocore 1.35.60 is below the 1.36 line that turned on default request
checksums — so CNPG should be clear today, but barman-cloud exposes no escape
hatch, making a CNPG image bump an R2 re-verification event.
VERIFIED BY ME, independently of the agent: the dead-guard glob (fnmatch False);
the placeholder now refused on prod before any pod is created; a full round trip
against dev's MinIO — 7/7 assertions on BOTH credential paths, byte-identical,
pod Succeeded, nothing left behind, checksum trap correctly reported absent; all
four Secrets and the committed account id present. Full CI gate PASS.
STILL UNPROVEN AND SAID SO IN THE DOC: Velero-to-R2 (metadata or kopia data),
CNPG-barman-to-R2, and any restore from R2. R-STATE-03 is PROVEN MinIO-ONLY and
its ledger row predates this move. infra/BACKUP-R2.md §5 is the re-proof sequence
and it ends in a RESTORE, not an upload.
Refs R-STATE-03, R-IDP-05, R-ENV-02.
Co-Authored-By: Claude Opus 5 <[email protected]>
196 lines
10 KiB
Bash
Executable File
196 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# set-r2-credentials.sh — put ONE Cloudflare R2 API token into every credential Secret that consumes
|
|
# the off-cluster backup target, without plaintext ever touching the disk.
|
|
#
|
|
# WHY THIS EXISTS
|
|
# The same S3 key is read by four different Secrets in three namespaces, in TWO different shapes
|
|
# (Velero wants an AWS INI file; CNPG/barman wants discrete ACCESS_KEY_ID / ACCESS_SECRET_KEY keys).
|
|
# Hand-editing four SOPS files is four chances to typo one of them — and a typo'd backup credential
|
|
# does not fail at apply time, it fails at 03:00 with no backup and a green HelmRelease.
|
|
#
|
|
# It uses `sops set`, which edits an ALREADY-ENCRYPTED file in place: the new value is encrypted as
|
|
# it is written and NO decrypted copy of the file is ever created. That is the whole point — the
|
|
# `cp template plaintext.yaml && edit && sops -e -i` dance leaves a plaintext Secret sitting in the
|
|
# working tree, one `git add -A` away from being committed forever.
|
|
#
|
|
# USAGE (inside the dev container — it has sops and mounts the age key)
|
|
# ./infra/devcontainer/dev.sh run platform/backup/set-r2-credentials.sh
|
|
# → prompts for the two values (never echoed, never in shell history, never in a file)
|
|
#
|
|
# ./infra/devcontainer/dev.sh run platform/backup/set-r2-credentials.sh --check
|
|
# → touches nothing; just reports whether the four Secrets already agree with each other
|
|
#
|
|
# R2_ACCESS_KEY_ID=… R2_SECRET_ACCESS_KEY=… …/set-r2-credentials.sh --no-prompt
|
|
# → non-interactive. Prefer the prompts: an env var is visible in /proc and in `history`.
|
|
#
|
|
# WHAT IT DOES NOT DO
|
|
# - It does not touch clusters/base/cluster-vars.yaml. The endpoint is not a secret and belongs in
|
|
# a reviewable plain manifest; the script prints the exact edit to make.
|
|
# - It does not commit. Review `git diff` (you will see only ENC[...] blobs change) and commit.
|
|
# - It does not delete or rewrite platform/backup/minio-credentials.sops.yaml. That is the RETAINED
|
|
# pre-migration MinIO key and it is what you need to read the pre-migration backups.
|
|
#
|
|
# SEE infra/BACKUP-R2.md for the whole procedure, including the restore that actually proves it.
|
|
set -euo pipefail
|
|
|
|
# Belt and braces: never let a stray `set -x` in a parent shell echo a credential out of this script.
|
|
set +x
|
|
|
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
root="$(git -C "${here}" rev-parse --show-toplevel 2>/dev/null || (cd "${here}/.." && pwd))"
|
|
cd "${root}"
|
|
|
|
MODE=set
|
|
PROMPT=1
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--check) MODE=check; shift ;;
|
|
--no-prompt) PROMPT=0; shift ;;
|
|
-h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
|
*) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
|
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
|
info() { printf ' %s\n' "$*"; }
|
|
warn() { printf '\033[33mWARN: %s\033[0m\n' "$*" >&2; }
|
|
|
|
command -v sops >/dev/null 2>&1 || die "'sops' not found — run this inside ./infra/devcontainer/dev.sh."
|
|
|
|
# ---------------------------------------------------------------- the consumers
|
|
# file|shape. shape=ini -> stringData.cloud, an AWS INI credentials file (velero-plugin-for-aws)
|
|
# shape=discrete -> stringData.ACCESS_KEY_ID + ACCESS_SECRET_KEY (CNPG barmanObjectStore)
|
|
CONSUMERS=(
|
|
"platform/velero/velero-cloud.sops.yaml|ini"
|
|
"platform/keycloak/keycloak-db-backup-credentials.sops.yaml|discrete"
|
|
"apps/wave-0/nextcloud-db-backup-credentials.sops.yaml|discrete"
|
|
"apps/wave-0/semaphore-db-backup-credentials.sops.yaml|discrete"
|
|
)
|
|
|
|
for entry in "${CONSUMERS[@]}"; do
|
|
f="${entry%%|*}"
|
|
[ -f "${f}" ] || die "missing consumer file ${f} — has the layout changed? Fix this script, do not skip the file."
|
|
grep -q '^sops:' "${f}" || die "${f} has no top-level sops: block — refusing to touch a file that is not SOPS-encrypted."
|
|
done
|
|
|
|
# A 32-bit fingerprint of a high-entropy secret: enough to prove two files hold the SAME value,
|
|
# nowhere near enough to recover it. Values themselves are NEVER printed.
|
|
fp() { local h; h="$(printf '%s' "$1" | sha256sum)"; printf '%s' "${h:0:8}"; }
|
|
|
|
# Read one value out of an encrypted file without ever writing plaintext anywhere.
|
|
xtract() { sops -d --extract "$2" "$1"; }
|
|
|
|
# Pull the two values out of an AWS INI blob using shell only.
|
|
ini_field() { # <ini-text> <aws_access_key_id|aws_secret_access_key>
|
|
local line v=""
|
|
while IFS= read -r line; do
|
|
case "${line}" in
|
|
"$2"=*|"$2 "*|*[![:alnum:]_]"$2"=*) v="${line#*=}"; v="${v#"${v%%[![:space:]]*}"}" ;;
|
|
esac
|
|
done <<<"$1"
|
|
printf '%s' "${v}"
|
|
}
|
|
|
|
report() { # print the fingerprint table; returns non-zero when the four files disagree
|
|
local entry f shape id sec akid_fp sec_fp first_id="" first_sec="" agree=0
|
|
printf ' %-58s %-10s %-10s\n' FILE ID-FP SECRET-FP
|
|
for entry in "${CONSUMERS[@]}"; do
|
|
f="${entry%%|*}"; shape="${entry##*|}"
|
|
if [ "${shape}" = ini ]; then
|
|
local blob; blob="$(xtract "${f}" '["stringData"]["cloud"]' 2>/dev/null || true)"
|
|
id="$(ini_field "${blob}" aws_access_key_id)"
|
|
sec="$(ini_field "${blob}" aws_secret_access_key)"
|
|
blob=""
|
|
else
|
|
id="$(xtract "${f}" '["stringData"]["ACCESS_KEY_ID"]' 2>/dev/null || true)"
|
|
sec="$(xtract "${f}" '["stringData"]["ACCESS_SECRET_KEY"]' 2>/dev/null || true)"
|
|
fi
|
|
[ -n "${id}" ] || { warn "${f}: could not read the access key id (age key available?)"; agree=1; }
|
|
[ -n "${sec}" ] || { warn "${f}: could not read the secret access key"; agree=1; }
|
|
akid_fp="$(fp "${id}")"; sec_fp="$(fp "${sec}")"
|
|
printf ' %-58s %-10s %-10s\n' "${f}" "${akid_fp}" "${sec_fp}"
|
|
if [ -z "${first_id}" ]; then first_id="${akid_fp}"; first_sec="${sec_fp}"
|
|
elif [ "${akid_fp}" != "${first_id}" ] || [ "${sec_fp}" != "${first_sec}" ]; then agree=1; fi
|
|
id=""; sec=""
|
|
done
|
|
return "${agree}"
|
|
}
|
|
|
|
if [ "${MODE}" = check ]; then
|
|
say "current state of the four backup credential Secrets (values never printed)"
|
|
if report; then
|
|
info "ALL FOUR AGREE. (Agreeing is not the same as being right — prove it with bootstrap/standup.sh s3check.)"
|
|
exit 0
|
|
fi
|
|
die "the four Secrets do NOT all hold the same key. Velero and CNPG would be writing to the same
|
|
target with different credentials — one of them is stale. Re-run this script without --check."
|
|
fi
|
|
|
|
# ---------------------------------------------------------------- the two values
|
|
AKID="${R2_ACCESS_KEY_ID-}"
|
|
SAK="${R2_SECRET_ACCESS_KEY-}"
|
|
if [ "${PROMPT}" = 1 ]; then
|
|
say "paste the R2 API token (Cloudflare dashboard -> R2 -> Manage API Tokens)"
|
|
info "Object Read & Write, scoped to the velero + cnpg buckets. Neither value is echoed or stored."
|
|
[ -n "${AKID}" ] || { printf ' Access Key ID: '; IFS= read -rs AKID; printf '\n'; }
|
|
[ -n "${SAK}" ] || { printf ' Secret Access Key: '; IFS= read -rs SAK; printf '\n'; }
|
|
fi
|
|
[ -n "${AKID}" ] || die "no access key id given (prompt, or R2_ACCESS_KEY_ID with --no-prompt)."
|
|
[ -n "${SAK}" ] || die "no secret access key given (prompt, or R2_SECRET_ACCESS_KEY with --no-prompt)."
|
|
|
|
# FAIL CLOSED on anything that is not a plain S3 credential. These values are passed to `sops set` as
|
|
# JSON, so a quote, a backslash or a newline would corrupt the Secret rather than error — and the
|
|
# usual cause is a copy-paste that swallowed a newline or a trailing space, which would produce a
|
|
# credential that authenticates nowhere and a backup that silently never runs.
|
|
for pair in "access key id:${AKID}" "secret access key:${SAK}"; do
|
|
case "${pair#*:}" in
|
|
*[!A-Za-z0-9._~+/=-]*) die "the ${pair%%:*} contains a character outside [A-Za-z0-9._~+/=-].
|
|
Almost certainly a stray space, quote or newline from the paste. Nothing was changed." ;;
|
|
esac
|
|
done
|
|
# R2 issues 32-hex ids and 64-hex secrets today. A mismatch is a warning, not a refusal: the format is
|
|
# Cloudflare's to change, and refusing outright would make this script the thing that blocks a rotation.
|
|
case "${AKID}" in *[!0-9a-f]*|"") warn "the access key id is not 32 lowercase hex chars — unusual for R2. Continuing." ;;
|
|
*) [ "${#AKID}" = 32 ] || warn "the access key id is ${#AKID} chars, not the usual 32. Continuing." ;;
|
|
esac
|
|
[ "${#SAK}" -ge 40 ] || warn "the secret access key is only ${#SAK} chars — unusual for R2 (64). Continuing."
|
|
|
|
# ---------------------------------------------------------------- write
|
|
say "writing the credential into ${#CONSUMERS[@]} SOPS Secrets (encrypted in place; no plaintext file)"
|
|
for entry in "${CONSUMERS[@]}"; do
|
|
f="${entry%%|*}"; shape="${entry##*|}"
|
|
case "${shape}" in
|
|
ini)
|
|
# velero-plugin-for-aws reads this key VERBATIM as an AWS credentials file. The [default]
|
|
# profile name and the two underscore_names are the plugin's contract, not a style choice.
|
|
sops set "${f}" '["stringData"]["cloud"]' \
|
|
"\"[default]\\naws_access_key_id=${AKID}\\naws_secret_access_key=${SAK}\\n\"" ;;
|
|
discrete)
|
|
sops set "${f}" '["stringData"]["ACCESS_KEY_ID"]' "\"${AKID}\""
|
|
sops set "${f}" '["stringData"]["ACCESS_SECRET_KEY"]' "\"${SAK}\"" ;;
|
|
esac
|
|
info "updated ${f}"
|
|
done
|
|
AKID_FP="$(fp "${AKID}")"; SAK_FP="$(fp "${SAK}")"
|
|
AKID=""; SAK=""
|
|
|
|
say "verifying by decrypting each file back (values never printed)"
|
|
report || die "the files still disagree after writing — do NOT commit. Inspect with 'sops <file>'."
|
|
info "every file now reads id-fp=${AKID_FP} secret-fp=${SAK_FP} — matching what was pasted."
|
|
|
|
say "NEXT — in this order, and do not skip the restore"
|
|
cat <<'NEXT'
|
|
1. Set the endpoint (NOT a secret, so it lives in a plain reviewable manifest):
|
|
clusters/base/cluster-vars.yaml
|
|
S3_BACKUP_ENDPOINT: "https://<account-id>.r2.cloudflarestorage.com"
|
|
S3_BACKUP_REGION: "auto" # R2 accepts nothing else
|
|
2. Prove the target BEFORE merging anything (writes and deletes one throwaway object):
|
|
bootstrap/standup.sh --kubeconfig <kc> --expect-server <url> \
|
|
--s3-endpoint https://<account-id>.r2.cloudflarestorage.com s3check
|
|
3. Only then commit + merge, and follow infra/BACKUP-R2.md through the RESTORE.
|
|
A backup that has never been restored is not a backup, and R-STATE-03 is currently PROVEN
|
|
against MinIO only.
|
|
NEXT
|