Files
home-network-k3s-impl/platform/velero/helmrelease.yaml
T
jakman85andClaude Opus 5 0a0527e7db feat(backup): prod's off-cluster DR lands on R2 — and stops the WAL clock
Prod has been archiving WAL to `TBD-PROD-S3-BACKUP-ENDPOINT` since bootstrap.
Verified live 2026-09-07 on all three CNPG Clusters: ContinuousArchiving=False,
"unexpected failure invoking barman-cloud-wal-archive: exit status 4". Because
archive_mode is ON, that is not a degraded backup — it is a DISK-FULL CLOCK.
Postgres will not recycle a segment it has not archived, so keycloak-db's pgdata
PVC is at 579 MiB used / 4360 MiB free and climbing monotonically. When it fills,
the primary PANICs and takes SSO down, and with it kubectl OIDC, Grafana,
Headlamp, Gitea and forward-auth. Wiring the endpoint is what lets Postgres drain
the whole accumulated backlog in one pass.

THE KNOB LAYOUT (three new, one repurposed) and why it is shaped this way.

The operator created exactly ONE R2 bucket, `k3s-prod-backup`, but the two backup
tools want differently-SHAPED values: the Velero chart takes `bucket` and `prefix`
as separate fields, while CNPG's barmanObjectStore.destinationPath is one full
s3:// URL whose first path segment IS the bucket. So:

  S3_BACKUP_ENDPOINT     dev http://192.168.1.9:9000   prod https://<acct>.r2...
  S3_BACKUP_REGION       dev us-east-1                 prod auto
  VELERO_BACKUP_BUCKET   dev velero                    prod k3s-prod-backup
  VELERO_BACKUP_PREFIX   dev ""                        prod velero
  CNPG_BACKUP_PATH       dev s3://cnpg                 prod s3://k3s-prod-backup/barman

There is deliberately NO separate `S3_BACKUP_BUCKET`: CNPG's bucket already lives
inside CNPG_BACKUP_PATH, and a second knob holding the same bucket name twice is a
rotation waiting to drift. Three knobs, each shaped for its own consumer, and no
two of them can contradict each other.

TENANCY. Inside k3s-prod-backup, velero and barman get disjoint TOP-LEVEL
prefixes — s3://k3s-prod-backup/velero/ and s3://k3s-prod-backup/barman/<cluster>/
— with the bucket ROOT left empty so a misrendered prefix lands somewhere
obviously wrong instead of silently colliding. This is not tidiness: Velero ADOPTS
every backup under its location and TTL-DELETES it on retention, so an overlapping
prefix would let it reap WAL segments; and interleaving two Postgres servers' WAL
into one prefix corrupts the chain outright. Dev↔prod isolation is doubly
independent — different endpoint AND different bucket names — so neither cluster
can reach the other's objects even with a mis-substituted prefix.

DEV IS UNCHANGED, BYTE-FOR-BYTE, and that is asserted rather than assumed. Base
defaults are dev's LIVE values (verified against the running cluster: BSL
bucket=velero with NO prefix, region us-east-1; all three CNPG Clusters
ContinuousArchiving=True at s3://cnpg on the MinIO), and the dev overlay now
restates all five explicitly for the same reason LONGHORN_DATA_PATH is restated —
a silent drift in base would ORPHAN the backups dev's PROVEN restore depends on.
The empty-prefix case was the one real risk, and it is settled empirically: with
prefix "" the chart omits objectStorage.prefix from the rendered
BackupStorageLocation ENTIRELY (helm template, chart 12.1.0), so dev's live BSL is
unchanged, not merely equivalent.

R2's TWO GOTCHAS, both wired up front:
  - region must be exactly `auto`. head-bucket answers {"BucketRegion":"WNAM"},
    which is a PHYSICAL LOCATION HINT, not a signing region. This was hardcoded
    us-east-1 inside the Velero HelmRelease — correct-by-accident for MinIO, wrong
    for anything else — so it is a knob now.
  - checksumAlgorithm: "". velero-plugin-for-aws v1.14.2 bundles an AWS SDK with
    flexible checksums on by default and sends x-amz-sdk-checksum-algorithm /
    x-amz-checksum-crc32 on every PUT; R2 rejects those with HTTP 400
    InvalidArgument, failing every backup and every kopia blob upload. It must be
    PRESENT-but-empty (the plugin branches on the key existing), and the chart does
    emit an empty value — verified by rendering it. Harmless against MinIO, so it
    is unconditional rather than a fourth knob.

WHAT THIS DOES NOT DO. Velero stays SUSPENDED on prod. `spec.suspend: true` is
owned by field manager `flux` (verified: kustomize-controller's managedFields do
not include f:suspend), so server-side apply will not strip it, and nothing here
declares suspension — which would also trip R-ENV-03, since a prod overlay may
patch only cluster-vars and a Flux Kustomization's `decryption`. Unsuspending is
the operator's, after `standup.sh --s3-endpoint <r2> s3check` proves a round trip.

