feat(ha-restore-test): throwaway HA backup restore test outside production (REN-339) #1061

Merged
Ghost merged 1 commits from atlas/ren-339-ha-restore-test into master 2026-09-28 16:41:31 +00:00
11 changed files with 397 additions and 0 deletions
@@ -0,0 +1,43 @@
# Runbook C.1: recover the recorder DB from ha-pg's Barman backups to the
# latest point (last base backup + archived WAL).
#
# DELIBERATELY NO `plugins:` block. With no WAL archiver this cluster never
# writes to s3://renner-uno-k8s-cnpg/ha-pg; see objectstore.yaml.
#
# One instance, no walStorage, ceph-block-ephemeral (reclaim Delete) so
# nothing outlives the namespace. 32Gi matches ha-pg's PGDATA: the restored
# data plus the WAL replayed since the last base backup.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: ha-pg-restore
namespace: ha-restore-test
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16
enableSuperuserAccess: true
storage:
storageClass: ceph-block-ephemeral
size: 32Gi
bootstrap:
recovery:
source: ha-pg
database: homeassistant
owner: homeassistant
externalClusters:
- name: ha-pg
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: ha-pg-store-ro
serverName: ha-pg
postgresql:
parameters:
shared_buffers: "512MB"
max_connections: "50"
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 1536Mi
@@ -0,0 +1,77 @@
# Runbook C.2 checks on the restored /config. NEVER run Home Assistant on this
# data: it would connect to MQTT, Zigbee2MQTT, Shelly and ESPHome and act on
# real devices. This pod only reads files, mounted read-only, and
# config-probe-deny-all in network-policy.yaml blocks all its traffic.
#
# The pod stays Pending until Velero has created the PVC. It prints file
# metadata and counts only: no secret values, secrets.yaml key names only.
apiVersion: batch/v1
kind: Job
metadata:
name: ha-config-check
namespace: ha-restore-test
spec:
backoffLimit: 0
activeDeadlineSeconds: 14400
template:
metadata:
labels:
app.kubernetes.io/name: ha-config-probe
spec:
restartPolicy: Never
automountServiceAccountToken: false
enableServiceLinks: false
securityContext:
# HA writes /config as root; the probe needs to read 0600 files.
# No capabilities, read-only volume and root filesystem.
runAsUser: 0
runAsGroup: 0
seccompProfile: { type: RuntimeDefault }
containers:
- name: check
image: alpine/git:2.54.0
command:
- sh
- -c
- |
set -u
cd /config
echo "checked_at $(date -u +%FT%TZ)"
du -sh /config 2>/dev/null
for f in .storage/core.config_entries .storage/core.entity_registry secrets.yaml configuration.yaml; do
if [ -s "$f" ]; then
echo "OK $f bytes=$(wc -c <"$f") mtime=$(date -u -r "$f" +%FT%TZ)"
else
echo "FAIL $f missing or empty"
fi
done
echo "config_entries: $(grep -c '"entry_id"' .storage/core.config_entries)"
echo "entity_registry entity_ids: $(grep -o '"entity_id":' .storage/core.entity_registry | wc -l)"
echo "newest .storage files (restore point):"
for f in .storage/*; do echo "$(date -u -r "$f" +%FT%TZ) $f"; done | sort -r | head -5
echo "secrets.yaml key names:"
grep -E '^[A-Za-z0-9_]+:' secrets.yaml | cut -d: -f1 | tr '\n' ' '; echo
echo "git:"
git -c safe.directory=/config log -1 --format='%H %cI %s'
git -c safe.directory=/config rev-parse --abbrev-ref HEAD
echo "git status (uncommitted/untracked vs HEAD):"
git -c safe.directory=/config status --porcelain | head -100
echo "yaml files:"
find . -path ./.git -prune -o \( -name '*.yaml' -o -name '*.yml' \) -print | sort | head -300
echo "done $(date -u +%FT%TZ)"
env:
- { name: HOME, value: /tmp }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: config, mountPath: /config, readOnly: true }
- { name: tmp, mountPath: /tmp }
volumes:
- name: config
persistentVolumeClaim:
claimName: home-assistant-config
readOnly: true
- name: tmp
emptyDir: {}
@@ -0,0 +1,18 @@
# Same shared Barman credential ha-pg uses (infrastructure/hetzner-s3). Needs
# ha-restore-test on the openbao ClusterSecretStore allowlist
# (infrastructure/post-crds/openbao-clustersecretstore.yaml).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: ha-restore-test
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hetzner-s3-creds
dataFrom:
- extract:
key: infrastructure/hetzner-s3
@@ -0,0 +1,17 @@
# THROWAWAY: HA backup restore test outside production (REN-339, runbook
# REN-249 C.1 + C.2). Nothing here touches namespace home-assistant. Remove the
# whole directory and clusters/homelab/ha-restore-test.yaml once the results
# are on the card; Flux prune then deletes the namespace and everything in it.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- network-policy.yaml
# C.1 recorder DB: recover ha-pg from Barman into a new Cluster.
- hetzner-creds-externalsecret.yaml
- objectstore.yaml
- cluster.yaml
- pg-check-job.yaml
# C.2 /config PVC: Velero restore + read-only probe.
- velero-restore.yaml
- config-check-job.yaml
@@ -0,0 +1,8 @@
# The `-restore-test` suffix is load-bearing: the monthly Velero restore-test
# sweep (infrastructure/velero/restore-test.sh) purges Released PVs whose
# claimRef matches `.*-restore-test/.*`. The Velero-restored PVCs land on
# ceph-block (Retain), so their PVs outlive this namespace until that sweep.
apiVersion: v1
kind: Namespace
metadata:
name: ha-restore-test
@@ -0,0 +1,85 @@
---
# The restored /config holds MQTT, Zigbee2MQTT, Shelly and ESPHome credentials.
# The probe pod must never reach anything. Deny rules win over every allow
# rule Cilium unions for the endpoint, so this holds even if some other policy
# selects the probe.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: config-probe-deny-all
namespace: ha-restore-test
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: ha-config-probe
ingressDeny:
- fromEntities:
- all
egressDeny:
- toEntities:
- all
---
# Recovered cluster: the CNPG operator (database ns) polls the instance
# manager on :8000; the pg-check Job reads on :5432. Nothing else gets in.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: ha-pg-restore-ingress
namespace: ha-restore-test
spec:
endpointSelector:
matchLabels:
cnpg.io/cluster: ha-pg-restore
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": database
app.kubernetes.io/name: cloudnative-pg
toPorts:
- ports:
- port: "8000"
protocol: TCP
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: ha-pg-check
toPorts:
- ports:
- port: "5432"
protocol: TCP
# PMTUD, same reason as ha-pg-allow-cnpg-operator in home-assistant-ns.yaml.
- fromEntities:
- cluster
icmps:
- fields:
- { type: 3, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
---
# The pg-check Job talks to the recovered cluster and DNS, nothing else.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: ha-pg-check-egress
namespace: ha-restore-test
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: ha-pg-check
egress:
- toEndpoints:
- matchLabels:
cnpg.io/cluster: ha-pg-restore
toPorts:
- ports:
- port: "5432"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
@@ -0,0 +1,30 @@
# READ-ONLY view of ha-pg's Barman prefix, used only as the recovery source.
#
# Two clusters archiving WAL to one destinationPath corrupt both (see
# ../home-assistant/ha-pg-objectstore.yaml). That cannot happen here: the
# recovered Cluster in cluster.yaml has NO `plugins` entry, so nothing archives
# WAL or takes base backups through this store. No retentionPolicy either, so
# nothing here can prune ha-pg's backups.
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: ha-pg-store-ro
namespace: ha-restore-test
spec:
configuration:
destinationPath: s3://renner-uno-k8s-cnpg/ha-pg
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
accessKeyId:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
@@ -0,0 +1,65 @@
# Runbook C.1 checks. Waits for the recovered cluster, then prints the
# results to its log (read with `view`; no exec needed).
apiVersion: batch/v1
kind: Job
metadata:
name: ha-pg-check
namespace: ha-restore-test
spec:
backoffLimit: 2
activeDeadlineSeconds: 14400
template:
metadata:
labels:
app.kubernetes.io/name: ha-pg-check
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 26
runAsGroup: 26
seccompProfile: { type: RuntimeDefault }
containers:
- name: check
image: ghcr.io/cloudnative-pg/postgresql:16
env:
- name: PGHOST
value: ha-pg-restore-rw
- name: PGDATABASE
value: homeassistant
- name: PGUSER
value: postgres
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: ha-pg-restore-superuser
key: password
command:
- bash
- -c
- |
set -u
echo "start $(date -u +%FT%TZ)"
until pg_isready -q; do sleep 15; done
until psql -Atqc 'select 1' >/dev/null 2>&1; do sleep 15; done
echo "ready $(date -u +%FT%TZ)"
psql -X -v ON_ERROR_STOP=1 <<'SQL'
\pset footer off
select now() as checked_at, pg_is_in_recovery() as in_recovery;
select to_timestamp(max(last_updated_ts)) as states_max_last_updated from states;
select count(*) as statistics_rows from statistics;
select count(*) as statistics_short_term_rows from statistics_short_term;
select schema_version, changed from schema_changes order by change_id desc limit 1;
select pg_size_pretty(pg_database_size('homeassistant')) as db_size;
select timeline_id, redo_lsn from pg_control_checkpoint();
SQL
echo "done $(date -u +%FT%TZ)"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: {}
@@ -0,0 +1,29 @@
# Runbook C.2: restore the latest home-assistant-daily backup into
# ha-restore-test. PVCs only (Flux owns every other object).
#
# Velero cannot filter by object name. The label narrows the restore to the
# four PVCs the home-assistant Kustomization owns (home-assistant-config,
# esphome-data, shelly-manager-data, zigbee2mqtt-data); only
# home-assistant-config is mounted. Nothing mounts the other three.
#
# namespaceMapping means nothing is written to namespace home-assistant.
# The `-rtest-` in the name lets the monthly restore-test sweep reclaim the
# data-mover staging PVs in velero/ (see restore-test.sh purge_released_pvs).
apiVersion: velero.io/v1
kind: Restore
metadata:
name: home-assistant-daily-rtest-ren339
namespace: velero
spec:
scheduleName: home-assistant-daily
includedNamespaces:
- home-assistant
namespaceMapping:
home-assistant: ha-restore-test
includedResources:
- persistentvolumeclaims
- persistentvolumes
labelSelector:
matchLabels:
kustomize.toolkit.fluxcd.io/name: home-assistant
restorePVs: true
@@ -0,0 +1,24 @@
# THROWAWAY (REN-339): HA backup restore test outside production. Delete this
# file and apps/ha-restore-test/ when the results are on the card; prune: true
# then removes the namespace, the recovered cluster and the Velero Restore.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: ha-restore-test
namespace: flux-system
spec:
interval: 10m
retryInterval: 1m
timeout: 10m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/ha-restore-test
prune: true
# Restores take a while; the Jobs report the results in their logs.
wait: false
dependsOn:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
- name: infrastructure-cnpg-barman
@@ -22,6 +22,7 @@ spec:
- crowdsec
- evcc
- flux-system # flux-system-git-auth: syncs the rotated flux gitea PAT into Flux's git secret
- ha-restore-test # THROWAWAY (REN-339): Barman creds for the ha-pg restore test; remove with apps/ha-restore-test/
- home-assistant
- immich
- influxdb