Multi-cluster disaster recovery
This page is the operational runbook for recovering a MysqlFailoverGroup
into a separate Kubernetes cluster when the source cluster is lost. It
covers every step from topology to DNS cutover using only the existing
MysqlFailoverGroup CRD surface (available today on main). No new
CRDs or controllers are required to execute this runbook.
WISHLIST #7 introduces MysqlStandbyCluster as a passive observability
layer that surfaces source-archive freshness via BucketReadable /
SourceConfigKnown conditions, summarised by Ready. Phase 2 adds a readiness gate
(Restorable); Phase 3 adds one-command activation. See
What MysqlStandbyCluster adds
at the bottom of this page for details on what ships in that first phase
and what remains in later phases.
Topology overview
Cross-cluster DR in Bloodraven v1 uses the shared object store (S3 or compatible) as the only channel between the source and DR clusters. There is no cross-cluster replication link, no operator-to-operator RPC, and no federation.
Key points:
- The source cluster writes: full dumps (operator-driven backup Jobs)
and sealed binlogs (sidecar archiver on the active primary, gated on
!@@read_only). The upload switches to a new primary within one archiver scan cycle after an intra-cluster failover. - The DR cluster only reads during bootstrap. It does not run a binlog archiver of its own — there is nothing to archive from a cluster that does not yet exist.
DisasterRecoverysite roles (SiteRoleDisasterRecoveryinapi/v1alpha1/types.go, Multi-site topology) are a separate concept: they are passive replicas inside the same cluster as the source MFG and are never auto-promoted. Cross-cluster DR is distinct from this.
Bucket and IAM layout
Bucket structure
Bloodraven writes to the bucket prefix configured in
spec.backup.profiles[].storage.s3.prefix. The layout under that prefix:
<prefix>/
<mysqlbackup-name>/ # one directory per successful full dump
@.json # dump sentinel: GTIDs, size, completion time
<shard-files>.sql.gz # mysqlsh dumpInstance artifacts
<mysqlbackup-name>.bloodraven-backup.json # backup stamp (identity, server clock)
binlogs/ # PITR archive (when spec.backup.pitr.enabled=true)
archive.json # write-once archive descriptor (plaintext)
manifest-<site>.json # per-site sealed-binlog manifest (version 2)
<site>/
<binlog-file>.enc # sealed binlogs (possibly encrypted)
dr-cursors/ # Phase 2: reserved, not written in this release
The DR cluster only needs read access to <prefix>/ to execute this
runbook. Write access to dr-cursors/ is reserved for the
MysqlStandbyCluster verification/readiness feature (Phase 2 of WISHLIST #7).
Minimum IAM policy — AWS
Grant the DR cluster's service account or IAM role the following policy.
Replace shipstream-backups and orders/west with your actual bucket
name and prefix.
DR cluster read-only policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DRReadOnly",
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetObject"],
"Resource": [
"arn:aws:s3:::shipstream-backups",
"arn:aws:s3:::shipstream-backups/orders/west/*"
]
}
]
}
Scope the ListBucket resource to the bucket name (not a prefix — S3
requires bucket-level ListBucket), and the GetObject resource to the
prefix subtree. The DR restore Job uses the credentialsSecretName you
reference in spec.initFromBackup.source.s3; that Secret must contain
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for credentials that the
above policy covers. Backup, restore, and cleanup Jobs fail if either key
is missing or empty; they do not fall back to IRSA, Workload Identity, or
instance-profile credentials.
MysqlStandbyCluster ships (Phase 2), the DR side needs one
additional write permission scoped to dr-cursors/* only. Do not add
it until that phase is in use.GCS / S3-compatible (MinIO, Ceph)
Bloodraven uses the AWS SDK v2 with a configurable endpoint. Set
spec.initFromBackup.source.s3.endpointURL (or
spec.backup.profiles[].storage.s3.endpointURL on the source) to your
endpoint. Minimum equivalent permissions:
| Cloud | Equivalent permissions |
|---|---|
| GCS (interop) | storage.objects.list + storage.objects.get on the prefix |
| MinIO | s3:ListBucket + s3:GetObject — same policy syntax |
| Ceph RGW | Same as MinIO; set endpointURL to your RGW endpoint |
Encryption passphrase distribution
When the source profile uses spec.backup.profiles[].encryption, every
dump artifact and archived binlog is wrapped in the Bloodraven BRV1
format (AES-256-GCM envelope). The DR restore needs the keys they were
written with, and those are not necessarily the bytes in the profile's
Secret today: the source group records what it used in its own key
snapshots, and keeps using them after a refused or in-progress rotation.
Copy the snapshots, never the live profile Secret.
There are up to two keys:
- Dump key. Each backup records the snapshot it was encrypted with in
status.artifactStorage.passphraseSecretKeyRef(amysqlbackup-<name>-dump-key-…Secret). - Archive key (PITR only). The source group's
Boundbinding for the archive's destination names it inencryption.keySecretName(amysql-<group>-pitr-key-…Secret).
They can differ, so copy each one separately. The operator does not distribute keys across clusters. Before triggering a recovery:
Step 1. On the source cluster, read the two snapshot names: the
selected backup's dump key, and the archive key of the destination you
replay (copy the destination exactly as status.pitrArchiveBindings[]
shows it):
DUMP_KEY=$(kubectl -n orders get mysqlbackup <backup> \
-o jsonpath='{.status.artifactStorage.passphraseSecretKeyRef.name}')
ARCHIVE_KEY=$(kubectl -n orders get mfg orders -o json | jq -r --arg d '<destination>' \
'.status.pitrArchiveBindings[] | select(.destination == $d and .state == "Bound") | .encryption.keySecretName')
An empty ARCHIVE_KEY means the destination has no Bound binding; do
not substitute the profile's Secret. Resolve the binding first, or use the
original passphrase from your own key backup.
Step 2. Copy each snapshot's passphrase through a temporary file on
a trusted workstation into the DR namespace:
for pair in "orders-dr-dump-key:$DUMP_KEY" "orders-dr-archive-key:$ARCHIVE_KEY"; do
PASSFILE=$(mktemp)
kubectl --context=<source-context> -n orders get secret "${pair#*:}" \
-o jsonpath='{.data.passphrase}' | base64 -d > "$PASSFILE"
kubectl --context=<dr-context> -n orders create secret generic "${pair%%:*}" \
--from-file=passphrase="$PASSFILE"
shred -u "$PASSFILE"
done
Step 3. Configure each reader with its own key:
- the dump:
spec.initFromBackup.decryption.passphraseSecretKeyRef.name: orders-dr-dump-key; - the archive: the DR group's archive profile (the
pointInTime.archiveProfileNameprofile on the source prefix) withencryption.passphraseSecretKeyRef.name: orders-dr-archive-key.
See Backup encryption for the field semantics and Archive binding and keys for how the restore checks the archive key.
If the archive key is wrong, the DR group's read binding is refused
KeyMismatch before any restore Job is created, and the restore waits in
Pending with a PITRArchiveBindingNotReady message: fix the Secret's
bytes and the binding is checked again. A wrong dump key fails the
restore Job's decrypt-download init container, because the envelope does
not authenticate under it. The profile's live Secret is never a fallback
for either key.
Source fencing checklist
Before you trigger a recovery into the DR cluster, confirm the source is genuinely down. Promoting the DR copy while the source is still accepting writes produces two writable clusters. Bloodraven v1 does not automatically detect or resolve cross-cluster split-brain — divergent GTIDs accumulate on the source side and can only be audited post-hoc (see Durability and RPO).
Use at least two of these three signals before proceeding:
Signal 1 — Source operator /active-site endpoint returns 5xx:
curl -f 'http://<source-operator-aux-svc>:8082/active-site?namespace=orders&group=orders'
# Healthy: {"activeSite":"iad"}
# Down: curl: (22) The requested URL returned error: 503
The operator's auxiliary HTTP server (cmd/bloodraven/main.go:361) serves
/active-site on port 8082 and requires namespace plus group query
parameters. It returns 503 when it cannot reach any writable site.
Signal 2 — Source cluster API server is unreachable:
kubectl --context=<source-context> get nodes --request-timeout=10s
# Error from server: timeout waiting for server response
Signal 3 — Source MySQL endpoint is TCP-unreachable from a third vantage point:
# From a network location that is NOT one of the two clusters
nc -zv <source-mysql-lb-ip> 3306
# Connection refused / timeout
If any signal is ambiguous — for example, the API server is reachable but appears degraded — wait for a clearer signal rather than promoting. The cost of delaying a few minutes is far lower than the cost of a split-brain incident.
initFromBackup on the DR cluster when the source is
still alive produces two writable groups. Use the three signals above to
be certain before proceeding.Recovery procedure
This walkthrough assumes:
- The source
MysqlFailoverGroupis namedorders, in namespaceorders, and had PITR enabled under a profile namednightly-s3stored ats3://shipstream-backups/orders/west. - The DR cluster has network access to the same S3 bucket.
- The passphrase Secret has been mirrored (see above).
- The DR cluster has a fresh namespace
orderswith Bloodraven installed (same operator version as the source; see Install production).
Step 1 — Identify the recovery point
Locate the most recent successful dump in the source bucket:
aws s3 ls s3://shipstream-backups/orders/west/ --recursive \
| grep '@\.json' | sort -k1,2 | tail -5
Each directory under orders/west/ that contains @.json is a
completed dump. The @.json holds the completion timestamp, GTID set,
and dump size. Pick the most recent @.json whose directory you want to
recover from. Note its prefix — for example,
orders/west/orders-nightly-20260520.
To target a specific point in time (e.g., 14:32 UTC before a bad
migration), note the desired stopTime. Binlogs replayed after the
dump will be bounded by the newest sealed binlog archived before the
event; the unarchived tail on the lost primary's PVC is not recoverable
(see PITR and the RPO window).
Step 2 — Apply the DR MysqlFailoverGroup
Create a MysqlFailoverGroup on the DR cluster configured for the DR
environment's nodes, IPs, and DNS. Set spec.initFromBackup to pull from
the source bucket.
Source group configuration (reference — source cluster):
apiVersion: shipstream.io/v1alpha1
kind: MysqlFailoverGroup
metadata:
name: orders
namespace: orders
spec:
sites:
- name: iad
serverID: 101
role: PrimaryCandidate
zone: us-east-1a
taintNodeSelector:
bloodraven.shipstream.io/failover-group.orders: "true"
bloodraven.shipstream.io/site.orders: iad
loadBalancerIP: 10.0.10.11
storage: { storageClassName: gp3, size: 500Gi }
- name: iad-2
serverID: 102
role: PrimaryCandidate
zone: us-east-1b
taintNodeSelector:
bloodraven.shipstream.io/failover-group.orders: "true"
bloodraven.shipstream.io/site.orders: iad-2
loadBalancerIP: 10.0.10.12
storage: { storageClassName: gp3, size: 500Gi }
credentials:
operatorSecretName: mysql-operator-creds
dns:
hostname: orders-east.example.com
ttlSeconds: 60
backup:
profiles:
- name: nightly-s3
storage:
type: S3
s3:
bucket: shipstream-backups
prefix: orders/west
region: us-west-2
credentialsSecretName: s3-backup-creds
encryption:
passphraseSecretKeyRef:
name: orders-backup-passphrase
pitr:
enabled: true
profileName: nightly-s3
DR cluster recovery manifest:
apiVersion: shipstream.io/v1alpha1
kind: MysqlFailoverGroup
metadata:
name: orders
namespace: orders
spec:
# DR-local node topology — different nodes, IPs, and zones from source
sites:
- name: east-1
serverID: 101
role: PrimaryCandidate
zone: us-east-1a
taintNodeSelector:
bloodraven.shipstream.io/failover-group.orders: "true"
bloodraven.shipstream.io/site.orders: east-1
loadBalancerIP: 10.1.20.11 # DR cluster's LB IP
storage: { storageClassName: gp3, size: 500Gi }
- name: east-2
serverID: 102
role: PrimaryCandidate
zone: us-east-1b
taintNodeSelector:
bloodraven.shipstream.io/failover-group.orders: "true"
bloodraven.shipstream.io/site.orders: east-2
loadBalancerIP: 10.1.20.12
storage: { storageClassName: gp3, size: 500Gi }
credentials:
operatorSecretName: mysql-operator-creds # must exist in DR namespace
dns:
hostname: orders-east.example.com
ttlSeconds: 60
backup:
profiles:
- name: west-archive # source archive, read-only
storage:
type: S3
s3:
bucket: shipstream-backups
prefix: orders/west
region: us-west-2
credentialsSecretName: s3-dr-readonly-creds
encryption:
passphraseSecretKeyRef:
name: orders-backup-passphrase
- name: east-nightly # this group's own archive
storage:
type: S3
s3:
bucket: shipstream-backups
prefix: orders/east
region: us-west-2
credentialsSecretName: s3-backup-creds
encryption:
passphraseSecretKeyRef:
name: orders-backup-passphrase
pitr:
enabled: true
profileName: east-nightly
initFromBackup:
source:
s3:
bucket: shipstream-backups
prefix: orders/west/orders-nightly-20260520 # exact dump directory
region: us-west-2
credentialsSecretName: s3-dr-readonly-creds # DR read-only creds
decryption:
passphraseSecretKeyRef:
name: orders-backup-passphrase # mirrored from source
pointInTime: # omit if recovering to dump GTID
stopTime: "2026-05-20T14:32:00Z"
archiveProfileName: west-archive # required: the source's archive
# allowPartial: true # see below; off by default
The restore reads the source archive through west-archive, including every
site manifest under it whatever the source's site names were, while the DR
group archives into its own orders/east prefix. archiveProfileName is
required, and it may not resolve to the DR group's own active PITR
destination: pointing spec.backup.pitr at orders/west too fails the
restore with RestoreBuildFailed / PITRArchiveDestinationShared before any
Job is created. Its sidecars would refuse the source's archive anyway
(PITRArchiveForeign), because the descriptor names the source group.
The example leaves allowPartial off. A direct dump is as well bounded as a
backupRef one: dump-meta reads its GTID set from @.json. With
allowPartial: true, a target later than anything the source managed to
archive (the usual case when the source region failed) restores to the
archive head instead of refusing PITRTargetNotCovered, and reports
outcome: Partial with a Warning PITRReplayTruncated. Enable it only when
"as late as the archive reaches" is an acceptable recovery point, and then
compare newestReplayedCommitTime with your recovery objective before
routing traffic. Without it, a refused target tells you to pick an earlier
one.
Restoring from a replicated archive
If the source bucket is copied to the DR region (S3 replication, rclone,
or a storage-level mirror), point the read-only profile at the copy, for
example bucket: shipstream-backups-dr, and name it in
archiveProfileName. Readers accept the copy: its archive.json still names
the source group and its archive ID, and the restore checks the encryption,
the passphrase's keyCheck, and that every manifest names that archive.
- Copies are read-only. A writer accepts a descriptor only when it names
its own group's UID and an archive ID derived for its own destination. The
DR group refuses the copy as
PITRArchiveForeign. The source group, if it were ever pointed at the copy, refuses it asPITRArchiveCopy. Neither writes into it, so replication cannot fight a second writer. - Replication lag shows as
PITRArchiveDescriptorMissing. Replication does not preserve write order, so a copy can hold version 2 manifests beforearchive.jsonarrives. Restores and the standby reconciler refuse it withPITRArchiveDescriptorMissinguntil it does. Wait for replication, or copyarchive.jsonfrom the source. - Objects can arrive after the manifest that lists them. For the same
reason a copied manifest can list a binlog object that has not been
replicated yet. A restore then sees a missing or unreadable object and can
refuse
PITRArchiveObjectInvalid,PITRArchiveGap(naming an unreadable row), orPITRArchiveNamespaceReuse("cannot be read and verified"). Their documented remedies (quarantine, a new prefix, a full backup) assume permanent damage. On a copy, first check replication is complete: compare the named object with the source archive, or wait for replication to catch up (S3 replication metrics,rclone check) and retry. Only an object missing at the source too is damage. - The destination differs, the archive ID does not. The archive ID is
derived from the source's own destination, so it stays the same in every
copy.
status.discovered.binlogArchive.identity.destinationon aMysqlStandbyClustershows where the copy was read.
A direct S3 dump works for PITR when it carries its backup stamp
(<prefix>.bloodraven-backup.json, copied with the dump): dump-meta reads
the backup GTID set from the dump's own @.json and checks the stamp, and
refuses a dump without one (PITRBackupIdentityUnknown). A backupRef
additionally checks the stamp against the MysqlBackup and its source group.
The outcome is in status.restore.provenance.pointInTime; compare its outcome,
newestReplayedCommitTime and gtidExecuted with your recovery objective.
Apply to the DR cluster:
kubectl --context=<dr-context> apply -f dr-orders.yaml
Step 3 — Monitor the restore
kubectl --context=<dr-context> -n orders get mysqlfailovergroup orders -w
Watch status.restore.phase. The sequence:
| Phase | Meaning |
|---|---|
Pending | Restore Job not yet created |
Running | util.loadDump() is running (and optionally mysqlbinlog replay after) |
Succeeded | Dump and any PITR replay completed; bootstrap continues |
Failed | Inspect status.restore.message and Job logs |
Full status:
kubectl --context=<dr-context> -n orders describe mysqlfailovergroup orders
The initFromBackup path is one-shot and runs before the failover group
is considered ready (api/v1alpha1/backup_types.go:552-557). Once
status.restore.phase == Succeeded, bootstrap (clone, replication,
fencing) completes normally.
Step 4 — Verify the DR group is healthy
kubectl --context=<dr-context> -n orders get mysqlfailovergroup orders -o yaml \
| grep -A5 'conditions:'
Wait for Ready=True. Check that at least one site is Writable and
replication is up on the peer:
kubectl --context=<dr-context> -n orders get mysqlfailovergroup orders \
-o jsonpath='{.status.activeSite}'
Step 5 — DNS cutover
Bloodraven writes a DNSEndpoint CR per site using
external-dns/endpoint-controller.io/v1 (api/v1alpha1/types.go:371-384,
internal/platform/dns.go:23-31). The operator controls the
per-cluster DNS record for spec.dns.hostname in whatever cluster it
runs in. It does not control the global application-facing record.
Choose one of these cutover patterns:
Option A — Weighted CNAME with health checks (recommended for production)
Maintain a weighted CNAME (Route53, Google Cloud DNS, Akamai GTM) or a GSLB record that points at both clusters. On source loss:
- Set source weight to 0 (or let health checks drop it automatically if the source LB IP is unreachable).
- Set DR weight to 100.
- TTL propagation time depends on your DNS TTL. Pre-reducing TTL to ≤ 60 s before an expected maintenance window shortens this.
Option B — Manual flip (simplest)
# Point the application CNAME at the DR cluster's DNS name
aws route53 change-resource-record-sets --hosted-zone-id Z123 --change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "db.example.com",
"Type": "CNAME",
"TTL": 60,
"ResourceRecords": [{"Value": "orders-east.example.com"}]
}
}]
}'
Option C — GSLB / Akamai GTM
Mark the source origin as offline in the GTM policy. The GSLB health monitor will have already removed it from rotation if the TCP probe to MySQL is failing.
After DNS propagation, confirm applications are connecting to the DR
cluster (check status.activeSite and replication lag on the DR group).
Failback narrative
When the source cluster returns, the original path is reversible. This is a manual, prose-only procedure in v1; Phase 4 of WISHLIST #7 will document and partially automate it.
The high-level steps:
- Confirm the DR cluster is steady-state primary. New writes are landing on the DR cluster; intra-cluster replication (if multi-site) is healthy.
- Wipe the source cluster's MySQL state. Delete the old
MysqlFailoverGroupCR. The operator cascades to Deployments, Services, and DNSEndpoints; PVCs are not cascaded — delete them explicitly:kubectl --context=<source-context> -n orders delete mysqlfailovergroup orders kubectl --context=<source-context> -n orders delete pvc \ -l bloodraven.shipstream.io/failover-group=orders - Ensure the DR group's backup prefix does not collide with the
original source prefix. A directional prefix convention avoids
ambiguity — for example, use
orders/east/for the DR group's dumps andorders/west/for the original source. Update the DR group'sspec.backup.profiles[].storage.s3.prefixaccordingly before the DR group takes its first backup. - Stand up a fresh
MysqlFailoverGroupon the source cluster withspec.initFromBackuppointing at the DR cluster's new prefix (the backup that the now-primary DR cluster took). This is the same pattern as the original recovery, but in reverse. Wait forstatus.restore.phase=Succeeded. - Cut DNS back if desired. This step is optional and human-driven. Bloodraven v1 does not auto-rebalance traffic across clusters.
Phase 4 (WISHLIST #7) introduces MysqlStandbyCluster symmetric failback:
a second standby CR on the returning source cluster pointing at the DR
cluster's new prefix, with the same continuous verification and
one-command activation path that Phase 3 provides for the forward
direction.
What MysqlStandbyCluster (Phase 1) adds today
WISHLIST #7 Phase 1 introduces MysqlStandbyCluster — a new CRD that
lives on the DR cluster and continuously monitors the source bucket.
What Phase 1 ships:
- A new
MysqlStandbyClusterCR (shipstream.io/v1alpha1, short namemsc) that names the relationship between a source MFG's backup archive and this DR cluster. - A passive reconciler (
MysqlStandbyClusterReconciler) that runs a discovery loop on a configurable cadence (default 5 minutes):- Lists objects under
spec.source.s3.prefixto find the most recent successful full dump and per-site binlog manifests. - Populates
status.discovered.latestDump(name,location,completionTime,gtidExecuted,sizeBytes),status.discovered.binlogArchiveandstatus.discovered.lastScanTime. - Stamps
BucketReadable=True/Falsebased on whether the bucket scan succeeded. - Stamps
SourceConfigKnown=True/Falsebased on whether at least one dump and one binlog manifest were found. - Stamps
Ready=True(reasonSourceDiscovered) when both areTrue; otherwiseReady=Falsewith the reason and message of the first of the two that is not (BucketReadablefirst). - Reads the archive descriptor and every version 2 manifest, and
publishes the identity and the window together as
status.discovered.binlogArchive:identity(archiveID,destination,source{namespace, name, uid},encryptionMode,algorithm),manifestCount,fileCount,sizeBytes,oldestEventTimeandnewestEventTime. A refused archive setsSourceConfigKnown=Falsewith the token minusPITRas the reason (ArchiveLegacy,ArchiveDescriptorMissing,ArchiveIdentityMismatch,ArchiveEncryptionMismatch,ArchiveKeyMismatch,ArchiveVersionUnsupported,ArchiveUnreadable), emits a WarningPITRArchiveRefusedwhen the reason changes, and still publisheslatestDump, with nobinlogArchive. Withsource.decryptionset the standby expects aPassphrasearchive and checks the passphrase against the descriptor'skeyCheck; without it, aNonearchive. The passphrase file is trimmed of leading and trailing\n,\r, space and tab only.
- Lists objects under
- No mysqld is started, no dump is loaded, and no
MysqlFailoverGroupis materialized. Phase 1 is purely observational.
What Phase 1 does NOT ship:
- Continuous restore verification (
Restorablecondition) — Phase 2. - One-command activation (
dr-activatekubectl plugin + a future activate-request state machine) — Phase 3. - Failback tooling — Phase 4.
The spec fields and status that drive Phase 2 verification and Phase 3
activation are not part of the API. They will be added
(backward-compatibly) when that code ships, rather than shipping inert fields
that enforce nothing today. That includes the shape of the group an
activation would create: keep the MysqlFailoverGroup manifest you would
apply in the DR cluster (with spec.initFromBackup pointed at this prefix) in
Git next to the standby.
An on-call engineer recovering from a cluster loss still executes this
runbook manually. Phase 1's value is operational visibility: you can see
at a glance whether the source bucket is readable and whether a dump +
binlog window exist, without having to run aws s3 ls yourself.
Sample MysqlStandbyCluster CR:
apiVersion: shipstream.io/v1alpha1
kind: MysqlStandbyCluster
metadata:
name: orders-east-from-west
namespace: orders
spec:
transport: ObjectStore # the only transport, and the default
source:
# The S3 destination of the source group's backup profile.
s3:
bucket: shipstream-backups
prefix: orders/west # required
region: us-west-2
credentialsSecretName: s3-dr-readonly-creds
decryption:
passphraseSecretKeyRef:
name: orders-backup-passphrase
freshness:
discoveryInterval: 5m # default 5m, minimum 30s
The standby identifies its source by the prefix alone; the archive
descriptor it verifies there names the source group (namespace, name and
UID). Use metadata labels if you want a display name for the source cluster.
See examples/standby-cluster.yaml for the annotated sample.
Check status:
kubectl -n orders get mysqlstandbycluster orders-east-from-west -o yaml
Look for status.discovered.latestDump, status.discovered.binlogArchive
(the verified archive identity and its window), and the Ready,
BucketReadable and SourceConfigKnown conditions. kubectl get msc shows
the latest dump's name, the newest archived event time and Ready.
Planned follow-up phases (tracking issue: WISHLIST #7):
- Phase 2 — Continuous DR readiness: the reconciler schedules
periodic
MysqlBackupVerificationruns against the discovered dump + binlog window, publishing aRestorablecondition and abloodraven_dr_restorable_timestamp_secondsgauge. The source operator gains awareness ofdr-cursors/sentinel objects to prevent premature binlog pruning. Prerequisite: WISHLIST #43 (PITR E2E scenarios). - Phase 3 — One-command activation:
kubectl bloodraven dr-activate- a future confirm-token gate materializes a writable
MysqlFailoverGroupfrom the discovered dump. The activation state machine is phase-stamped and durable across operator restarts. The spec field that carries the confirm token will be added when this lands.
- a future confirm-token gate materializes a writable
- Phase 4 — Failback tooling: symmetric
MysqlStandbyClusteron the returning source cluster + extended runbook.
Until Phase 3 ships, activation always requires the manual
spec.initFromBackup steps described in this runbook.

