fix(backup): quiesce swarm writers for restore

Defer Swarm SQL replay to the disposable DR drill so shared writers such as Discourse are stopped before the destructive restore. Verify the selected generation with before/after database probes while preserving the Compose restore path.
This commit is contained in:
kevinveenbirkenbach committed 2026-07-20 05:45:26 +02:00
1 parent d6f9805746
commit 985ec7eab9
12 files changed
+725 -72

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BKP_TEST_DATABASES_CSV:?}"
MODE="${1:?usage: db_probe.sh seed TOKEN | verify PRE_TOKEN POST_TOKEN MANIFEST}"
PROBE_TABLE="infinito_backup_restore_probe"
validate_token() {
if [[ ! "${1}" =~ ^[A-Za-z0-9_-]+$ ]]; then
echo "FAIL: unsafe database probe token"
exit 1
fi
}
engine_for_container() {
local image
image="$(container inspect -f '{{.Config.Image}}' "${1}")"
case "${image}" in
*postgres* | *postgis*) printf 'postgres\n' ;;
*mariadb* | *mysql*) printf 'mariadb\n' ;;
*) return 1 ;;
esac
}
container_for_instance() {
local candidate engine instance="${1}"
mapfile -t candidates < <(container ps --filter "volume=${instance}_data" --format '{{.Names}}')
if ((${#candidates[@]} < 1)); then
mapfile -t candidates < <(container ps --filter "name=${instance}" --format '{{.Names}}')
fi
for candidate in "${candidates[@]}"; do
if engine="$(engine_for_container "${candidate}")"; then
printf '%s;%s\n' "${candidate}" "${engine}"
return 0
fi
done
return 1
}
container_for_volume() {
local candidate engine volume="${1}" expected_engine="${2}"
while IFS= read -r candidate; do
[[ -n "${candidate}" ]] || continue
if engine="$(engine_for_container "${candidate}")" && [[ "${engine}" == "${expected_engine}" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
done < <(container ps --filter "volume=${volume}" --format '{{.Names}}')
return 1
}
run_seed() {
local container database engine instance password resolved token="${1}" user
local seeded=0
validate_token "${token}"
if [[ ! -f "${BKP_TEST_DATABASES_CSV}" ]]; then
echo "FAIL: databases csv missing at ${BKP_TEST_DATABASES_CSV}"
exit 1
fi
while IFS=';' read -r instance database user password; do
[[ -n "${instance}" ]] || continue
[[ "${database}" != "*" ]] || continue
if ! resolved="$(container_for_instance "${instance}")"; then
echo "SKIP: no running database container found for instance '${instance}'"
continue
fi
IFS=';' read -r container engine <<<"${resolved}"
case "${engine}" in
postgres)
container exec -e "PGPASSWORD=${password}" "${container}" \
psql -X -v ON_ERROR_STOP=1 -U "${user}" -d "${database}" -c \
"CREATE TABLE IF NOT EXISTS ${PROBE_TABLE} (token text PRIMARY KEY); INSERT INTO ${PROBE_TABLE} (token) VALUES ('${token}') ON CONFLICT (token) DO NOTHING;" >/dev/null
;;
mariadb)
container exec -e "MYSQL_PWD=${password}" "${container}" \
mariadb --batch --skip-column-names -u "${user}" "${database}" -e \
"CREATE TABLE IF NOT EXISTS ${PROBE_TABLE} (token varchar(128) PRIMARY KEY); INSERT IGNORE INTO ${PROBE_TABLE} (token) VALUES ('${token}');" >/dev/null
;;
esac
echo "OK: seeded database restore probe in ${database} (${engine})"
seeded=$((seeded + 1))
done < <(tail -n +2 "${BKP_TEST_DATABASES_CSV}")
echo "OK: seeded ${seeded} database restore probe(s)"
}
probe_count() {
local container="${1}" database="${2}" engine="${3}" password="${4}" token="${5}" user="${6}"
case "${engine}" in
postgres)
container exec -e "PGPASSWORD=${password}" "${container}" \
psql -X -Atq -v ON_ERROR_STOP=1 -U "${user}" -d "${database}" -c \
"SELECT COUNT(*) FROM ${PROBE_TABLE} WHERE token = '${token}';" | tr -d '[:space:]'
;;
mariadb)
container exec -e "MYSQL_PWD=${password}" "${container}" \
mariadb --batch --skip-column-names -u "${user}" "${database}" -e \
"SELECT COUNT(*) FROM ${PROBE_TABLE} WHERE token = '${token}';" | tr -d '[:space:]'
;;
*)
echo "FAIL: unsupported database engine '${engine}'"
exit 1
;;
esac
}
run_verify() {
local container database engine manifest="${3}" password post_count pre_count
local pre_token="${1}" post_token="${2}" row user verified=0 volume
validate_token "${pre_token}"
validate_token "${post_token}"
if [[ ! -s "${manifest}" ]]; then
echo "FAIL: restored database manifest missing or empty at ${manifest}"
exit 1
fi
while IFS=';' read -r engine volume database; do
[[ -n "${engine}" && -n "${volume}" && -n "${database}" ]] || continue
row="$(awk -F';' -v db="${database}" 'NR > 1 && $2 == db { print; exit }' "${BKP_TEST_DATABASES_CSV}")"
if [[ -z "${row}" ]]; then
echo "FAIL: no databases.csv row for restored database '${database}'"
exit 1
fi
IFS=';' read -r _instance _database user password <<<"${row}"
if ! container="$(container_for_volume "${volume}" "${engine}")"; then
echo "FAIL: no running ${engine} container mounts restored volume ${volume}"
exit 1
fi
pre_count="$(probe_count "${container}" "${database}" "${engine}" "${password}" "${pre_token}" "${user}")"
post_count="$(probe_count "${container}" "${database}" "${engine}" "${password}" "${post_token}" "${user}")"
if [[ "${pre_count}" != "1" ]] || [[ "${post_count}" != "0" ]]; then
echo "FAIL: ${database} restore probe mismatch (pre=${pre_count}, post=${post_count})"
exit 1
fi
echo "OK: ${database} contains the pre-backup probe and excludes the post-backup probe"
verified=$((verified + 1))
done <"${manifest}"
echo "OK: verified ${verified} destructively restored database(s)"
}
case "${MODE}" in
seed)
run_seed "${2:?seed token required}"
;;
verify)
run_verify "${2:?pre token required}" "${3:?post token required}" "${4:?manifest required}"
;;
*)
echo "FAIL: unsupported db_probe mode '${MODE}'"
exit 1
;;
esac
@@ -16,6 +16,11 @@ set -euo pipefail
: "${NEWEST_GENERATION:?}"
GEN_DIR="${REPO_DIR}/${NEWEST_GENERATION}"
RESTORED_DATABASES_FILE="${BKP_TEST_RESTORED_DATABASES_FILE:-}"
if [[ -n "${RESTORED_DATABASES_FILE}" ]]; then
: >"${RESTORED_DATABASES_FILE}"
fi
if [[ ! -f "${BKP_TEST_DATABASES_CSV}" ]]; then
echo "FAIL: databases csv missing at ${BKP_TEST_DATABASES_CSV}"
@@ -71,6 +76,9 @@ for sql_file in "${SQL_FILES[@]}"; do
--db-password "${db_password}" \
--empty
echo "OK: replayed ${engine} dump '${db_name}' into ${container}"
if [[ -n "${RESTORED_DATABASES_FILE}" ]]; then
printf '%s;%s;%s\n' "${engine}" "${volume}" "${db_name}" >>"${RESTORED_DATABASES_FILE}"
fi
RESTORED=$((RESTORED + 1))
done
+47 -12
View File
@@ -1,14 +1,4 @@
#!/usr/bin/env bash
# E2E orchestrator for svc-bkp-volume-2-local.
# Waits for the backup service to terminate, then:
# sync pass (ASYNC_ENABLED=false): verify the first backup generation is
# stored, then run the destructive restore drill (stop containers, wipe
# the backed-up volumes, restore, require everything healthy again) and
# replay the sql dumps.
# async pass (ASYNC_ENABLED=true): require two differential backup
# generations, triggering one extra service run when only one exists;
# no destructive steps.
# Variables sourced from test.env.j2 by test-e2e-cli.
set -euo pipefail
: "${ASYNC_ENABLED:?}"
@@ -17,6 +7,7 @@ set -euo pipefail
: "${BKP_TEST_SERVICE:?}"
: "${BKP_TEST_RESTORE_BIN:?}"
: "${BKP_TEST_HEALTH_TIMEOUT:?}"
: "${BKP_TEST_SWARM_DRILL:?}"
if [[ "${BKP_TEST_IS_STACK_HOST}" != "true" ]]; then
echo "SKIP: not the stack host; svc-bkp-volume-2-local only deploys there"
@@ -57,13 +48,35 @@ count_generations() {
wait_service_terminated
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SWARM_ACTIVE=false
if [[ "$(container info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null)" == "active" ]]; then
SWARM_ACTIVE=true
fi
MACHINE_HASH="$(sha256sum /etc/machine-id | cut -c1-64)"
HANDOFF_PENDING="${DIR}/swarm-restore.pending"
HANDOFF_COMPLETE="${DIR}/swarm-restore.complete"
HANDOFF_MANIFEST="${DIR}/swarm-restore.manifest"
if [[ "${ASYNC_ENABLED}" != "true" ]] && [[ "${SWARM_ACTIVE}" == "true" ]]; then
if [[ "${BKP_TEST_SWARM_DRILL}" != "true" ]]; then
echo "FAIL: refusing to replay database dumps on an active Swarm outside the orchestrated CI DR drill"
exit 1
fi
rm -f "${HANDOFF_PENDING}" "${HANDOFF_COMPLETE}" "${HANDOFF_MANIFEST}"
PROBE_BASE="infinito_${MACHINE_HASH:0:12}_$(date +%s)_${BASHPID}"
PROBE_PRE_TOKEN="${PROBE_BASE}_pre"
PROBE_POST_TOKEN="${PROBE_BASE}_post"
bash "${DIR}/db_probe.sh" seed "${PROBE_PRE_TOKEN}"
fi
echo "Forcing a post-deploy backup run (the service-loader pre-state backup can predate the app volumes and be empty)"
if ! timeout "${BKP_TEST_HEALTH_TIMEOUT}" systemctl start "${BKP_TEST_SERVICE}"; then
echo "backup unit start returned non-zero; inspecting result"
fi
wait_service_terminated
MACHINE_HASH="$(sha256sum /etc/machine-id | cut -c1-64)"
MACHINE_DIR="${BKP_TEST_BACKUPS_DIR%/}/${MACHINE_HASH}"
if [[ ! -d "${MACHINE_DIR}" ]]; then
@@ -86,7 +99,6 @@ if (( COUNT < 1 )); then
exit 1
fi
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export MACHINE_HASH REPO_DIR REPO_NAME
if [[ "${ASYNC_ENABLED}" == "true" ]]; then
@@ -111,6 +123,29 @@ fi
export NEWEST_GENERATION="${GENERATIONS[-1]}"
bash "${DIR}/verify_backup.sh"
if [[ "${SWARM_ACTIVE}" == "true" ]]; then
mapfile -t SQL_FILES < <(find "${REPO_DIR}/${NEWEST_GENERATION}" -mindepth 3 -maxdepth 3 \
-type f -path '*/sql/*.backup.sql' ! -name '*.cluster.backup.sql' | sort)
if (( ${#SQL_FILES[@]} < 1 )); then
echo "SWARM SYNC PASS COMPLETE: backup verified; no single-database dumps require a restore"
exit 0
fi
bash "${DIR}/db_probe.sh" seed "${PROBE_POST_TOKEN}"
umask 077
{
printf 'MACHINE_HASH=%q\n' "${MACHINE_HASH}"
printf 'REPO_NAME=%q\n' "${REPO_NAME}"
printf 'NEWEST_GENERATION=%q\n' "${NEWEST_GENERATION}"
printf 'PROBE_PRE_TOKEN=%q\n' "${PROBE_PRE_TOKEN}"
printf 'PROBE_POST_TOKEN=%q\n' "${PROBE_POST_TOKEN}"
} >"${HANDOFF_PENDING}.tmp"
mv "${HANDOFF_PENDING}.tmp" "${HANDOFF_PENDING}"
echo "SWARM SYNC PASS COMPLETE: destructive database restore handed to the orchestrated CI DR drill"
exit 0
fi
bash "${DIR}/restore_cycle.sh"
bash "${DIR}/db_restore.sh"
echo "SYNC PASS COMPLETE: backup verified and restore cycle succeeded"
@@ -5,3 +5,4 @@ BKP_TEST_HEALTH_TIMEOUT={{ lookup('timeout', 1200) | int }}
BKP_TEST_IS_STACK_HOST={{ IS_STACK_HOST | bool | lower }}
BKP_TEST_RESTORE_BIN=baudolo-restore
BKP_TEST_SERVICE={{ SYS_SERVICE_BACKUP_VOLUME_2_LOCAL }}
BKP_TEST_SWARM_DRILL={{ TEST_E2E_SWARM_DRILL | default(false) | bool | lower }}
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
MODE="${1:?usage: 04_quiesce_database_writers.sh manager|worker}"
is_infrastructure_image() {
local image="${1,,}" repository
repository="${image##*/}"
repository="${repository%%:*}"
repository="${repository%%@*}"
case "${repository}" in
postgres | postgres_* | postgis | postgis_* | mariadb | mariadb_* | mysql | mysql_* | registry) return 0 ;;
*) return 1 ;;
esac
}
if [[ "${MODE}" == "manager" ]]; then
mapfile -t STACKS < <(docker stack ls --format '{{.Name}}' | sort)
REMOVED=()
for stack in "${STACKS[@]}"; do
mapfile -t IMAGES < <(docker stack services "${stack}" --format '{{.Image}}')
keep=true
if ((${#IMAGES[@]} < 1)); then
keep=false
fi
for image in "${IMAGES[@]}"; do
is_infrastructure_image "${image}" || keep=false
done
if [[ "${keep}" == "true" ]]; then
echo "KEEP: infrastructure stack ${stack} (${IMAGES[*]})"
continue
fi
docker stack rm "${stack}"
REMOVED+=("${stack}")
done
deadline=$(($(date +%s) + 180))
for stack in "${REMOVED[@]}"; do
while [[ -n "$(docker service ls -q --filter "label=com.docker.stack.namespace=${stack}")" ]]; do
if (($(date +%s) >= deadline)); then
echo "FAIL: stack ${stack} still has services after quiesce timeout"
exit 1
fi
sleep 2
done
echo "OK: removed writer stack ${stack}"
done
elif [[ "${MODE}" != "worker" ]]; then
echo "FAIL: unsupported quiesce mode '${MODE}'"
exit 1
fi
STOP_CONTAINERS=()
while IFS=$'\t' read -r container_id image; do
[[ -n "${container_id}" ]] || continue
if ! is_infrastructure_image "${image}"; then
STOP_CONTAINERS+=("${container_id}")
fi
done < <(docker ps --format '{{.ID}}\t{{.Image}}')
if ((${#STOP_CONTAINERS[@]} > 0)); then
docker stop "${STOP_CONTAINERS[@]}"
fi
echo "OK: ${MODE} runs only database engines and the local registry"
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
TEST_DIR="${1:?role test directory required}"
BACKUPS_ROOT="${2:?backup root required}"
PENDING="${TEST_DIR}/swarm-restore.pending"
COMPLETE="${TEST_DIR}/swarm-restore.complete"
MANIFEST="${TEST_DIR}/swarm-restore.manifest"
if [[ ! -f "${PENDING}" ]]; then
echo "FAIL: database restore handoff missing at ${PENDING}"
exit 1
fi
# shellcheck disable=SC1091
. "${TEST_DIR}/test.env"
# shellcheck disable=SC1090
. "${PENDING}"
if [[ "${BKP_TEST_SWARM_DRILL}" != "true" ]]; then
echo "FAIL: database restore handoff was not created by the Swarm CI drill"
exit 1
fi
BKP_TEST_BACKUPS_DIR="${BACKUPS_ROOT%/}"
REPO_DIR="${BKP_TEST_BACKUPS_DIR}/${MACHINE_HASH}/${REPO_NAME}"
BKP_TEST_RESTORED_DATABASES_FILE="${MANIFEST}"
export BKP_TEST_BACKUPS_DIR REPO_DIR BKP_TEST_RESTORED_DATABASES_FILE
export MACHINE_HASH REPO_NAME NEWEST_GENERATION
if [[ ! -d "${REPO_DIR}/${NEWEST_GENERATION}" ]]; then
echo "FAIL: handed-off database backup generation missing at ${REPO_DIR}/${NEWEST_GENERATION}"
exit 1
fi
bash "${TEST_DIR}/db_restore.sh"
if [[ ! -s "${MANIFEST}" ]]; then
echo "FAIL: database restore completed without a restored-database manifest"
exit 1
fi
mv "${PENDING}" "${COMPLETE}"
echo "OK: consumed Swarm database restore handoff"
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
TEST_DIR="${1:?role test directory required}"
COMPLETE="${TEST_DIR}/swarm-restore.complete"
MANIFEST="${TEST_DIR}/swarm-restore.manifest"
if [[ ! -f "${COMPLETE}" ]]; then
echo "FAIL: completed database restore handoff missing at ${COMPLETE}"
exit 1
fi
# shellcheck disable=SC1091
. "${TEST_DIR}/test.env"
# shellcheck disable=SC1090
. "${COMPLETE}"
if [[ "${BKP_TEST_SWARM_DRILL}" != "true" ]]; then
echo "FAIL: completed database restore was not produced by the Swarm CI drill"
exit 1
fi
bash "${TEST_DIR}/db_probe.sh" verify \
"${PROBE_PRE_TOKEN}" "${PROBE_POST_TOKEN}" "${MANIFEST}"
@@ -6,6 +6,28 @@ the live instance; the drill passes only when every marker survives the
whole loop. `base.sh` runs the nine steps below; the numbers in both
diagrams are its `[n/9]` log markers.
## Schema
The CI drill includes destructive database recovery. The disposable test
cluster keeps only the database engines and local registry available while
every database-writing workload, including shared dependencies such as
Discourse, is stopped. The normal matrix update remains the single source of
truth for bringing the workloads back.
```mermaid
flowchart LR
seed["Seed pre-backup database probes"]
backup["Run deployed backup units"]
mutate["Write post-backup probes"]
quiesce["Remove all database writers<br/>target stack + shared dependencies"]
files["Recover NFS, volumes and secrets"]
database["Restore SQL dumps into<br/>the real CI databases"]
redeploy["Matrix update redeploys<br/>the desired state"]
verify["Health + Playwright<br/>pre-backup probes present<br/>post-backup probes absent"]
seed --> backup --> mutate --> quiesce --> files --> database --> redeploy --> verify
```
## Step sequence (who does what)
```mermaid
@@ -13,10 +35,13 @@ sequenceDiagram
autonumber
participant N as nfs-server
participant M as manager
participant D as database engines
participant B as backup host
participant U as LUKS "USB" (loop img on B)
Note over N,M: [1/9] seed markers
Note over M,D: role test seeds pre-backup DB probes, backs up, then seeds post-backup probes
Note over N,M: [1/9] seed file markers
N->>N: marker → live NFS volume dir
M->>M: marker → host secrets dir
@@ -34,11 +59,11 @@ sequenceDiagram
Note over B,U: [5/9] plug LUKS device, sync via local-2-device unit
B->>U: hard-linked snapshot
Note over M: [6/9] full disaster teardown
alt stack workload
M->>M: docker stack rm + wait
else node-local workload
M->>M: stop compose project on every node
Note over M,D: [6/9] quiesce writers
alt database handoff exists
M->>M: remove all non-DB/non-registry stacks and containers
else file-only drill
M->>M: remove the target stack or node-local compose project
end
Note over B,U: [7/9] recover device → local root
@@ -53,7 +78,11 @@ sequenceDiagram
B-->>M: volume generation → recover CLI
B-->>M: secrets generation → recover CLI
Note over M,N: matrix update pass redeploys, then verify_recovered_marker.sh asserts the live marker
Note over M,D: [9b/9] restore selected SQL generation
B-->>M: device-recovered volume backup repository
M->>D: replay dumps while all writers remain stopped
Note over M,N: matrix update redeploys; health, Playwright, file markers and DB before/after probes must pass
```
## Data flow (what is proven)
@@ -64,33 +93,39 @@ flowchart TB
direction TB
vol["📂 [1] Live NFS volume + docker volume<br/>marker seeded"]:::live
sec["🔑 [1] Host secrets on the manager<br/>(secrets, CA, ACME, node identity) marker seeded"]:::live
dbpre["🗃️ Pre-backup DB probe<br/>included in the selected SQL generation"]:::live
localbkp["🗄️ [2-3] Local backups on manager + NFS server<br/>volume / nfs / secrets generation snapshots"]:::store
pulled["🗄️ [4] Backup host<br/>pulled generations (remote-2-local unit, rsync over ssh, pull-only)"]:::store
usb["🔒 [5] Encrypted USB (LUKS)<br/>hard-linked snapshot (local-2-device unit)"]:::device
vol -->|backup units| localbkp
sec -->|secrets unit| localbkp
dbpre -->|SQL dump| localbkp
localbkp -->|remote pull| pulled -->|device sync| usb
end
teardown["🛑 [6] Full disaster:<br/>stack rm (stack workload) or<br/>compose stop on every node (node-local workload),<br/>then export wiped"]:::dead
dbpost["✍️ Post-backup DB probe<br/>must disappear after restore"]:::dead
teardown["🛑 [6] Full disaster:<br/>all DB writers removed, including shared dependencies;<br/>only DB engines + registry remain,<br/>then export wiped"]:::dead
subgraph recovery["♻️ Recovery (dashed: the recover CLI, newest generation only)"]
subgraph recovery["♻️ Recovery (dashed: newest file snapshots + handed-off SQL generation)"]
direction TB
restored["📁 [7] Restore root on the backup host<br/>newest snapshot after luksOpen (pulled tree + loop img freed)"]:::store
export["📂 [8] Live NFS export<br/>volume subtree rebuilt, ganesha + client mounts refreshed"]:::live
volume["📦 [9] Docker volume<br/>rebuilt on the manager"]:::live
secrets["🔑 [9] Host secrets<br/>rebuilt on the manager (marker cleared first)"]:::live
verified["✅ Update pass redeploys<br/>markers verified on the live instance"]:::ok
database["🗃️ [9b] Live databases<br/>selected SQL generation replayed without writers"]:::live
verified["✅ Update pass redeploys<br/>health + Playwright pass;<br/>file markers and DB before/after probes verified"]:::ok
usb -.->|decrypt + recover| restored
restored -.->|restore export| export
restored -.->|restore volume| volume
restored -.->|restore secrets| secrets
restored -.->|restore SQL dumps| database
export -.-> verified
volume -.-> verified
secrets -.-> verified
database -.-> verified
end
forward --> teardown --> recovery
forward --> dbpost --> teardown --> recovery
classDef live fill:#f1f8f6,stroke:#d5e7e2,color:#455a64;
classDef store fill:#f3f7fb,stroke:#d6e2ee,color:#455a64;
@@ -103,13 +138,16 @@ flowchart TB
## Applicability
- The drill runs whenever the app declares an NFS-flagged volume
(`PRIMARY_NFS_VOLUME`); apps without one skip it, since there is nothing
to prove a restore against.
- The workload form only changes step 6: stack workloads get
`docker stack rm`, `workload: node-local` roles get their compose
project stopped on every node. Every other step operates on volumes,
the NFS export and the deployed backup units, independent of the
orchestrator.
- Runs once per app, in the variant-0 CI job, between the matrix's first
deploy and its update pass.
- The first matrix round runs the complete file chain whenever the app
declares an NFS-flagged volume (`PRIMARY_NFS_VOLUME`). A database handoff
is destructive even without NFS: all writers are quiesced and the selected
manager-local generation is restored.
- Later variant rounds skip the expensive file chain. If their role test
hands off SQL dumps, they still run the destructive database restore and
post-update probe verification; no Swarm restore is replaced by a guard.
- In a file-only drill, the workload form changes step 6: stack workloads
get `docker stack rm`, while `workload: node-local` roles have their
Compose project stopped on every node. A database handoff instead removes
every non-database workload in either form.
- Runs between the sync and update pass. The full NFS/device chain runs once
per app; database recovery runs in every round that produced a handoff.
@@ -1,21 +1,4 @@
#!/usr/bin/env bash
# Disaster-recovery drill for the swarm test cluster: proves the full
# backup chain volume + secrets + nfs -> remote -> device through the
# DEPLOYED systemd units on every host (the backup host runs the real
# svc-bkp-remote-2-local and svc-bkp-local-2-device roles; the drill only
# installs the ssh pull identity and simulates the USB plug via a LUKS
# loop mount), then tears the stack down completely and recovers the same
# data back through the recover CLI (cli.administration.recover: device ->
# local root -> nfs export, docker volume and host secrets). The matrix update
# pass then boots the stack onto the recovered export and
# verify_recovered_marker.sh asserts the live marker, so no dedicated redeploy
# runs here. Runs once, between the matrix's first and second round, against
# the already-converged round-1 stack. Per-host
# routines live next to this file and execute in-node from the repo copy
# under INFINITO_NODE_SRC_DIR (one docker exec per routine). Marker probes
# are scoped per repo (volume/nfs/secrets share DR_MARKER; an unscoped
# ${MID} glob would cross-select). Device paths (mount/target) come from
# the same extras SPOT that configures the deployed role (write_extras).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -45,14 +28,64 @@ USB_IMG="/var/lib/infinito-usb.img"
USB_MAPPER="usbdrill"
USB_PASS="drillpass"
RESTORE_ROOT="/var/tmp/dr-device-restored"
ROLE_TEST_DIR="/tmp/test-e2e-cli/svc-bkp-volume-2-local"
DB_PENDING_FILE="${ROLE_TEST_DIR}/swarm-restore.pending"
DB_RESTORE_ROOT="/var/tmp/dr-database-restore-${APP_ID}"
DRILL_FULL_CHAIN="${DRILL_FULL_CHAIN:-true}"
DEV_MOUNT="$(python3 -c "import sys, yaml; print(yaml.safe_load(open(sys.argv[1]))['applications']['svc-bkp-local-2-device']['services']['local-2-device']['mount'])" "${DRILL_EXTRAS}")"
DEV_TARGET="$(python3 -c "import sys, yaml; print(yaml.safe_load(open(sys.argv[1]))['applications']['svc-bkp-local-2-device']['services']['local-2-device']['target'])" "${DRILL_EXTRAS}")"
DEV_DEST="${DEV_MOUNT}${DEV_TARGET}"
if [ -z "${PRIMARY_NFS_VOLUME}" ]; then
echo "SKIP drill: ${APP_ID} declares no NFS-flagged volume — nothing to prove a restore against"
case "${DRILL_FULL_CHAIN}" in
true | false) ;;
*)
echo "FAILURE: DRILL_FULL_CHAIN must be true or false"
exit 1
;;
esac
DB_PENDING=false
if docker exec "${MGR}" test -f "${DB_PENDING_FILE}"; then
DB_PENDING=true
fi
MGR_MID="$(docker exec "${MGR}" sha256sum /etc/machine-id | cut -c1-64)"
quiesce_database_writers() {
docker exec "${MGR}" bash "${BKP_IN_NODE}/04_quiesce_database_writers.sh" manager
for _node in "${WRK1}" "${WRK2}"; do
docker exec "${_node}" bash "${BKP_IN_NODE}/04_quiesce_database_writers.sh" worker
done
}
restore_databases() {
local backups_root="${1}"
docker exec "${MGR}" bash "${BKP_IN_NODE}/06_restore_databases.sh" \
"${ROLE_TEST_DIR}" "${backups_root}"
}
write_verify_env() {
local db_verify="${1}" nfs_verify="${2}"
printf 'DR_DB_VERIFY=%s\nDR_NFS_VERIFY=%s\nMGR=%s\nROLE_TEST_DIR=%s\nBKP_IN_NODE=%s\n' \
"${db_verify}" "${nfs_verify}" "${MGR}" "${ROLE_TEST_DIR}" "${BKP_IN_NODE}" >"${DR_VERIFY_ENV}"
if [ "${nfs_verify}" = true ]; then
printf 'DR_TOKEN=%s\nDR_MARKER=%s\nNFS_SERVER=%s\nNFS_VOL_DIR=%s\n' \
"${DR_TOKEN}" "${DR_MARKER}" "${NFS_SERVER}" "${NFS_VOL_DIR}" >>"${DR_VERIFY_ENV}"
fi
}
if [ "${DRILL_FULL_CHAIN}" = false ] || [ -z "${PRIMARY_NFS_VOLUME}" ]; then
if [ "${DB_PENDING}" != true ]; then
echo "SKIP drill: no database handoff and no full NFS recovery requested for ${APP_ID}"
exit 0
fi
echo "==> destructive database-only DR drill for ${APP_ID}"
quiesce_database_writers
restore_databases "${DIR_BACKUPS}"
write_verify_env true false
echo "==> database recovery complete; the matrix update redeploys and verifies the restored probes"
exit 0
fi
if ! docker inspect "${BACKUP_NODE}" >/dev/null 2>&1; then
echo "FAILURE: backup host ${BACKUP_NODE} is not running (01_bootstrap.sh did not run?)"
exit 1
@@ -63,7 +96,6 @@ if [ ! -f "${BACKUP_KEY_PATH}" ]; then
fi
NFS_VOL_DIR="${NFS_STATE_PATH}/${PRIMARY_NFS_VOLUME}"
MGR_MID="$(docker exec "${MGR}" sha256sum /etc/machine-id | cut -c1-64)"
NFS_MID="$(docker exec "${NFS_SERVER}" sha256sum /etc/machine-id | cut -c1-64)"
echo "==> DR drill for ${APP_ID} (volume '${PRIMARY_NFS_VOLUME}')"
@@ -152,8 +184,10 @@ if ! docker exec "${BACKUP_NODE}" find "${DEV_DEST}" -name "${DR_MARKER}" 2>/dev
fi
echo " marker present on encrypted USB"
echo "==> [6/9] tear the stack down completely (full disaster) before recovery"
if [ "${HAS_SWARM_SERVICE}" = true ]; then
echo "==> [6/9] quiesce workloads before recovery"
if [ "${DB_PENDING}" = true ]; then
quiesce_database_writers
elif [ "${HAS_SWARM_SERVICE}" = true ]; then
docker exec "${MGR}" bash "${BKP_IN_NODE}/04_stack_rm_wait.sh" "${STACK_NAME}"
else
for _node in "${MGR}" "${WRK1}" "${WRK2}"; do
@@ -251,7 +285,18 @@ else
echo " secrets recover skipped: svc-bkp-secrets-2-local not installed on ${MGR}"
fi
echo "==> recovery complete: device -> nfs export -> volume -> secrets restored via the recover CLI"
echo " the matrix update pass boots the stack onto the recovered export; verify_recovered_marker.sh asserts the live marker there"
printf 'DR_TOKEN=%s\nDR_MARKER=%s\nNFS_SERVER=%s\nNFS_VOL_DIR=%s\n' \
"${DR_TOKEN}" "${DR_MARKER}" "${NFS_SERVER}" "${NFS_VOL_DIR}" >"${DR_VERIFY_ENV}"
if [ "${DB_PENDING}" = true ]; then
echo "==> [9b/9] restore the handed-off SQL generation from the device-recovered tree"
if ! docker exec "${BACKUP_NODE}" test -d "${RESTORE_ROOT}/${MGR_MID}/${VOLUME_REPO}"; then
echo "FAILURE: ${VOLUME_REPO} missing from the device-recovered manager tree"
exit 1
fi
docker exec "${MGR}" sh -c "rm -rf '${DB_RESTORE_ROOT}'; mkdir -p '${DB_RESTORE_ROOT}'"
docker exec "${BACKUP_NODE}" tar -C "${RESTORE_ROOT}" -cf - "${MGR_MID}/${VOLUME_REPO}" |
docker exec -i "${MGR}" tar --numeric-owner -C "${DB_RESTORE_ROOT}" -xf -
restore_databases "${DB_RESTORE_ROOT}"
fi
echo "==> recovery complete: device -> nfs export -> volume -> secrets -> databases restored"
echo " the matrix update pass redeploys every workload and verifies the recovered markers and database probes"
write_verify_env "${DB_PENDING}" true
@@ -1,8 +1,4 @@
#!/usr/bin/env bash
# Runs on the controller after the matrix update pass. Asserts the DR-drill
# marker (seeded pre-backup) survived recovery plus the update-pass bring-up
# onto the recovered NFS export. Skips when the sentinel is absent (the drill
# recovered nothing: the app declares no NFS-flagged volume).
set -euo pipefail
DR_VERIFY_ENV="/tmp/dr-drill-verify-${APP_ID:?APP_ID required (matrix sets it)}.env"
@@ -13,8 +9,15 @@ fi
# shellcheck disable=SC1090
. "${DR_VERIFY_ENV}"
if ! docker exec "${NFS_SERVER}" grep -qF "${DR_TOKEN}" "${NFS_VOL_DIR}/${DR_MARKER}" 2>/dev/null; then
echo "FAILURE: DR-drill marker missing on the live volume after recover + update pass"
exit 1
if [ "${DR_NFS_VERIFY:?}" = true ]; then
if ! docker exec "${NFS_SERVER}" grep -qF "${DR_TOKEN}" "${NFS_VOL_DIR}/${DR_MARKER}" 2>/dev/null; then
echo "FAILURE: DR-drill marker missing on the live volume after recover + update pass"
exit 1
fi
fi
echo "==> DR drill PASSED: backup -> teardown -> recover device->nfs->volume->secrets -> live marker verified after the update pass"
if [ "${DR_DB_VERIFY:?}" = true ]; then
docker exec "${MGR}" bash "${BKP_IN_NODE}/07_verify_databases.sh" "${ROLE_TEST_DIR}"
fi
echo "==> DR drill PASSED: recovered NFS=${DR_NFS_VERIFY}, databases=${DR_DB_VERIFY}; post-update probes verified"
@@ -0,0 +1,235 @@
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from utils import PROJECT_ROOT
BACKUP_SCRIPTS = PROJECT_ROOT / "scripts/tests/deploy/swarm/routine/backup"
ROLE_TEST_SCRIPTS = PROJECT_ROOT / "roles/svc-bkp-volume-2-local/files/test"
class TestDatabaseRestoreHandoff(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.test_dir = self.root / "role-test"
self.test_dir.mkdir()
(self.test_dir / "test.env").write_text(
"BKP_TEST_SWARM_DRILL=true\nBKP_TEST_DATABASES_CSV=/unused\n"
)
def test_restore_consumes_handoff_only_after_manifest_is_written(self):
backups = self.root / "backups"
(backups / "machine" / "repo" / "generation").mkdir(parents=True)
(self.test_dir / "swarm-restore.pending").write_text(
"MACHINE_HASH=machine\n"
"REPO_NAME=repo\n"
"NEWEST_GENERATION=generation\n"
"PROBE_PRE_TOKEN=before\n"
"PROBE_POST_TOKEN=after\n"
)
restore = self.test_dir / "db_restore.sh"
restore.write_text(
"#!/usr/bin/env bash\n"
"set -euo pipefail\n"
": \"${BKP_TEST_BACKUPS_DIR:?}\" \"${REPO_DIR:?}\"\n"
"printf 'postgres;postgres_data;app\\n' >\"${BKP_TEST_RESTORED_DATABASES_FILE}\"\n"
)
restore.chmod(0o755)
subprocess.run(
[
"bash",
str(BACKUP_SCRIPTS / "06_restore_databases.sh"),
str(self.test_dir),
str(backups),
],
check=True,
capture_output=True,
text=True,
)
self.assertFalse((self.test_dir / "swarm-restore.pending").exists())
self.assertTrue((self.test_dir / "swarm-restore.complete").is_file())
self.assertTrue((self.test_dir / "swarm-restore.manifest").is_file())
def test_verify_passes_completed_probe_tokens_to_role_helper(self):
(self.test_dir / "swarm-restore.complete").write_text(
"PROBE_PRE_TOKEN=before\nPROBE_POST_TOKEN=after\n"
)
manifest = self.test_dir / "swarm-restore.manifest"
manifest.write_text("postgres;postgres_data;app\n")
verified = self.root / "probe-verified"
probe = self.test_dir / "db_probe.sh"
probe.write_text(
"#!/usr/bin/env bash\n"
"set -euo pipefail\n"
"test \"$1\" = verify\n"
"test \"$2\" = before\n"
"test \"$3\" = after\n"
f"test \"$4\" = {manifest}\n"
"touch \"${PROBE_VERIFIED:?}\"\n"
)
probe.chmod(0o755)
env = os.environ.copy()
env["PROBE_VERIFIED"] = str(verified)
subprocess.run(
[
"bash",
str(BACKUP_SCRIPTS / "07_verify_databases.sh"),
str(self.test_dir),
],
check=True,
capture_output=True,
text=True,
env=env,
)
self.assertTrue(verified.is_file())
class TestDatabaseRestoreProbe(unittest.TestCase):
def test_seeds_and_verifies_postgres_and_mariadb(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
csv_file = root / "databases.csv"
csv_file.write_text(
"instance;database;username;password\n"
"postgres;app_pg;user_pg;pw_pg\n"
"mariadb;app_my;user_my;pw_my\n"
)
manifest = root / "manifest"
manifest.write_text(
"postgres;postgres_data;app_pg\n"
"mariadb;mariadb_data;app_my\n"
)
fake_bin = root / "bin"
fake_bin.mkdir()
container = fake_bin / "container"
container.write_text(
"#!/usr/bin/env bash\n"
"set -euo pipefail\n"
"case \"$1\" in\n"
" ps)\n"
" case \"$*\" in\n"
" *postgres_data*) printf 'postgres.1\\n' ;;\n"
" *mariadb_data*) printf 'mariadb.1\\n' ;;\n"
" esac\n"
" ;;\n"
" inspect)\n"
" case \"${*: -1}\" in\n"
" postgres.1) printf 'postgres_custom\\n' ;;\n"
" mariadb.1) printf 'mariadb_custom\\n' ;;\n"
" esac\n"
" ;;\n"
" exec)\n"
" case \"$*\" in\n"
" *SELECT*before*)\n"
" test -f \"${DB_STATE:?}/seeded-${4}\" && printf '1\\n' || printf '0\\n'\n"
" ;;\n"
" *SELECT*after*) printf '0\\n' ;;\n"
" *) touch \"${DB_STATE:?}/seeded-${4}\" ;;\n"
" esac\n"
" ;;\n"
"esac\n"
)
container.chmod(0o755)
env = os.environ.copy()
env["PATH"] = f"{fake_bin}:{env['PATH']}"
env["BKP_TEST_DATABASES_CSV"] = str(csv_file)
env["DB_STATE"] = str(root)
subprocess.run(
["bash", str(ROLE_TEST_SCRIPTS / "db_probe.sh"), "seed", "before"],
check=True,
capture_output=True,
text=True,
env=env,
)
self.assertTrue((root / "seeded-postgres.1").is_file())
self.assertTrue((root / "seeded-mariadb.1").is_file())
subprocess.run(
[
"bash",
str(ROLE_TEST_SCRIPTS / "db_probe.sh"),
"verify",
"before",
"after",
str(manifest),
],
check=True,
capture_output=True,
text=True,
env=env,
)
class TestDatabaseWriterQuiesce(unittest.TestCase):
def test_keeps_only_database_registry_and_stops_discourse(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
fake_bin = root / "bin"
fake_bin.mkdir()
docker = fake_bin / "docker"
docker.write_text(
"#!/usr/bin/env bash\n"
"set -euo pipefail\n"
"case \"$1 $2\" in\n"
" 'stack ls') printf 'docker\\npostgres\\nwordpress\\n' ;;\n"
" 'stack services')\n"
" case \"$3\" in\n"
" docker) printf 'registry:2\\n' ;;\n"
" postgres) printf 'localhost:5000/postgres_custom:17\\n' ;;\n"
" wordpress) printf 'wordpress:latest\\n' ;;\n"
" esac\n"
" ;;\n"
" 'stack rm') touch \"${DOCKER_STATE:?}/removed-$3\" ;;\n"
" 'service ls') : ;;\n"
" 'ps --format')\n"
" printf 'db\\tlocalhost:5000/postgres_custom:17\\n'\n"
" printf 'registry\\tregistry:2\\n'\n"
" printf 'discourse\\tlocal_discourse/discourse:latest\\n'\n"
" printf 'wordpress-task\\twordpress:latest\\n'\n"
" ;;\n"
" 'stop discourse')\n"
" touch \"${DOCKER_STATE:?}/stopped-$2\" \"${DOCKER_STATE:?}/stopped-$3\"\n"
" ;;\n"
" *) printf 'unexpected docker call: %s\\n' \"$*\" >&2; exit 1 ;;\n"
"esac\n"
)
docker.chmod(0o755)
env = os.environ.copy()
env["PATH"] = f"{fake_bin}:{env['PATH']}"
env["DOCKER_STATE"] = str(root)
subprocess.run(
[
"bash",
str(BACKUP_SCRIPTS / "04_quiesce_database_writers.sh"),
"manager",
],
check=True,
capture_output=True,
text=True,
env=env,
)
self.assertTrue((root / "removed-wordpress").is_file())
self.assertTrue((root / "stopped-discourse").is_file())
self.assertTrue((root / "stopped-wordpress-task").is_file())
self.assertFalse((root / "removed-postgres").exists())
self.assertFalse((root / "removed-docker").exists())
self.assertFalse((root / "stopped-db").exists())
self.assertFalse((root / "stopped-registry").exists())
if __name__ == "__main__":
unittest.main()
+13 -5
View File
@@ -139,6 +139,8 @@ def _deploy(
f"@{extras_path}",
"-e",
f"VARIANT_INDEX={json.dumps(round_index)}",
"-e",
"TEST_E2E_SWARM_DRILL=true",
]
pass_label = (
f"matrix-deploy: round {round_index + 1}/{total} "
@@ -192,11 +194,14 @@ def _converge_and_verify(*, app_id: str) -> int:
)
def _backup_restore_drill(*, app_id: str, inv_dir: str, extras_path: str) -> int:
def _backup_restore_drill(
*, app_id: str, inv_dir: str, extras_path: str, full_chain: bool
) -> int:
env = os.environ.copy()
env["APP_ID"] = app_id
env["INFINITO_INVENTORY_DIR"] = inv_dir
env["DRILL_EXTRAS"] = extras_path
env["DRILL_FULL_CHAIN"] = str(full_chain).lower()
return _run(
["bash", str(_SWARM_SCRIPTS / "backup" / "base.sh")],
env=env,
@@ -339,15 +344,18 @@ def main(argv: list[str] | None = None) -> int:
)
if rc == 0:
rc = _converge_and_verify(app_id=app_id)
if rc == 0 and round_index == 0:
if rc == 0 and plan_index == 0:
rc = _deploy_backup_host(
app_id=app_id,
inv_dir=inv_root,
extras_path=f"{inv_root}/swarm-nfs-extras.deploy.yml",
)
if rc == 0 and round_index == 0:
if rc == 0:
rc = _backup_restore_drill(
app_id=app_id, inv_dir=inv_root, extras_path=extras_path
app_id=app_id,
inv_dir=inv_root,
extras_path=extras_path,
full_chain=plan_index == 0,
)
if rc == 0:
rc = _deploy(
@@ -360,7 +368,7 @@ def main(argv: list[str] | None = None) -> int:
)
if rc == 0:
rc = _converge_and_verify(app_id=app_id)
if rc == 0 and round_index == 0:
if rc == 0:
rc = _verify_recovered_marker(app_id=app_id)
if rc != 0:
return rc