Disaster Recovery Runbook¶
Status: Partial. Scenarios are marked drillable today, blocked on infrastructure, or blocked on feature work — see the per-section banners. 21 CFR Part 11 §11.10(c) requires tested restore procedures, not just documented ones; the compliance row stays at Implemented (archival) / Untested (restore) until every drillable scenario has a measured drill date. Tracker: #166.
Recovery procedures for Cloud-Native DCS disaster scenarios. This doc complements Backup and Recovery, which focuses on what is backed up. This doc focuses on how to restore, with RTO/RPO targets and verification.
How to read this runbook¶
Every scenario section carries one of three banners:
- Drillable today — the procedure can be exercised on the available
infrastructure (single-node k3s is sufficient). Run the drill, fill
the measured RTO/RPO in the table below, and stamp
Last drillwith today's date. - Blocked on infrastructure — the procedure is written but cannot be exercised on the current clusters (needs a multi-node HA cluster, a second edge device, or re-enabled CNPG S3 backups). Keep the procedure current, and mark the drill pending when the infra lands.
- Blocked on feature work — the scenario depends on platform capabilities that are not yet implemented (tamper-evident audit archival, Object Lock storage). Do not drill, since the outcome would be misleading. Resolve the linked feature issue first.
Do not promote a row in the compliance traceability matrix
based on a tabletop walkthrough alone. A drill means an actual restore
on an actual cluster, with wall-clock RTO measured and a signed
Target.Kind=Qualification AuditRecord attached as evidence (see
Restore Validation below).
RTO / RPO Targets¶
Tentative targets below are aspirational until drilled. Rows pinned to
n/a — pending drill in Measured RTO / Last drill have not been
exercised end-to-end on a real cluster.
| Component | RPO target | RTO target | Measured RTO | Last drill | Drill status |
|---|---|---|---|---|---|
| CRDs (recipes, equipment, procedures) | Last snapshot | Minutes | 95 s (sqlite/kine proxy drill) | 2026-04-22 (sqlite proxy) | Rolled back all post-snapshot CRs on the DR-drill k3s; markers disappeared and count returned to the pre-snapshot baseline. See Full Etcd Loss. |
| etcd (control plane) | Last snapshot | 15 min | 95 s (sqlite/kine proxy drill) / etcd primary-path: pending | 2026-04-22 (sqlite proxy) | Sqlite/kine equivalent of the restore flow drilled on the DR-drill k3s (which ships without --cluster-init). Embedded-etcd primary path still needs a drill on a --cluster-init-provisioned cluster. See Full Etcd Loss. |
| Historian (tag data, S3 + WAL) | Continuous WAL | 30 min | 91 s (correct-path) / 632 s (incl. 2 learning iterations) | 2026-04-22 | Drilled on two-droplet k3s with DO Spaces. See Historian Database Loss — Procedure (primary path). |
Historian (tag data, pg_dump fallback) |
Last pg_dump |
1 hour | 388 s (6.5 min) | 2026-04-22 | Drilled on two-droplet k3s (ctrl + edge). See Historian Database Loss — Procedure (fallback path). |
| Audit records (hot, in etcd) | Last etcd snapshot | Minutes | 95 s (sqlite/kine proxy drill) | 2026-04-22 (sqlite proxy) | Pre-snapshot AuditRecords (e.g., qual-20260422-182643) survived the rollback intact; post-snapshot command audit records rolled back as expected. See Full Etcd Loss. |
| Audit records (archived, in historian) | Continuous | 1 hour | <1 s detection / 124 s full recovery | 2026-04-22 | Drilled on two-droplet k3s with immutable.enabled=true pointed at in-cluster minio with Object Lock (COMPLIANCE, 365-day per-object retention). One manifest covering 3 records mutated via UPDATE audit_records; dcs audit verify --archived flagged digest_mismatch; bundle pulled from minio via mc cp; transactional BEGIN/UPDATE×3/COMMIT restored canonical message field; re-verify clean. See Audit Archive Corruption. Signed qualification: qual-20260422-192940-443c0887. |
| Unit runtime state (edge device) | N/A (derived) | Minutes | 41 s (power-on → pod Running) / 132 s wall-clock | 2026-04-22 | Drilled on two-droplet k3s (ctrl + edge). See Node Loss (Single Edge Device). |
| Control-plane quorum (3-node HA) | Last etcd snapshot | 30 min | n/a — pending drill | n/a — pending drill | Blocked on infrastructure — no HA k3s cluster exists in the project |
Scenario: Full Etcd Loss¶
Drillable today. Single-node k3s is sufficient as long as it was installed with
--cluster-init(embedded etcd). A default k3s install uses kine/sqlite and has no etcd datastore — see the "Backend detection" subsection below before running this drill. Do not drill against a live or shared instance — spin up a throwaway k3s VM and install a minimal Cloud-Native DCS release seeded with a representative CR set.Partial drill 2026-04-22 on the DR-drill two-droplet k3s (
dcs-dr-ctrl+dcs-dr-edge). The control-plane was provisioned viascripts/dr-drill-infra.shwhich did not pass--cluster-init, so the cluster ran kine/sqlite. Ran the functionally-equivalent sqlite snapshot + restart flow: 95 s wall-clock RTO, rollback of all post-snapshot CRs verified, AuditRecords preserved,dcs commandsafety interlocks enforced. The embedded-etcd primary path (k3s--cluster-reset) still needs a dedicated drill on an appropriately-provisioned cluster. Signed qualification:qual-20260422-183656-feb850f7.
An etcd loss is recoverable whenever a recent snapshot exists. The detailed procedure below was written and drilled against k3s (the DR-drill infrastructure). The Talos path for the reference deployments is summarized first.
Talos path (reference deployments)¶
Both reference deployments run Talos (since 2026-05-12,
#363),
which always uses real etcd, so no backend detection is needed. Snapshot and
restore are talosctl operations (see the
Talos v1.10 disaster-recovery guide):
# Snapshot (any healthy control-plane node):
$ talosctl -n <control-plane-ip> etcd snapshot db.snapshot
# Restore: wipe the ephemeral partition, then bootstrap from the snapshot
$ talosctl -n <control-plane-ip> reset --graceful=false --reboot \
--system-labels-to-wipe=EPHEMERAL
$ talosctl -n <control-plane-ip> bootstrap --recover-from=./db.snapshot
After the control plane is back, resume at step 6 of the k3s procedure
below (wait for nodes/controllers, then run dcs qualify dr). The
validation and stuck-procedure triage steps are platform-independent.
A measured Talos drill is still pending. The k3s drill results below
do not transfer.
The guide is linked at a version-pinned URL because the Siderolabs documentation site serves no unversioned path for that page. It is pinned to v1.10 because the reference deployments run Talos v1.10.9. The recovery procedure changes between Talos minor versions, so a link to whatever release is current would describe a platform this project does not run. A Talos bump on the reference deployments therefore has to move this link and the matching one in Backup and Recovery. Upgrade and Rollback carries that step.
k3s path (DR-drill infrastructure)¶
On k3s, etcd snapshots come from the built-in schedule (every 12 hours
by default), plus on-demand via scripts/etcd-snapshot.sh. The
procedure below restores the full control plane from a single snapshot
file.
Backend detection¶
Before running this procedure, confirm that k3s is actually running
embedded etcd. Otherwise k3s etcd-snapshot save fails with
etcd datastore disabled and the rest of the procedure does not
apply.
# ls /var/lib/rancher/k3s/server/db/
state.db(+state.db-shm,state.db-wal) → kine/sqlite backend. The k3s etcd-snapshot / cluster-reset flow does not apply. Fall back to the sqlite-equivalent procedure at the end of this scenario.etcd/subdirectory with member files → embedded etcd. Proceed with the full procedure below.
If a drill cluster needs to use embedded etcd, reprovision with
INSTALL_K3S_EXEC='server --cluster-init …' (the --cluster-init
flag is what switches the backend). scripts/dr-drill-infra.sh
does not currently set this. Add it there if you plan to drill
the embedded-etcd path on that infrastructure.
Preconditions¶
- Snapshot file produced by
scripts/etcd-snapshot.sh(copies to/var/lib/dcs/backups/etcd/with retention) or by k3s's automatic schedule (/var/lib/rancher/k3s/server/db/snapshots/). - Root/sudo access on the k3s server node.
- A known-good baseline expected-state file (for example
pkg/qualify/baselines/dr-default.yaml, or a customer-approved derivative).
Procedure¶
- Capture a baseline of expected state.
On a healthy cluster, prior to the drill:
# kubectl get crd -o name | wc -l
# kubectl get batches.batch.dcs.io -A --no-headers | wc -l
# kubectl get procedures.procedural.dcs.io -A --no-headers | wc -l
Record the CRD count and the per-CR counts. The restore must match these: ±0 for CRDs, and pre-outage CR counts as the floor for everything else. In-flight work created during the outage window is lost RPO.
- Stop the k3s server on the node that will run the restore.
# sudo systemctl stop k3s
Expected: systemctl status k3s shows inactive (dead).
- Identify the most recent consistent snapshot.
# sudo ls -lht /var/lib/dcs/backups/etcd/ | head -5
# sudo ls -lht /var/lib/rancher/k3s/server/db/snapshots/ | head -5
Pick the newest snapshot older than the earliest known-bad
timestamp. Verify the file size is non-zero and the .sha256
sidecar (if present) matches.
- Restore from the snapshot.
# sudo k3s server \
--cluster-reset \
--cluster-reset-restore-path=/var/lib/dcs/backups/etcd/on-demand-NODE-TIMESTAMP.db
Expected tail (~30–60 s):
INFO[...] Managed etcd cluster membership has been reset, restart without --cluster-reset flag now
The command exits. k3s is not running yet, and that is expected.
- Restart k3s normally.
# sudo systemctl start k3s
# sudo systemctl status k3s # expect: active (running)
- Wait for the API server and core controllers.
# sudo k3s kubectl wait --for=condition=Ready node --all --timeout=5m
# sudo k3s kubectl -n kube-system wait --for=condition=Ready pods --all --timeout=5m
- Wait for Cloud-Native DCS controllers.
# sudo k3s kubectl -n dcs-system wait --for=condition=Available deploy --all --timeout=5m
Expected: all five deployments ready
(cloud-native-dcs-physical-operator,
cloud-native-dcs-procedural-operator,
cloud-native-dcs-batch-operator,
cloud-native-dcs-control-operator,
cloud-native-dcs-gateway).
-
Run scripted restore validation. See Restore Validation for the single-command
dcs qualify drinvocation. Stop the wall-clock here. This is the RTO boundary. -
Triage stuck procedures. Any procedures or batches that were transitioning at the snapshot instant are now frozen in an intermediate state. See Scenario: Stuck Procedure After Restore.
-
Record measured RTO/RPO in the RTO/RPO table above and stamp
Last drillwith today's date. Attach thedcs qualify drreport's AuditRecord name to the drill log.
If this fails¶
-
Snapshot missing or corrupt. Fall through to the next-oldest snapshot. You lose more RPO but avoid a longer outage. If no snapshot is usable, the cluster is un-recoverable from etcd alone. Rebuild from declarative manifests (Helm + applied CRs) and accept loss of all runtime/status fields since the last manifest snapshot. The CR half of that rebuild is
dcs restore crds, and it targets a cluster with nothing executing: the gateway refuses a document whose target is running (ADR 0060), and the restore stops at the first refusal. Carrying on would write the rest of a plant model over a live plant. In this scenario the cluster is empty, so every document is a create and nothing can be refused. A refusal here means you are pointed at the wrong cluster. See Restore CRDs from Backup. -
k3s server --cluster-resetexits non-zero. Checkjournalctl -u k3s -n 200 --no-pagerfor the actual error. Common causes: ownership mismatch on/var/lib/rancher/k3s/server/db/(chown to root:root), disk full, or a corrupt snapshot (try the next-oldest). -
Operators crashloop after restart.
kubectl -n dcs-system logs -l app.kubernetes.io/name=cloud-native-dcs --tail=200 --prefix. Typical causes: cert-manager issuer not ready yet (wait), missing secrets that were not backed into the etcd snapshot (re-runhelm upgradewith--reuse-valuesto re-materialize secrets from Helm values), or a CRD schema mismatch because the cluster was restored from a snapshot taken before a CRD migration (roll the chart forward to match the snapshot epoch, then roll again to current). -
dcs qualify drreports FAIL. The report enumerates which checks failed. CRD-missing and pod-not-ready failures usually indicate the restart is not finished. Wait a minute and re-run. Helm-values-digest drift means someone edited values out of band. Reconcile against the approved values YAML before trusting the restore.
Fallback procedure (kine/sqlite backend)¶
If the cluster was installed without --cluster-init, k3s stores
state in state.db (sqlite via kine) and the etcd CLI does not
apply. The online-backup + file-swap flow below is the functional
equivalent. This is what the 2026-04-22 DR drill exercised, at 95 s
wall-clock RTO.
# On the k3s server node:
# Online snapshot while k3s is running (sqlite .backup API):
sqlite3 /var/lib/rancher/k3s/server/db/state.db \
".backup '/var/lib/dcs/backups/sqlite/state-$(date +%F-%H%M%S).db'"
# --- incident occurs ---
# Stop k3s, keep current state.db as evidence, restore:
systemctl stop k3s
cp /var/lib/rancher/k3s/server/db/state.db \
/var/lib/dcs/backups/sqlite/state-at-incident.db
rm -f /var/lib/rancher/k3s/server/db/state.db-shm \
/var/lib/rancher/k3s/server/db/state.db-wal
cp /var/lib/dcs/backups/sqlite/state-<SNAPSHOT_TS>.db \
/var/lib/rancher/k3s/server/db/state.db
chown root:root /var/lib/rancher/k3s/server/db/state.db
chmod 600 /var/lib/rancher/k3s/server/db/state.db
systemctl start k3s
After k3s restarts, run dcs qualify dr --fail-on-deviation
exactly as in step 8 of the primary procedure. The acceptance
criterion is identical. Operators will rediscover the rolled-back
CRs and reconcile. Expect a few restarts (RESTARTS=1-2) on the
operator pods as they re-list. The sqlite WAL/SHM files must be
removed before copying the snapshot in, or sqlite will refuse to
open the database because the WAL ≠ main-file.
Scenario: Historian Database Loss¶
Blocked on infrastructure for the S3 + WAL path. The reference cluster has CNPG backup disabled since 2026-04-13, when a misconfigured WAL archiver filled the root disk (see the comment in
cndcs-deploy-demo'sflux/clusters/demo/apps/dcs-release.yaml). The S3 path is drillable only on a separate test cluster with backup re-enabled. Thepg_dumpfallback path is drillable today on any cluster.
CNPG stores base backups + WAL in S3-compatible object storage when
enabled. The primary restore is point-in-time recovery from those
artifacts. Without S3, the fallback is a pg_dump output loaded into
a fresh CNPG Cluster: coarser RPO, no point-in-time, but functional.
Procedure (primary path: CNPG + S3 PITR)¶
Drilled 2026-04-22 on a two-droplet k3s cluster (
dcs-dr-ctrl+dcs-dr-edge) with DO Spaces as the S3 endpoint. Wall-clock RTO was 91 s on the correct-spec path; the first two attempts each wasted ~5 min on misconfiguration pitfalls that are now called out in the template below. RPO in the drill was ~9.5 min because we deliberately induced loss 9.5 min after the last base backup; a production cluster with continuous WAL archiving sees RPO in seconds. Signed qualification:qual-20260422-182643-22ffd036.
Preconditions: historian.backup.enabled=true at install time with
historian.backup.s3.{endpointURL,bucket,path,secretRef} populated,
and at least one completed ScheduledBackup run visible in
kubectl get backups.postgresql.cnpg.io -n dcs-system before the
inducement. Without a base backup in the bucket there is no WAL
chain anchor to replay from, and you are in the fallback path below.
Gotchas the 2026-04-22 drill surfaced:
bootstrap.recovery.databaseandownermust mirror the originalinitdb.database/owner. Default app DB isapp. If you omit these, CNPG creates anappDB that the recovered backup never populated, and the-db-appsecret'suristill says…/app. The historian writer then connects to an empty schema-less DB and crashloops on migration 001.-
serverNamein the recovery cluster'sbarmanObjectStoremust be new. CNPG's pre-bootstrap check rejects a non-empty archive path (barman-cloud-check-wal-archive: Expected empty archive) to prevent timeline collisions. Rotate the suffix per attempt (-recovered-YYYYMMDD[-vN]) or leavebackup:off the recovery cluster entirely if you don't need the recovered cluster to push new WAL. -
Detect and confirm data loss. Rule out transient query-planner regressions, blocked connections, and ingestion pauses before declaring loss. Restoring a working database is more disruptive than finishing a long query.
# kubectl -n dcs-system exec -it deploy/cloud-native-dcs-historian -- \
dcs historian tags --tag <known-good-tag> --from "$(date -u -d '5 min ago' +%Y-%m-%dT%H:%M:%SZ)"
If this returns empty while MQTT ingestion is healthy, data loss
is real. Record outage_start as the wall-clock timestamp of the
first failing query.
- Identify the most recent base backup and WAL chain.
# kubectl -n dcs-system get backups.postgresql.cnpg.io \
-l cnpg.io/cluster=cloud-native-dcs-historian-db \
--sort-by=.status.stoppedAt \
-o custom-columns=NAME:.metadata.name,STARTED:.status.startedAt,PHASE:.status.phase | tail -5
Pick the newest completed backup. Its backupId becomes the
bootstrap.recovery.backup.name in step 4.
- Stop the historian writer so it cannot ingest into the doomed cluster while the restore is in flight.
# kubectl -n dcs-system scale deploy cloud-native-dcs-historian --replicas=0
# kubectl -n dcs-system wait --for=delete pod \
-l app.kubernetes.io/component=historian --timeout=60s
- Apply a recovery Cluster with
bootstrap.recoverypointing at the backup objectstore. Use a distinct name (-restore) so the original Cluster object stays quarantined as evidence until the restore is verified green.
# cat <<'YAML' | kubectl apply -f -
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: cloud-native-dcs-historian-db-restore
namespace: dcs-system
spec:
instances: 1
imageCatalogRef:
apiGroup: postgresql.cnpg.io
kind: ClusterImageCatalog
name: cloud-native-dcs-timescaledb
major: 16
podSecurityContext: { runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000 }
postgresql:
shared_preload_libraries: [timescaledb]
storage: { size: 20Gi }
bootstrap:
recovery:
# Must match the original cluster's initdb.database/owner
# (see "Gotchas" above). Without these, the CNPG-generated
# app secret points at an empty default `app` DB.
database: historian
owner: historian
source: cloud-native-dcs-historian-db
recoveryTarget:
targetTime: "TARGET_TIMESTAMP_UTC" # ← outage_start - 1m
externalClusters:
- name: cloud-native-dcs-historian-db
barmanObjectStore:
destinationPath: "s3://BUCKET/historian"
endpointURL: "SPACES_ENDPOINT" # e.g., https://nyc3.digitaloceanspaces.com
s3Credentials:
accessKeyId: { name: cloud-native-dcs-historian-backup-s3, key: ACCESS_KEY_ID }
secretAccessKey: { name: cloud-native-dcs-historian-backup-s3, key: ACCESS_SECRET_KEY }
YAML
Substitute TARGET_TIMESTAMP_UTC = outage_start - 1m (RFC 3339,
UTC). The -1m is the deliberate test: the query in step 6 asks
for data that must exist in the restored database.
- Wait for the recovery Cluster to finish replay.
# kubectl -n dcs-system wait cluster/cloud-native-dcs-historian-db-restore \
--for=condition=Ready --timeout=15m
# kubectl -n dcs-system exec cloud-native-dcs-historian-db-restore-1 -- \
psql -U postgres -d historian -c "SELECT pg_is_in_recovery();"
Expected: condition Ready=True and pg_is_in_recovery() = false
(recovery mode exited, server is accepting writes). Stop the
wall-clock here. This is the RTO boundary.
- Verify tag continuity across the restore point.
# kubectl -n dcs-system patch deploy cloud-native-dcs-historian --type=merge -p \
'{"spec":{"template":{"spec":{"containers":[{"name":"historian","env":[{"name":"HISTORIAN_DB_HOST","value":"cloud-native-dcs-historian-db-restore-rw"}]}]}}}}'
# kubectl -n dcs-system scale deploy cloud-native-dcs-historian --replicas=1
# dcs historian tags --tag <known-good-tag> \
--from "$(date -u -d 'outage_start -1m' +%Y-%m-%dT%H:%M:%SZ)" \
--to "$(date -u -d 'outage_start -30s' +%Y-%m-%dT%H:%M:%SZ)"
Expected: at least one sample in the minute before the outage. If
the window is empty the restore regressed past the WAL tail.
Re-apply step 4 with a later targetTime that still predates the
inducement.
-
Promote the restored cluster once verification passes. Rename by swapping the
rwservice endpoint, then delete the original Cluster. Keep the most recent backup of the original as evidence attached to the deviation report. -
Record measured RTO/RPO in the RTO/RPO table and stamp
Last drillwith today's date. Expected RPO is the WAL archive lag: seconds for a healthybarman-cloud-wal-archive, and the 2026-04-13 incident saw it balloon to hours when the bucket filled.
Procedure (fallback path: pg_dump restore)¶
Use this path when either (a) no S3 backup is configured, or (b) the objectstore itself is the failure domain (e.g., the S3 endpoint is unreachable during the incident).
Drilled 2026-04-22 on a two-droplet k3s cluster (
dcs-dr-ctrl+dcs-dr-edge). Steps 2–6 below are what was exercised; wall-clock RTO 388 s for a 50-row corpus. Restore itself was trivial (0 s stream-in); the dominant cost was the new CNPG primary's initdb + TimescaleDB image pull (~5.5 min). Signed qualification:qual-20260422-181008-3428ada6.
- Locate the most recent
pg_dumpoutput and validate it before anything else is touched:
# dcs backup verify /var/lib/dcs/backups/historian/dcs-historian-2026-08-15.sql
Dumps are captured by dcs backup historian. That command runs
pg_dump inside the primary Pod and writes the sidecar integrity
manifest verify reads. It refuses a dump it cannot prove ran to
completion. See backup-recovery.md
"pg_dump Fallback (Administrator)". A dump captured with a shell
redirection has no manifest, so verify cannot be run over it at
all. Step 5 is then the first thing that would notice a truncated
file.
2. Stop the historian writer by scaling
cloud-native-dcs-historian to 0 replicas.
3. Re-create the CNPG Cluster (delete the old one if it still
exists, since a helm upgrade reapplies the templated Cluster CR).
Wait for status.phase = "Cluster in healthy state" and
status.readyInstances = 1. On a cold node this is bounded by
the TimescaleDB-HA image pull (~300 MB).
4. Re-scale historian to 0 again after the helm upgrade. It
will otherwise crashloop against a freshly initdb'd DB that has
no historian schema yet. The previous step's helm reapply
restarts it at replicas=1.
5. Stream the dump back in:
kubectl exec -i <primary> -c postgres -- psql -U postgres -d
historian -v ON_ERROR_STOP=1 < dump.sql. The dump already
contains the TimescaleDB CREATE EXTENSION and hypertable
metadata, so the _hyper_*_chunk child tables get re-registered
automatically.
6. Scale historian back to 1. Verify ingestion resumes by publishing
a test MQTT sample and confirming it lands in tag_values.
7. Record the RPO as last_pre_outage_write_ts - last_dump_row_ts,
typically on the order of hours (cadence of the dump cron).
If this fails¶
- Base backup present but WAL gap. CNPG PITR requires a continuous
WAL chain. A gap (common when WAL archiving was failing unnoticed)
forces recovery to the base-backup epoch only, a much coarser RPO.
This is the failure mode that caused the 2026-04-13 incident.
Monitor
barman-cloud-wal-archiveexit codes. - Fresh CNPG cluster won't bind to the restored volume. Check
that the new
Clusterspec'sstorage.storageClassand claim size match the snapshot. CNPG refuses to start a restore into a smaller volume.
Scenario: Node Loss (Single Edge Device)¶
Drillable today. Exercised 2026-04-22 on a two-droplet k3s cluster (
dcs-dr-ctrl+dcs-dr-edge, NYC1) spun up viascripts/dr-drill-infra.sh. Reproduce by standing up that harness, pinning a workload todcs.io/device=edge-01with 30 s unreachable/not-ready tolerations, then power-cycling the edge droplet. Do not drill against a live or shared instance.
Unit runtime uses hostPath for FB-network persistence, so losing
the node means losing the persisted runtime state. The declarative
state (Unit CR) still lives in etcd.
Measured RTO (2026-04-22 drill)¶
| Event | Elapsed from power-off |
|---|---|
Node Ready=Unknown (k3s detection) |
~50 s (default node-monitor-grace-period) |
Pod Pending (30 s toleration expires) |
~80 s |
| Power-on issued | 90 s |
Node Ready=True again |
~125 s |
Pod Running on reattached node |
132 s |
Wall-clock RTO: 132 s. From the moment the edge droplet powers
back on until the pinned workload is Running again: 41 s. RPO
is 0 for declarative state (CRs live in ctrl-plane etcd which was
untouched). The hostPath-persisted FB network survives a clean reboot,
so runtime RPO is 0 as well.
Signed AuditRecord: qual-20260422-175914-536330b2 (retrieve with
dcs audit export --target-kind Qualification).
Procedure¶
- Confirm the unit-runtime pod is stuck, with transient restarts
ruled out (
kubectl -n <site> describe pod <runtime>,kubectl get node <nodeName>,NotReady> 5 min with no pending kubelet reconnect). - If hardware is unrecoverable, join a replacement device at the deployment layer and register it (see Device Enrollment).
- Rebind the Unit CR's
nodeSelectorlabel to the new device (kubectl patch unit ... --type=merge -p '{"spec":{"nodeSelector":{"dcs.io/device":"<new-name>"}}}'). - Verify the runtime replays the last-known FB network from its
local
hostPath. It will: each device keeps its own copy, so the new device starts "fresh" and the operator re-applies the declarative UnitSpec. - Verify I/O drivers reconnect to their remote I/O endpoints.
If this fails¶
- New device has no cached FB network. Expected: a fresh device
starts with no
/var/lib/dcs/fb-cache. The operator will reconcile the UnitSpec and produce a fresh network. I/O will be offline briefly while the new runtime starts. - I/O addresses were device-local. Should not happen. Addresses are encoded in IOModule CRs, which live in etcd and survive. If this does happen it is a bug in the product, and no recovery procedure covers it.
Scenario: Node Loss (Control Plane, Multi-Node)¶
Blocked on infrastructure. No 3-node HA k3s cluster exists anywhere in the project. Provisioning one is a prerequisite for this drill. Open a separate infra issue before filling this section.
Quorum-loss recovery for an HA control plane follows upstream
guidance for the platform: on Talos, talosctl bootstrap
--recover-from=<snapshot> on one surviving member, then rejoin the
others (see the Talos disaster-recovery guide). On k3s, restore the
surviving node's etcd snapshot via --cluster-reset, then add fresh
replacement members. Procedure is intentionally left as a pointer to
upstream until we have a cluster to test against.
Scenario: Certificate Expiry¶
Drilled 2026-04-22 on the DR-drill two-droplet k3s (
dcs-dr-ctrl+dcs-dr-edge). Walked the cert-manager-failed degenerate case end-to-end: scaled cert-manager to zero, corrupted the gateway-mtls Secret, killed the gateway pod so the failure surfaced (EOF on/api/v1/system/health, "tls: failed to find any PEM data" in pod logs), manually re-issued an ECDSA P-256 cert from the auto-created CA Secret using openssl, patched the Secret, rolled the gateway, confirmed health. Wall clock from cert-manager-down to gateway-healthy: 123 s. Signed qualification:qual-20260422-192458-ca6ef6e0.
Normal cert rotation is handled by cert-manager + the rotation
runbook: cmctl renew (graceful) or the
rotation runbook's "mTLS Component Certificate (Emergency)" path. The
runbook's emergency path assumes cert-manager is recoverable. This
scenario covers the unrecoverable case: cert-manager itself is down
and one or more component certs are minutes from expiry. The fallback
is openssl + the auto-created CA Secret.
Procedure (manual emergency re-issue)¶
The gateway is the canonical example because it is the most
externally-visible component. The same procedure applies to any other
component with a cert-manager.io/Certificate in dcs-system (the
operators, io-probe, historian).
- Confirm cert-manager is the failure mode. Healthy cert-manager would have already renewed. If it has not, something in the controller / webhook / cainjector trio is wedged.
kubectl -n cert-manager get pods
kubectl -n cert-manager logs deploy/cert-manager --since=15m | tail
If cert-manager is recoverable (e.g. crashlooping on a transient
API outage, OOMKilled), fix it first and let cmctl renew --all
handle the rotation. That is the rotation runbook's path. Only
continue here if the cert window is shorter than the cert-manager
recovery ETA.
- Pull the CA cert + key from the in-cluster CA Secret. This is
the break-glass material. The auto-created CA is in
cloud-native-dcs-mtls-cain the release namespace.
mkdir -p /tmp/cert-emergency && cd /tmp/cert-emergency
kubectl -n dcs-system get secret cloud-native-dcs-mtls-ca \
-o jsonpath='{.data.tls\.crt}' | base64 -d > ca.crt
kubectl -n dcs-system get secret cloud-native-dcs-mtls-ca \
-o jsonpath='{.data.tls\.key}' | base64 -d > ca.key
openssl x509 -in ca.crt -noout -subject -dates
- Mint a replacement cert with the original Certificate's
dnsNames. The dnsNames are pinned in the
cert-manager.io/CertificateCR. Do not free-hand them. Algorithm defaults are ECDSA P-256 (matchesmtls.certManagerdefaults).
kubectl -n dcs-system get certificate cloud-native-dcs-gateway-mtls \
-o jsonpath='{.spec.dnsNames}' ; echo
openssl ecparam -name prime256v1 -genkey -noout -out gateway.key
cat > csr.cnf <<'EOF'
[req]
prompt = no
distinguished_name = dn
req_extensions = req_ext
[dn]
CN = dcs-gateway
[req_ext]
subjectAltName = @alt_names
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth, serverAuth
[alt_names]
DNS.1 = cloud-native-dcs-gateway
DNS.2 = cloud-native-dcs-gateway.dcs-system.svc
DNS.3 = cloud-native-dcs-gateway.dcs-system.svc.cluster.local
EOF
openssl req -new -key gateway.key -out gateway.csr -config csr.cnf
openssl x509 -req -in gateway.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 1 -out gateway.crt -extfile csr.cnf -extensions req_ext
-days 1 is deliberate. These break-glass certs must be
replaced with cert-manager-issued certs as soon as cert-manager is
healthy, and a tight expiry forces the followup.
- Patch the component Secret + roll the deployment. Preserve
ca.crtso peers still validate.
kubectl -n dcs-system create secret generic cloud-native-dcs-gateway-mtls \
--from-file=tls.crt=gateway.crt \
--from-file=tls.key=gateway.key \
--from-file=ca.crt=ca.crt \
--type=kubernetes.io/tls --dry-run=client -o yaml \
| kubectl apply -n dcs-system -f -
kubectl -n dcs-system rollout restart deploy/cloud-native-dcs-gateway
kubectl -n dcs-system rollout status deploy/cloud-native-dcs-gateway --timeout=120s
- Verify. The health endpoint comes back,
dcs healthis green, and pod logs no longer show TLS errors.
dcs health
kubectl -n dcs-system logs deploy/cloud-native-dcs-gateway --since=2m | grep -iE 'tls|x509'
- Restore cert-manager and force a canonical re-issue. Once cert-manager is back, delete the manually-issued Secret so cert-manager re-creates it from its own Certificate CR, then roll the component once more.
kubectl -n cert-manager scale deploy --all --replicas=1
kubectl -n cert-manager rollout status deploy --timeout=120s
kubectl -n dcs-system delete secret cloud-native-dcs-gateway-mtls
# Wait ~10s for cert-manager to recreate the Secret, then:
kubectl -n dcs-system rollout restart deploy/cloud-native-dcs-gateway
- File a deviation report AuditRecord. Capture cert-manager failure mode, openssl break-glass justification, and the re-issued cert's serial / fingerprint. The 1-day expiry on the manually-issued cert is itself the action item, and the followup ticket should be filed against the original incident.
What this drill measured¶
| Step | Wall clock |
|---|---|
| cert-manager scaled to 0 | 0 s (T0) |
| Gateway pod failure surfaced (EOF on health) | ~25 s |
| Manual cert minted (openssl) + Secret patched + rollout restart issued | ~85 s |
| Gateway rollout complete and health green | 123 s (T1) |
The bulk of the wall clock is openssl invocation + kubectl round-trips. A templated re-issue script checked into ops would compress this further.
If this fails¶
-
Step 2: the CA Secret itself is missing or unreadable. Then cert-manager hasn't merely failed. The ClusterIssuer's own material has been wiped. There is no break-glass CA to re-sign against, and the deviation has escalated to a CA-loss incident. The mTLS CA Certificate rotation procedure covers that case separately, and it assumes a healthy cert-manager. Restore the CA Secret from a Helm-state backup before proceeding, or rebuild the cluster. There is no in-cluster path.
-
Step 4: the patched Secret is rejected. Confirm the Secret type is
kubernetes.io/tls(the operator pkgpkg/tlsutilexpects this) and thattls.crt+tls.keymatch. A mismatch typically surfaces astls: private key does not match public keyin the component's startup logs. -
Step 6: cert-manager re-issued the Secret but the gateway still trusts only the break-glass cert. Each component's Secret carries its own copy of
ca.crt. After a CA rotation, runcmctl renew -n dcs-system --allto force every component's Secret to be rewritten with the current CA bundle (see the mTLS CA Certificate warning about CA fingerprint changes on renewal).
Cross-link: Secret and Certificate Rotation, the graceful and emergency cmctl-based paths.
Scenario: Audit Archive Corruption¶
Drilled 2026-04-22 on the DR-drill two-droplet k3s (
dcs-dr-ctrl+dcs-dr-edge) withimmutable.enabled=truepointed at an in-cluster minio bucket created--with-lockand default retentionCOMPLIANCE 30d(per-object retention 365d set by the archiver). One manifest covering 3 records corrupted viaUPDATE audit_records, detected viadcs audit verify --archived(digest_mismatch), quarantined via AuditRecord (audit-quarantine-zc8pl), bundle pulled from minio withmc cp(proof:X-Amz-Object-Lock-Mode: COMPLIANCE,Retain-Until-Date: 2027-04-22T19:18:32Z), restored via transactional psqlBEGIN/UPDATE×3/COMMIT, re-verify clean, deviation report filed (audit-deviation-lzcxh). Wall clock from corruption to clean re-verify: 124 s (detection itself was <1 s — single-manifest verify). Signed qualification:qual-20260422-192940-443c0887.Drillable on any cluster with
immutable.enabled=true. The three #216 primitives are all onmain: per-batch HMAC manifests (Phase A), scheduled verification plus UI panel (Phase B), and the Object Lock mirror (Phase C). The drill is conditional because step 4 depends onhistorian.audit.archival.immutable.enabled=truehaving been in effect when the corrupted batch was archived — there is no way to retroactively produce an immutable copy of a batch that was never mirrored. Run the drill on a throwaway cluster with a pre-provisioned Object-Lock bucket; do not drill on a live instance or any cluster with live pharma batches.The 2026-04-22 drill pulled the bundle by name against a bucket where nobody had issued a delete, so it did not exercise the delete-marker case step 4 now covers. A drill that wants to cover it issues
mc rm "s3-alias/BUCKET/${OBJECT_KEY}"(no--vid) after step 1 and confirms the version-targeted read still recovers the bundle.
This scenario covers the detection-through-recovery loop for 21 CFR
Part 11 §11.10(c). The archive is tamper-evident by construction: any
mutation to an archived record, any deletion of a record covered by a
manifest, or any attempt to forge a new manifest invalidates the HMAC
signature and is surfaced by dcs audit verify --archived. The drill
exercises detection, evidence preservation, and restoration from the
immutable S3 copy.
Preconditions¶
historian.audit.archival.immutable.enabled=truein the Helm values and at least one archival CronJob run has completed before the corruption is simulated (otherwise there is nothing in the bucket to restore from).- Direct
psqlaccess to the historian database or a break-glass IAM role that can write to theaudit_recordstable. Regular operators cannot produce the corruption we're detecting, which is the point. mc(orawsCLI) configured against the same bucket the archiver uses.-
A known-good baseline expected-state: the most recent
dcs audit verify --archivedrun should have reported all manifests clean. Pull that report from the Archive Integrity panel's history or from an AuditRecord withTarget.Kind=ArchiveIntegrityCheckcreated in the hour before the drill.If that baseline is not clean and the failures are
missing_recordson manifests older than your upgrade to 0.2.5, they are most likely orphans, with no tampering involved. Archivers before that release derived a random manifest ID per attempt, so every retry after a failed mirror upload committed a manifest that no record pointed at. The archiver now removes such rows on each run, but only when another manifest in the same namespace carries an identical digest and still holds its records. An empty manifest that is not a proven duplicate is left alone, because retention trimming can empty a legitimate manifest and that row is then the only surviving evidence those records existed. Run the CronJob once and re-verify before treating a stalemissing_recordsas a finding.
Procedure¶
- Simulate the corruption. Mutate one row in
audit_recordsso its content no longer matches the canonical bytes the manifest digest was computed over. In a drill, a one-field rewrite is sufficient:
# psql "$DATABASE_URL" -c \
"UPDATE audit_records SET message = 'tampered by drill'
WHERE manifest_id = 'MANIFEST_UUID' AND uid = 'UID_UNDER_TEST';"
Pick MANIFEST_UUID + UID_UNDER_TEST from the manifest you want
to break. Record the pre-mutation values of the row for the
deviation report (the row you just rewrote is the evidence).
- Detect the corruption. Run the verifier against the affected window:
# dcs audit verify --archived \
--since "$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
--output json
Expected: non-zero exit, and at least one results entry with
reason: digest_mismatch naming MANIFEST_UUID. Stop the wall
clock here for the detection-RTO target.
If the gateway.archiveIntegrity scheduler is enabled, the failure
should also appear in the Archive Integrity panel's "Latest
Verification" card within one scheduler interval (default 6h), and
an AuditRecord with Result=Failure and
Target.Kind=ArchiveIntegrityCheck will be written to the gateway
Pod's namespace (default dcs-system). Capture that record's name.
It is the primary audit-trail anchor for the deviation.
- Isolate the affected manifest (never delete). Stamp an AuditRecord that marks the manifest as under quarantine. This is the 21 CFR Part 11 §11.10(e) trail of the quarantine decision:
# kubectl apply -f - <<'YAML'
apiVersion: audit.dcs.io/v1alpha1
kind: AuditRecord
metadata:
generateName: audit-quarantine-
namespace: dcs-system
labels:
audit.dcs.io/target: ArchiveIntegrityCheck
spec:
timestamp: "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
action: Update
result: Failure
actor:
component: dcs-operator
userID: "OPERATOR_OIDC_SUBJECT"
target:
apiGroup: audit.dcs.io
kind: ArchiveIntegrityCheck
name: "MANIFEST_UUID"
reason: "Quarantined after digest_mismatch detected during DR drill"
message: "Manifest MANIFEST_UUID under quarantine pending restore from S3 mirror"
YAML
Do not delete or overwrite the tampered PG rows. The tampered row is evidence. Destroying it destroys the forensic record the deviation report needs.
- Pull the authoritative copy from the immutable mirror. The
object key is derived from the manifest's
namespaceandCreatedAt. Read the bundle by version. A name-addressed read is the one a delete marker fools. Object Lock protects the object version alone, so aDeleteObjectcall that names no version is accepted against a locked bundle. It appends a delete marker as the new latest version, and every read addressed by name then reports the object missing while the retained version sits intact underneath it.
# OBJECT_KEY="audit-archive/NAMESPACE/YYYY/MM/DD/MANIFEST_UUID.json"
# (NAMESPACE and the date come from the manifest row's namespace
# and created_at columns in audit_archive_manifest)
# mc ls --versions "s3-alias/BUCKET/${OBJECT_KEY}"
# VERSION_ID=$(mc ls --versions --json "s3-alias/BUCKET/${OBJECT_KEY}" \
| jq -rs 'map(select(.isDeleteMarker != true)) | .[0].versionId')
# mc cp --vid "${VERSION_ID}" \
"s3-alias/BUCKET/${OBJECT_KEY}" /tmp/archive-restore.json
# jq -r '.manifest.digest_sha256' /tmp/archive-restore.json \
| base64 -d | xxd -p -c 32
mc ls --versions lists the retained PUT version even when a
delete marker sits above it, and --vid reads straight through the
marker. Listing is newest first, so .[0] of the non-marker versions
is the live bundle. Use jq -s with no head -1 pipe. A
key the archiver retried carries several PUT versions, and a head
that closes the pipe early kills jq with EPIPE under a pipefail
shell. Record VERSION_ID for the deviation report. Retention rides
the version itself, so the
version-targeted mc stat is what proves it:
# mc stat --vid "${VERSION_ID}" "s3-alias/BUCKET/${OBJECT_KEY}"
# X-Amz-Object-Lock-Mode : COMPLIANCE
# X-Amz-Object-Lock-Retain-Until-Date: 2027-08-11T15:10:48.000Z
The bundle's manifest keys are snake_case (digest_sha256,
signature_hex, key_id). The digest_sha256 value is base64,
which is why the readback above decodes it before comparing.
Sanity-check the bundle against the manifest row in PG. Those three
fields should match the PG row even though the records array is
known-good while the PG rows are tampered. That
version was written under Compliance-mode Object Lock at archive
time, so no party with credentials could have mutated it. The
X-Amz-Object-Lock-Mode metadata above is the proof.
- Restore the tampered records from the bundle. Reload the
records array from the JSON bundle back into
audit_records:
# jq -c '.records[]' /tmp/archive-restore.json | \
while read -r rec; do
UID=$(echo "$rec" | jq -r '.uid')
NS=$(echo "$rec" | jq -r '.namespace')
TS=$(echo "$rec" | jq -r '.ts')
MSG=$(echo "$rec" | jq -r '.message')
psql "$DATABASE_URL" -c \
"UPDATE audit_records
SET message = '$MSG'
WHERE namespace = '$NS' AND uid = '$UID' AND ts = '$TS';"
done
(In a real incident, write this as a transaction with explicit
BEGIN; SELECT ... FOR UPDATE; UPDATE ...; COMMIT; and capture the
psql session log for the deviation report. The one-liner above is
the drill-simplified version.)
- Re-verify. The same
dcs audit verify --archivedinvocation from step 2 should now exit 0 and report every manifest clean. Stop the wall clock. This is the full-recovery-RTO target.
dcs audit verify --archived naming digest_mismatch once a row is altered underneath it, and the archive proving itself again after the mirrored copy is restored. The corruption and the restore are injected off camera. What is filmed is the verification on either side of them.- File the deviation report. The report must include, at minimum:
- Detection timestamp + the failing
dcs audit verify --archivedJSON body. - Quarantine AuditRecord name (the CR from step 3).
- Object key, version ID, and
mc stat --vidoutput for the S3 bundle used for restore (proof that the authoritative copy was immutable at time of access). If a delete marker was covering that version, its own version ID and creation time belong here too (see the first failure mode below). - Psql transaction log from step 5.
- Post-restore green
dcs audit verify --archivedJSON body. - Root-cause analysis (for a drill, "simulated by DR runbook step 1", and for a real incident whatever the storage-level investigation turns up).
-
Quality-system signature per the site's SOP.
-
Record measured RTO/RPO in the RTO/RPO table and stamp
Last drillwith today's date. Two RTO values: detection (step 2) and full recovery (step 6). RPO for this scenario is effectively zero: the mirror captures every signed batch, so nothing is lost.
If this fails¶
- Step 4: the read reports "Object does not exist", or the prefix
lists empty. Do not conclude that the archive is gone. An
unversioned
DeleteObjectagainst a Compliance-locked bundle is accepted by the server: it appends a zero-byte delete marker as the new latest version and returns success, and a name-addressedmc cp/mc cat/mc lsthen behaves exactly as it would if the object had never been written. List versions before drawing any conclusion:
# mc ls --versions "s3-alias/BUCKET/${OBJECT_KEY}"
# [...] 0B ... d7423dab-... v2 DEL MANIFEST_UUID.json
# [...] 1.0KiB ... 6d7960b3-... v1 PUT MANIFEST_UUID.json
A DEL row sitting above a PUT row means the bundle is intact and
recoverable through step 4's --vid read. It also means somebody
holding s3:DeleteObject on the bucket issued that delete, which is
a second deviation in its own right and is very likely the same actor
as the PG tampering. Record the marker's version ID and creation time,
and treat the bucket credentials as compromised. Only if the key has
no versions at all was the object never written. That is the next
failure mode.
-
Step 4: the S3 object was never written. The corrupted manifest was written by an archiver run that pre-dated
immutable.enabled=truebeing flipped on. There is no restore source. The deviation report must record this as an unrecoverable loss of the affected records. The tampered PG rows stay in quarantine and the archive is flagged in the QMS as partial. The remediation is an organizational control: enable the feature mid-cluster only with a backfill, or accept the gap window in writing. -
Step 4: the S3 object's own digest does not match the PG manifest digest. Two legitimate causes. (a) The bundle was written by an older archiver whose canonical JSON format differed. Check
schemaVersionin the bundle and re-canonicalize using the older format. (b) The signing key referenced by the manifest is no longer indcs-signing-key, sodcs audit verify --archivedcorrectly reportsunknown_key. Re-mount the retired key version askey.<id>in the Secret and re-run the verify. Never modify the bundle. If neither (a) nor (b) applies, the bundle itself is compromised (Object Lock bypass or bucket-root abuse). Escalate to the QMS as a serious deviation. -
Step 5: the PG update transaction fails. The compressed TimescaleDB chunks older than 30 days cannot be updated in place without decompression. Decompress the affected chunk with
SELECT decompress_chunk(show_chunks('audit_records', ...));, re-run the update, and optionally recompress when done. The archive retention policy (default 3 years) governs compression. A corrupted manifest older than the 30-day compression boundary will always need this step. -
Step 6: verify-archived still reports
digest_mismatch. Confirm that the UPDATE affected every tampered row, beyond the one you first targeted. Re-run step 1'sSELECTon a fresh window to spot any additional mutations. If the verify still fails with a clean set, the canonical bytes differ for a reason other than content. Check timezone drift in thets/k8sCreationTimestampcolumns. TheCanonicalRecordByteshelper forces UTC on marshal, so this should be stable. A custom-imported archive might not be. -
Object Lock retention already expired. Objects pass out of lock after
retentionDays. If a corruption is detected more thanretentionDaysafter the batch was archived, the mirror no longer protects it. Bucket lifecycle rules should transition such objects to Glacier Deep Archive and never expire them, so the data is still retrievable (see backup-recovery.md). If the object was lifecycle-expired, this degrades to the same "unrecoverable loss" case as the first failure mode.
Scenario: Stuck Procedure After Restore¶
Drillable today as part of the etcd-loss drill (any etcd restore will leave in-flight procedures frozen in intermediate states).
Identification¶
After the etcd restore completes, enumerate procedures in intermediate states:
$ dcs get procedures
$ kubectl get procedures.procedural.dcs.io -A \
-o jsonpath='{range .items[?(@.status.state=="Stopping")]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}'
$ kubectl get procedures.procedural.dcs.io -A \
-o jsonpath='{range .items[?(@.status.state=="Holding")]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}'
Do the same for unitprocedures, operations, phases, and
batches. ISA-88 intermediate states that indicate a transition was
interrupted: Starting, Stopping, Holding, Unholding,
Restarting, Resetting, Aborting, Pausing, Resuming,
Clearing.
Decision: force state, or roll the batch?¶
Force the next stable state using dcs command when all of:
- The equipment is known to be safe in the target stable state (confirmed by the operator at the physical cell).
- The intermediate-state duration is short (< 15 min) and there is no material safety reason to treat the in-flight work as compromised.
- The target stable state matches the one the procedure was
transitioning toward at snapshot time (e.g.,
Stopping→ forceStop,Holding→ forceHold).
$ dcs command Batch <batch-name> Stop --reason "post-dr-restore force-state, equipment confirmed safe"
$ dcs command Batch <batch-name> Hold --reason "post-dr-restore force-state, awaiting operator review"
Commands target the owning Batch (the only commandable kind) and propagate down to the in-flight procedure.
dcs command already requires --reason for irreversible commands
(Stop/Hold/Abort/Reset), so the justification is captured in the
resulting AuditRecord. Make the reason explicit. The audit trail
is the regulatory record that the transition was a conscious
recovery action, and an unexplained command reads as a normal one.
Held, and the reason read back out of the audit trail under the batch's own correlation ID. No disaster precedes this run and it is not a timed drill.Roll the batch (abort, then start a new one from a checkpoint) when any of:
- Physical-cell state is unknown or unsafe (for example, a valve was mid-stroke and cannot be confirmed without manual inspection).
- The procedure had been in the intermediate state for > 15 min at the snapshot instant, meaning the transition itself may have been stuck, with no transient explanation left.
- Material integrity is in question (contamination, out-of-spec conditions during the outage).
- Quality deviation rules require a CAPA for any post-DR-restore batch regardless of state.
To roll:
$ dcs command Batch <batch-name> Abort --reason "post-dr-restore, quality requires roll"
# wait until state is Aborted
$ dcs get batches # confirm batch is in terminal state
$ dcs create batch ... # new batch from appropriate checkpoint
Evidence preservation¶
Do not delete or edit stuck-procedure CRs. The intermediate-state
AuditRecords from before the outage are the legal record of what the
system was doing at the snapshot instant. Archive them via the
normal audit-archival flow. Any post-restore force-state
AuditRecord will carry the same CorrelationID as the originating
batch, so the full recovery story is reconstructible from
dcs audit trace <batchID>.
Before You Restore: is the backup whole?¶
A restore can only rebuild what the export captured, and an export captures only the kinds the gateway was permitted to read. Run the pre-restore check first. It answers integrity and completeness:
$ dcs backup verify /backups/dcs-crds-2026-03-27.yaml
PARTIAL BACKUP means the file is intact but a kind was never in it,
almost always over a missing rule in the gateway ClusterRole. Do not start the
drill clock on that file: take a fresh backup once the gateway can read
the kind. If the outage means no better backup exists, restore it with
--allow-partial and record the named kinds as a known gap in the drill
evidence, because nothing downstream will report them again (dcs restore
crds reports 0 failed over kinds that were never in the file).
Background and the footer format: Backup and
Recovery
(#1299).
Restore Validation¶
Restore validation runs a scripted DR qualification baseline against
the restored cluster and persists the report as a signed
Target.Kind=Qualification AuditRecord, the same retention path as
IQ/OQ/PQ. The drill therefore produces regulatory evidence, with no
ad-hoc markdown log to lose.
dcs qualify dr drives after a restore, on camera in its IQ/OQ/PQ form: dcs qualify iq comparing live cluster state to a quality-approved baseline, its PASS report persisting as a signed Qualification AuditRecord in the adjacent trail.$ dcs qualify dr --fail-on-deviation
This command:
- Fetches the cluster snapshot via the gateway's qualification
endpoints (CRDs, operator pods, Helm release, ClusterRole), the
same data path as
dcs qualify iq. - Evaluates against the embedded
dr-defaultbaseline (pkg/qualify/baselines/dr-default.yaml): all DCS CRDs present, the four DCS operators and the gateway Ready, Helm release indeployedstatus. The DR baseline is stricter than the IQ default. It pins the full CRD set, beyond the execution-critical subset, and treats every listed CRD as FAIL-if-missing. - Emits a markdown report to stdout and POSTs a signed AuditRecord
with
Target.Kind=Qualification,Target.Name=dr-<runID>. - Exits non-zero if any check fails, suitable for CI-style use or for gating the end-of-drill wall-clock timer.
For formal pharma validation, pass an explicit approved baseline:
$ dcs qualify dr --baseline /path/to/approved-dr-baseline.yaml \
--fail-on-deviation
Retrieve DR drill evidence later via:
$ dcs audit trace <runID>
Beyond the automated baseline¶
The dcs qualify dr check covers the structural cluster invariants
(CRDs, operators, Helm) but does not yet exercise data-plane
continuity or detect stuck procedures automatically. Add the
following manual checks to each drill until they can be
automated:
- [ ] No procedures, operations, phases, or batches are stuck in an
ISA-88 intermediate state (
Stopping,Holding,Starting, etc.). See Stuck Procedure After Restore. - [ ] Historian returns a tag value from the minute immediately
before the outage
(
dcs historian tags --tag <name> --from <T-1m>). - [ ] Audit records for the outage window are present and their
electronic-signature digests verify (
dcs audit verify). - [ ] A canary batch runs start-to-finish on restored equipment.
- [ ] Alarms configured before the outage still fire on the same conditions.
- [ ] OIDC login still works and RBAC bindings are intact (run
dcscommands as each role). - [ ] mTLS between operators and runtimes still verifies
(
dcs health).
Automating the data-plane checks (canary batch, historian continuity, alarm verification, stuck-procedure detection) is tracked as a follow-up to the DR baseline work.
Drill Schedule¶
- Quarterly: tabletop walkthrough of each scenario (reading this document as a team, confirming command sequences still match the deployed code).
- Annually: full restore to an isolated cluster, run
dcs qualify dr, verify the manual checks above, and update the RTO/RPO table. - After every upgrade that changes a CRD schema, audit-archiver behaviour, or the Kubernetes platform version (Talos or k3s): re-run the etcd-loss drill on a disposable cluster built from the new images.
Related Documentation¶
- Backup and Recovery — what gets backed up and how
- High Availability — failure modes DR responds to
- Upgrade and Rollback — rollback uses DR primitives
- 21 CFR Part 11 Traceability — regulatory requirements for tested restore
- Rotation Runbook — credential rotation (cert-expiry scenario depends on this)