Disaster Recovery & Rebuild
The lab’s doctrine is rebuild over repair: both clusters are treated as disposable, and git is the source of truth. This chapter is the honest version of that doctrine — what a cold rebuild actually takes, which state cannot come back from git, and where the manual seams are. Verified against live state on 2026-10-24.
The stateful inventory
Everything else in both clusters is stateless and reconciles from git. The complete persistent-volume list, classified:
Tooling plane (Mac mini, Docker Desktop Kubernetes)
| Volume | Class | Why |
|---|---|---|
data-vault-0 (Vault file storage) | Irreplaceable | The secrets themselves. The only mitigation is the backup archive (below); there is no second copy anywhere. |
openldap-data | Recreatable, with care | The directory re-seeds from git (rhels/lab-agentops, ldap module), and the credentials of record live in Vault — but any post-seed directory change lives only here. Treat as precious. |
openldap-config | Recreatable from git | Server configuration is in the module. |
Harbor database | Reconstructable, not in git | Projects, users, robot accounts, and the LDAP auth configuration are Harbor-internal state. Rebuilding means re-creating them (images are re-pushable; registry metadata is manual). |
Harbor registry | Recreatable | Images are rebuilt and re-pushed by the pipeline. |
Harbor redis, jobservice | Disposable | Cache/session and job state. |
storage-loki-0 | Disposable | Logs, 72-hour retention by design. |
logstash-files | Disposable copy | The plain-text archive mirrors what Loki holds. |
storage-tempo-0 | Disposable | Traces; no instrumented apps yet anyway. |
The agentops observability stack (Prometheus/Grafana/Alertmanager)
has no volumes at all — its state is in-pod by construction, its
configuration in git.
Workload cluster (CRC)
| Volume | Class | Why |
|---|---|---|
grafana-data | Recreatable from git | Dashboards and datasources are provisioned; the database holds UI state only. |
| Prometheus TSDB (10 Gi PVC) | Disposable history | Retention is 15 days. The volume survives pod restarts (proven 2026-10-24: the pod was deleted and pre-restart samples were identical after); it is lost only with the CRC VM itself, which is acceptable. |
data-vector-aggregator-0 | Disposable | The sink disk buffer: in-flight events only, empty at steady state by design. |
crc-image-registry-storage | Disposable | The cluster-internal registry refills from pushes. The CRC VM itself is the lab’s most disposable object — crc delete and rebuild is routine doctrine. |
The image inventory — what a rebuild cannot pull
A rebuilt cluster re-pulls almost everything from public registries. The exceptions below (enumerated from every live pod and every GitOps manifest on 2026-10-24) are the images whose reference does not resolve publicly; each needs its build path run once before its workload can start.
Tooling plane (Mac mini):
| Image | Rebuild source |
|---|---|
lab-docs (date-tagged) | In-repo build path: scripts/build-image.sh in rhels/lab-docs-source, built into the tooling host’s image store. |
agentops/cmdb, agentops/snow-sim, agentops/mcp-server, agentops/group-sync | In-repo build paths: services/<name>/Dockerfile in rhels/lab-agentops, built into the tooling host’s image store (Docker Desktop’s mirror serves them to pods with IfNotPresent). |
local-k8s-gitops/cursor-mymachine-worker | No build path exists — no Dockerfile, build script, or manifest in any git repo; the image exists only in the tooling host’s image store. A rebuild loses this workload until its source is recovered or recreated. Owner decision pending. |
Workload cluster (CRC):
| Image | Rebuild source |
|---|---|
harbor.rhels.com/library/log-producer:0.1.0 | In Harbor since 2026-10-24 (digest sha256:2c212345…): built once on the tooling host from clusters/crc/logging/delivery-test/ in rhels/local-k8s-gitops and pushed through the in-cluster skopeo path. Before that it was node-cached with imagePullPolicy: Never and every rebuild needed a mid-recovery build on the node — the single largest avoidable delay in the 2026-10-24 drill. The in-repo build-image.sh remains only as an emergency sideload fallback. |
harbor.rhels.com/library/{cmdb:0.3, snow-sim:0.5.2, mcp-server:0.6.0, group-sync:0.3.2} | The rhels/lab-agentops service images, living in the tooling-plane Harbor (project library, public pull). They were briefly referenced via the Harbor NodePort address, which dies whenever the mini’s Docker Desktop NodePort forwarding wedges; since 2026-10-24 the GitOps references use the harbor.rhels.com name through the tailnet ingress. Harbor must still be up before these CRC workloads can start. |
The demo apps in the internal registry (app1001-dev/demo-java-app, app1002-dev/demo-python-app, app1003-dev/demo-redis-app) | In-cluster BuildConfigs (Docker builds from git) in each app namespace; they rebuild themselves once the cluster is up. |
Everything else in both clusters resolves from a public registry (quay.io, docker.io, registry.k8s.io, ghcr.io, the Red Hat registries, MCR) and needs no local action.
The one true backup: Vault
Because Vault’s volume is the single irreplaceable, it is the single thing with a backup procedure:
- The file-storage backend makes Vault’s whole state a directory tree — portable as a tar archive, restorable into a same-version Vault by untarring into the data path while stopped, then unsealing with the owner’s key.
- Archives live on the tooling host, outside the cluster, under
~/vault-backups/(dated folders), with an off-box copy of the current archive kept on the agent VM (checksum-verified identical). - Automated daily backup (since 2026-10-24): a scheduled job
(
vault-backup-daily, ~02:18 America/Chicago) runs~/workspace/vault-backups/vault-backup.sh, which takes the same tar archive every day. The*-dailyarchives are kept 14 days on both the tooling host and the agent VM, sha256-verified identical, so the worst case is losing one day of Vault changes. The first daily archive (2026-10-24-daily, 219 files) passed the same structural restore drill as the milestone archives — restored tree identical (219/219) and a throwaway Vault booted on it toSealed=true. The daily job reports only on failure. - Status (2026-10-24): the current milestone archive is
2026-10-24-postmigration, taken after the secrets migration (215 files), and its restore drill passed the same day — the restored tree diffed file-for-file identical to the live tree, 215/215. The previous archive (2026-10-18-step1, 62 files) passed the same drill on its day. The discipline this implies: a backup counts only with a same-day restore drill, and any change on the scale of the 2026-10-24 migration is not “backed up” until a fresh archive + drill says so.
Cold rebuild order
From bare hosts. Steps marked [manual] are the seams — everything else is root-app sync.
- [manual] Hosts. Tooling host with Docker Desktop Kubernetes enabled; the CRC host with the CRC bundle set up; both on the tailnet. No cluster exists yet, so nothing in git can do this.
- [manual] Tooling-plane Argo CD. First install of Argo CD, then
apply the root application (
argocd/applications/root.yamlinrhels/local-k8s-gitops). From here the plane self-assembles: foundation, cert-manager, ingress, Tailscale operator, runners. Two owner-placed secrets gate full sync: the Cloudflare API token (cert-manager namespace) and the Tailscale operator’s credentials. - [manual] Vault. Helm-install from the platform repo’s values
(
clusters/docker-desktop/vault/) — it is deliberately not an Argo app. Restore the backup archive into the data volume (or initialize empty), then the owner unseals from the Keychain (see vault.md). Then [manual] runvault-access/scripts/reconcile.pyfrom the platform repo with Vault admin access: it ensures the KV mounts and reconciles every namespace-scoped Vault policy and Kubernetes-auth role, seeds the proof tenants, and verifies the CRC auth path end to end. The script is in git; running it is a human/agent step — Argo CD cannot. - Identity. The
agentops-ldapapp syncs fromrhels/lab-agentops; the directory seeds from its module. vault.md records where its credentials of record live, and identity.md Part A documents exactly what the seed restores. Since the 2026-10-24 codification the seed restores the full directory as it stands — every user (including the health-suite test user), group, and membership. The one deliberate exception is theapp1002-prodgroup, which is intentionally unseeded (a test fixture) and arrives through theldap-groups/request path instead. - Harbor. The
tooling-harborapp installs the chart once two manual pieces exist: [manual] the pre-createdharbor-adminSecret (value taken from Vault), and [manual] Harbor’s LDAP auth mode, which is Harbor-internal configuration set through its own API/UI — the chart values do not carry it. The exact values to re-enter are collected as a rebuild checklist in tools/harbor.md. - CRC. [manual] Create the VM (
crc start; the pull secret of record is in Vault), install the OpenShift GitOps operator, and apply the bootstrap root application (clusters/crc/openshift-gitops/bootstrap/). The cluster then self-assembles: Secrets Operator and the vault-access tenants (step 3 must be done — tenants cannot sync against a sealed or unreconciled Vault), Grafana, the Prometheus stack, Kyverno, the logging pipeline, the agent platform, the demo apps. For the routine delete-and-rebuild drill this whole step is scripted — see “CRC recovery: the timed procedure” below. - Front doors and verification. The
rhels-ingressapps publish the service names once cert-manager and the Tailscale operator are up (see certificates.md). Close out with the health suite (runbook 12’s delivery check plus the login checks): a rebuild is done when the suite is green, not when the syncs are.
CRC recovery: the timed procedure
Step 6 above, expanded into the drill that is actually run. CRC is
deleted and rebuilt on purpose, repeatedly, to keep the recovery
time measured and shrinking. The executable form is
clusters/crc/openshift-gitops/bootstrap/recover.sh in
rhels/local-k8s-gitops — one command from the agent VM, and it
needs nothing from the owner: the pull secret comes from Vault,
Vault unseals through the documented Keychain path if it is sealed,
and every other credential is placed from Vault by the script or by
a VaultStaticSecret. The script prints a per-phase timing table;
the phases and their gates:
| # | Phase | Gate to pass before continuing |
|---|---|---|
| 0 | Preflight: mini + m4 reachable, Vault state known | Vault unsealed (script unseals via bootstrap_vault.py if sealed) |
| 1 | Snapshot (records only: app states, crc status) | — |
| 2 | crc delete -f on the m4 | VM gone |
| 3 | Pull secret from Vault (lab/openshift/crc-pull-secret, in-process, shredded after) + crc start | crc status reports Running |
| 4 | API gate: poll oc whoami | API answers (the kubeconfig survives: the CRC API CA is stable across delete/start) |
| 5 | Bootstrap: oc apply -k clusters/crc/openshift-gitops/operator/, then the root application | GitOps operator CSV Succeeded; Argo repo-server appears (the root sync creates the Argo CD instance) |
| 6 | Argo repository secrets: the 11 secrets regenerated from the Vault escrow (lab/argocd/crc-repository-creds/*), in-process | All 11 repository-labelled secrets present in openshift-gitops |
| 7 | UID pins: fix-uid-pins.sh rewrites the monitoring/kyverno UID pins in git to the recreated namespaces’ live sa.scc.uid-range starts and pushes only on change | Namespaces exist; script reports ok/repinned |
| 8 | Vault auth reconcile: vault-access/scripts/reconcile.py reconcile vault-access/namespaces/*.json from the mini | Policies/roles verified by the script |
| 8b | Kyverno verifier ensure: provision-kyverno-verifier.sh ensure on the mini (the script rsyncs it there). Idempotently writes the Vault policy/role crc-kyverno-vso and converges the Harbor verifier robot + KV entry if missing or broken | Ensure green, or recover aborts with a named ENSURE FAIL [<stage>] |
| 9 | Convergence: poll until every Application is Synced/Healthy (45 min cap) | App table printed for the record |
| 10 | Acceptance battery (below) | Battery green |
Two bootstrap details the first drills taught, both now scripted:
the logging app’s controller Role in openshift-logging is applied
once by the script (Argo cannot grant its own rights in a namespace
it has none in yet; the Role itself lives in git at
clusters/crc/logging/operator/argocd-manager-rbac.yaml), and the
Grafana OAuthClient is re-applied by a PostSync Job from the
Vault-delivered secret (an OAuthClient carries its secret as a
literal field, so the object itself can never be committed).
What the 2026-10-24 optimization pass closed
The first timed baseline (below) exposed four self-inflicted delays; all four are fixed in git, so a rebuild no longer meets them:
- Images: the log-producer is pulled from Harbor like everything else (see the image inventory); no build happens during recovery.
- The Prometheus operator’s admission secret was created by
hand; when a rebuild missed it, the operator sat in
ContainerCreating for 43 minutes. A self-healing Job in
clusters/crc/observability/now creates the self-signed secret iff absent, before the operator starts. - UID pins: namespace UID ranges are re-allocated on every
rebuild, and the monitoring/kyverno pins in git went stale —
two manual mid-drill fix commits.
fix-uid-pins.sh(phase 7) makes it one scripted step. - Hand-applied objects are GitOps now: the Cluster Logging
operator + ClusterLogForwarder, the cluster OAuth object (its
two IdP secrets arrive from Vault — the LDAP bind secret is
renamed to the
bindPasswordkey by a Vault Secrets Operator transformation), and two agentops secrets (agentops-snow-token,agentops-github) that a rebuild silently lost.
Acceptance battery
A recovery is done when all of these hold, not when the syncs look quiet:
- Every Argo CD Application Synced and Healthy.
- Every VaultStaticSecret reports
secretSynced: True. - The Kyverno verifier gate: the
harbor-verifierVaultStaticSecret in thekyvernonamespace is synced, and the private-Harbor verify proof is live inapp000-healthPolicyReports — signed image PASS, unsigned control FAIL (“no signatures found”). (The credential itself is mini-plane state outside git; phase 8b is what puts it back.) - Both group-sync CronJobs (
group-sync,openshift-group-sync) run green as manually triggered Jobs. - The lab health suite (in
rhels/lab-agentops,health-suite/) runs green against the rebuilt cluster.
Drill log
| Run | Date | Total | Notes |
|---|---|---|---|
| Baseline (manual) | 2026-10-24 | ≈ 1 h 35 m | GO 19:22 CDT → battery substantially green ~21:02. Sinks: 43 min Prometheus operator admission secret (hand-created, missed); ~20 min crc delete+start; two mid-drill UID-pin fix commits; ~11 repository secrets + AppProjects + namespaces placed by hand. cmdb/mcp-server/snow-sim never recovered that night (Harbor NodePort forwarding on the mini was dead — the reason their image references moved to harbor.rhels.com). |
| Drill 1 (scripted, first run) | 2026-10-25 | ≈ 55 m | recover.sh end to end for the first time; nine defects found and fixed in-run (script set -u array bug, tunnel autostart, kubeconfig refresh after crc start, kustomize bootstrap alongside the root app, Vault reconcile token file, vault-access RBAC wave, tenant Role gaps, ClusterLogForwarder dry-run skip, bundle-preinstalled htpasswd secret needing overwrite: true). Final state fully green: 36/36 apps, 13/13 VaultStaticSecrets, suite 9/9. |
| Drill 2 | 2026-10-25 | 46 m 14 s | Scripted phases clean to convergence in 7 m 30 s. The tail stalled on three more defects, all fixed in Git: tenant Roles missing rights (grafana + proof tenants), the vault-access first-sync CRD race (script guard added), and the Argo CD application-controller OOM-killing at its 2 Gi default limit — 9 restarts, each abandoning in-flight syncs; the instance now runs it at 6 Gi. A host reboot killed the drill script mid-tail; the battery was driven by hand: 36/36 apps, 13/13 secrets, both group-sync Jobs Succeeded, suite 9/9. |
| Drill 3 | 2026-10-25 | 27 m 24 s | The plateau run. Phase times: crc start 188 s, bootstrap 82 s, repository secrets 25 s, UID pins 144 s, Vault reconcile 55 s, convergence 929 s, battery 209 s (suite 9/9). Two bounded interventions, both since scripted: the vault-access sync deadlock (the script now resets the Application object itself if the re-sync does not land) and one forced Kyverno sync past its retry backoff. Where the floor is: crc start bundle provisioning (~3 m), OLM operator installs and 36-app convergence dominated by image pulls and pod scheduling (~13–15 m), VSO secret-sync retry backoffs, and the suite’s own browser checks (~3.5 m). Below ~25 m needs fewer, bigger changes (pre-pulled node image cache, fewer operators), not more scripting. |
What “GitOps” does not cover — the seam list, collected
- Cluster creation (both planes) and host setup.
- Argo CD’s own first install on each cluster, and applying each root application once.
- Vault: install, unseal, and the reconcile script run.
- Owner-placed secrets that other things consume: Cloudflare token,
Tailscale operator credentials,
harbor-admin. - Harbor’s internal LDAP auth configuration.
- The CRC tunnel into the agent environment (its service definition lives in the agent VM’s own config repo; the tunnel’s SSH key is recorded in Vault).
- CRC rebuild seams closed by scripting on 2026-10-24 (covered by
recover.sh, listed here so they are not rediscovered as gaps): the 11 Argo repository secrets (regenerated from the Vault escrow), the Grafana OAuthClient (PostSync Job from the Vault-delivered secret), and the logging app’s controller Role (applied once at bootstrap; Argo cannot grant its own rights).
Everything on this list is a known, accepted seam — the failure mode to avoid is discovering one mid-incident, which is why they are written here.