NO DNS KNOB IS TOUCHED. BASE_DOMAIN stays prod.home.keithjackson.dev and every
EXTERNALDNS_* value is untouched. The apex inversion on work/prod-standup-campaign
was deliberately NOT carried: this branch ports that branch's R2 reasoning knob by
knob, and merges nothing.

VERIFIED: ci/l0.sh green (flux-postbuild-substitution 272 assertions, 0 hazards,
33 knobs defined / 28 referenced, every new token resolved in BOTH overlays);
render-golden, lint, schema, spec-immutability green; touched requirement
R-STATE-03 covered.

Refs R-STATE-03, R-IDP-05, R-ENV-02, R-ENV-03.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 01:14:29 -05:00

169 lines
9.7 KiB
YAML

---
# Velero — off-cluster backup/DR of cluster resources + PVC data (ADR-0003, R-STATE-03). LIVE.
#
# TARGET: the S3 endpoint ${S3_BACKUP_ENDPOINT} (cluster-vars) at
# s3://${VELERO_BACKUP_BUCKET}/${VELERO_BACKUP_PREFIX}, in region ${S3_BACKUP_REGION}. Four knobs,
# because the two environments' object stores differ in shape as well as address:
# dev http://192.168.1.9:9000 bucket `velero`, NO prefix, region us-east-1 (on-Proxmox MinIO,
# plain HTTP, path-style; MinIO ignores the region)
# prod https://<acct>.r2.cloudflarestorage.com bucket `k3s-prod-backup`, prefix `velero`,
# region `auto` (Cloudflare R2; prod cannot route to that MinIO,
# and R2 demands exactly `auto`)
# Prod's bucket is SHARED with CNPG/barman (the operator created one bucket), so the `velero` prefix
# is what keeps this controller's retention sweep away from the Postgres WAL stream. See the
# "OBJECT-STORE TENANCY" block in clusters/base/cluster-vars.yaml.
#
# WHY THIS SHAPE:
# - provider aws + velero-plugin-for-aws (init container) drives the S3-compatible MinIO target.
# - File System Backup (kopia) via the node-agent DaemonSet — storage-agnostic PVC backup that does
# NOT depend on CSI VolumeSnapshots or a data-mover (the simplest path that still captures the
# Longhorn PVC *data*). snapshotsEnabled:false → no VolumeSnapshotLocation is created/needed.
# - Credentials: the `cloud` INI key of the SOPS Secret velero-cloud-credentials (ns velero). ONE
# Secret serves both environments, and it now carries the Cloudflare R2 API token (written by
# platform/backup/set-r2-credentials.sh, 2026-09-07) rather than the scoped MinIO key. That is
# safe only because the R2 token is bucket-scoped to k3s-prod-backup, which dev never names:
# dev's Velero would fail loudly against its MinIO endpoint with an R2 key, it could not
# silently write somewhere unexpected. Rotating either target means re-running that script.
#
# VOLUME OPT-OUT (per the sizing decision) is implemented with TWO schedules (a single Backup has one
# cluster-wide defaultVolumesToFsBackup, so per-namespace volume policy needs two): see `schedules`.
#
# NON-GATING TIER: reconciled by clusters/base/backup.yaml (dependsOn platform-config, wait:false).
# A backup controller must NEVER gate the platform graph (mirror headlamp/oauth2-proxy).
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: velero
namespace: velero
spec:
interval: 30m
chart:
spec:
chart: velero
version: ">=12.0.0 <13.0.0" # Velero v1.18.x line (k8s-1.36 catch-up). CRDs pre-applied server-side (upgradeCRDs:false). Renovate maintains
sourceRef:
kind: HelmRepository
name: vmware-tanzu
namespace: velero
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
values:
# --- CRD install strategy. The chart's pre-install `velero-upgrade-crds` HOOK job runs
# docker.io/bitnami/kubectl, whose public Docker Hub catalog Bitnami REMOVED (Aug 2025) —
# `docker.io/bitnami/kubectl:1.31` now 404s, leaving the hook in Init:ImagePullBackOff so Velero
# never installs. The hook exists ONLY to UPDATE CRDs on chart upgrades; it is NOT how CRDs are
# first installed. The chart ships all 13 Velero CRDs (backups/restores/backupstoragelocations/
# schedules/podvolumebackups/…) in its native `crds/` directory, which Helm applies
# UNCONDITIONALLY on install — before hooks — regardless of this flag.
# Disabling the hook therefore drops the fragile external-kubectl-image dependency entirely while
# the CRDs still install via Helm's crds/ handling. Trade-off: Helm never manages crds/ after the
# initial install, so CRD *schema upgrades* on future chart bumps become a manual/Renovate step —
# acceptable: Velero's CRDs are stable, and this is far less fragile than pinning a shell-bearing
# kubectl mirror (rancher/kubectl is distroless → no /bin/sh for the hook's copy-binary step). ---
upgradeCRDs: false
# --- AWS object-store plugin (BSL provider=aws → MinIO S3-compatible), loaded as an init container.
# Image minor MUST track the Velero minor (chart 12.x = Velero 1.18 → plugin v1.14.x). ---
initContainers:
- name: velero-plugin-for-aws
image: velero/velero-plugin-for-aws:v1.14.2
imagePullPolicy: IfNotPresent
volumeMounts:
- mountPath: /target
name: plugins
# --- File System Backup (kopia): the node-agent DaemonSet mounts each pod's volumes and copies
# the data into S3. Storage-agnostic; no CSI-snapshot / data-mover wiring. ---
deployNodeAgent: true
snapshotsEnabled: false # no VolumeSnapshotLocation — kopia FSB is the volume-data path
configuration:
uploaderType: kopia
backupStorageLocation:
- name: default
provider: aws
# Bucket and prefix are BOTH knobs (cluster-vars), because the two environments' object
# stores are shaped differently. Dev's MinIO has a dedicated `velero` bucket and dev's
# backups sit at its ROOT, so dev renders bucket=velero with an EMPTY prefix — and the
# chart omits `objectStorage.prefix` entirely when it is empty (verified, chart 12.1.0),
# leaving dev's live BackupStorageLocation byte-unchanged. Prod's operator created ONE R2
# bucket shared with CNPG/barman, so prod renders bucket=k3s-prod-backup with prefix
# `velero` — a disjoint top-level prefix from barman's. Getting this wrong is not a
# cosmetic error: Velero ADOPTS every backup it finds under its location and TTL-DELETES
# it on retention, so a prefix that overlaps another tenant deletes that tenant's data.
bucket: ${VELERO_BACKUP_BUCKET}
prefix: "${VELERO_BACKUP_PREFIX}"
default: true
config:
# Region is a knob, not a constant: S3 clients require SOME region, MinIO ignores
# whatever it is, and Cloudflare R2 requires exactly `auto`. Hardcoding `us-east-1`
# was correct-by-accident and broke the moment the target stopped being MinIO.
region: ${S3_BACKUP_REGION}
s3Url: ${S3_BACKUP_ENDPOINT} # cluster-vars; dev MinIO (http) or prod R2 (https)
s3ForcePathStyle: "true" # MinIO is path-style; R2 accepts path-style too
# *** REQUIRED FOR CLOUDFLARE R2 — DO NOT DROP THIS LINE. ***
# velero-plugin-for-aws v1.14.2 bundles an AWS SDK with flexible checksums ON by
# default, so it sends `x-amz-sdk-checksum-algorithm` / `x-amz-checksum-crc32` on every
# PUT. R2 REJECTS those headers with HTTP 400 InvalidArgument, which would fail every
# backup AND every kopia blob upload. The empty string is the documented opt-out, and
# it must be PRESENT-but-empty rather than absent — the plugin branches on the key
# existing, and the chart does emit an empty value (verified, chart 12.1.0). Harmless
# against MinIO, which ignores it, so it is stated unconditionally rather than as a
# fourth knob. If backups start failing with InvalidArgument after a plugin bump, look
# here first.
checksumAlgorithm: ""
credentials:
existingSecret: velero-cloud-credentials # SOPS Secret carrying the `cloud` INI key (ns velero)
# --- Daily backups. TWO schedules implement the opt-out volume policy: capture every PVC's data
# by default (opt-out), but skip the two reconstructable heavy volumes' DATA while still
# capturing their Kubernetes resources. ---
schedules:
# (1) The whole cluster EXCEPT monitoring+unifi: all resources + all pod-volume data via kopia.
# defaultVolumesToFsBackup:true is the OPT-OUT model — nothing with real data is forgotten.
daily:
disabled: false
schedule: "${VELERO_BACKUP_SCHEDULE}"
useOwnerReferencesInBackup: false
template:
ttl: "${VELERO_RETENTION}"
storageLocation: default
snapshotVolumes: false
defaultVolumesToFsBackup: true
excludedNamespaces:
- monitoring # volume DATA excluded here; resources captured by schedule (2)
- unifi
# (2) monitoring + unifi: RESOURCES ONLY (defaultVolumesToFsBackup:false → no volume data). Their
# PVCs are reconstructable and excluded from data backup per the sizing decision:
# - monitoring: the Prometheus TSDB PVC (ephemeral/reconstructable) and the Loki 72h-log PVC
# (Grafana/Alertmanager are emptyDir — no PVC). Grafana dashboards are GitOps-provisioned.
# - unifi: the ~7.4 GiB KubeVirt VM disk (huge + reconstructable; UniFi is protected by its
# own config export, not this disk).
# Backing up their K8s objects (VM/DataVolume/Services/HelmRelease-owned CRs) still aids DR.
daily-resources-only:
disabled: false
schedule: "${VELERO_BACKUP_SCHEDULE}"
useOwnerReferencesInBackup: false
template:
ttl: "${VELERO_RETENTION}"
storageLocation: default
snapshotVolumes: false
defaultVolumesToFsBackup: false
includedNamespaces:
- monitoring
- unifi
# Modest home-dev footprint (mirror the other platform components).
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
nodeAgent:
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi