Playbook: MongoDB Restore Drill — Troubleshooting
Reference for diagnosing failures in scripts/mongodb-restore-drill-cron.sh and
scripts/mongodb-restore-drill.sh.
First successful drill run: 2026-07-01 (w3_db + w4_db, backup 2026-06-15). All issues below were discovered and fixed before that run.
Incident 2026-10-01 — timeout → unauthenticated mongod exposed for 7 days (#7609)
What happened. The Q4 2026 quarterly drill started 2026-10-01 03:05 UTC on bms-4 and
systemd SIGTERM-killed it at 04:05:49 UTC: TimeoutStartSec was 3600s but the w3_db
restore alone took 3397s and w4_db had only just started, so the drill timed out mid-run
and produced no complete Q4 verification. Worse, the drill script had no cleanup trap at
the time, so the SIGTERM left the throwaway container mongo-drill-1439728 (mongo:7.0, no
--auth) running for 7 days publishing 0.0.0.0:27019->27017. Docker’s published ports
bypass ufw (iptables DOCKER chain), so an unauthenticated listDatabases on
54.36.123.110:27019 returned the restored production w3_db from the internet. The two
production dump archives were also left world-readable (0644) in /tmp.
Connection-log review of the container before removal (IP addresses only, no data): 26 accepted connections from 20 distinct external public IPs over the window (plus 16 local from the drill’s own checks), consistent with internet-wide MongoDB scanners — but because auth was disabled, unauthenticated reads of the restored copy could not be excluded.
Root causes & fixes (this PR):
- Timeout too short →
TimeoutStartSecraised to 14400s (4h) indeploy/p24-mongodb-restore-drill.service. - No teardown on SIGTERM →
scripts/mongodb-restore-drill.shnow installstrap _cleanup EXIT INT TERM(idempotent_CLEANEDguard), and the unit has anExecStopPost=that force-removes anyp24.drill=1container (with-v) even after a SIGKILL. - Internet exposure → the container now publishes on
127.0.0.1only and runs with enforced--auth(image root env vars, a random single-use credential delivered to mongosh on stdin). Nothing consumes the host port — all checks run viadocker exec. - World-readable dumps →
umask 077+ a 0700 scratch dir;docker rm -vdrops the restored-data volume. - Orphan safety net →
scripts/mongodb-drill-orphan-sweep.sh(p24-mongodb-drill-orphan-sweep.{service,timer}, daily 06:00 UTC) removes any labelled drill container older than the max runtime and reports per the Error Notification Standard.
The Q4 2026 DR verification must be re-run (a separate infra-task) now the fix is merged and its result recorded here.
Incident 2026-10-10 — drill exits 1 after a good restore; verdict block skipped (#7847)
What happened. p24-mongodb-restore-drill.service on bms-4 showed failed (exit-code)
after the 2026-10-10 02:40 UTC run. The restore and comparisons themselves succeeded (w3_db +
w4_db restored, non-zero document counts, OVERALL_STATUS PASS, ERRORS/FAILURES: None), but the
mongodb-restore-drill-cron.sh wrapper saw rc=1 and reported “already self-alerted”. The Q4
drill comment on #7609 showed Verdict line: none captured / Fail reason: none captured. The
PASS Discord notification and the dev_r_ops_events success row were silently skipped.
Root cause. _cleanup() calls log "Cleanup done." after rm -rf "${SCRATCH_DIR}". Since
LOG_FILE lives inside SCRATCH_DIR, the tee -a "${LOG_FILE}" in log() finds the file gone
and exits non-zero. Under set -euo pipefail the pipe failure propagates out of _cleanup, and
because _cleanup is called before the “Final verdict” block (line ~685), the script exits 1
at that point — before _supabase_log_result, the Discord PASS notification, or exit 0 can run.
The _CLEANED guard and EXIT trap added by the #7650 hardening did not introduce the rm-then-log
ordering; they exposed it by making _cleanup run reliably on every exit path.
Fix (#7847).
log()is now tolerant:echo ... | tee -a "${LOG_FILE}" 2>/dev/null || true. A missing log file silently drops the message but never aborts the script.BACKUP_TOO_OLDage guard added to Step 1:date -dcomputes the backup age in hours; if> 48h,BACKUP_TOO_OLD=trueand OVERALL_STATUS is set to FAIL with the age in FAIL_REASONS. This prevents a months-old backup from producing a misleading PASS (the #7717 scenario: backups stopped 2026-06-15; the drill restored that backup and still passed).
What to check if you see a similar rc=1 with a successful restore.
# On bms-4 — check the journal for the relevant drill PID:
journalctl -u p24-mongodb-restore-drill.service --no-pager | grep -E 'tee:|exit|Cleanup|verdict'
# Look for: tee: /tmp/mongo-drill-<PID>/drill.log: No such file or directory
# If present: the drill script pre-dates the #7847 fix — deploy the updated script.After deploying the fix, re-run the drill in a quiet window (see “Running the drill manually” below). Note: as of 2026-10-13 backups are still stale (#7717, BOM in mongodb-backup.sh on bms-3), so the drill will now correctly report FAIL with a backup-age reason. A meaningful PASS requires fresh backups from bms-3 first.
Orphan drill container cleanup (teardown didn’t fire)
scripts/mongodb-restore-drill.sh names its container mongo-drill-$$ (the script PID), labels
it p24.drill=1 / p24.drill.started=<epoch> / p24.drill.pid=<PID>, and tears it down in
_cleanup() via a trap _cleanup EXIT INT TERM (docker stop → docker rm -v → rm -rf the
/tmp/mongo-drill-$$ scratch dir). That trap now survives the SIGTERM a TimeoutStartSec kill
sends; it still does not run on a hard SIGKILL or a host reboot mid-drill. Two further nets
cover that residual case: the unit’s ExecStopPost= (fires even after SIGKILL) and the daily
p24-mongodb-drill-orphan-sweep.timer. Before #7609 none of these existed, so a killed drill left
an orphan mongo-drill-<PID> publishing a mongod — e.g. mongo-drill-3306081 left Up 4 days
publishing 0.0.0.0:27019->27017 (#6354), and mongo-drill-1439728 for 7 days (#7609).
A drill runs up to ~4h, so any drill container older than that is an orphan. The automated sweep
handles this, but to do it by hand — confirm no drill is actually running, then tear it down
exactly as _cleanup() would:
# List drill containers + age (prefer the label; name filter is the legacy fallback)
docker ps -a --filter "label=p24.drill=1" \
--format '{{.Names}} | {{.Status}} | {{.CreatedAt}}'
docker ps -a --filter "name=mongo-drill" \
--format '{{.Names}} | {{.Status}} | {{.CreatedAt}}'
# For a candidate: verify it is NOT a live drill (label PID, then process scan)
PID=$(docker inspect -f '{{ index .Config.Labels "p24.drill.pid" }}' <name> 2>/dev/null)
kill -0 "$PID" 2>/dev/null && echo "PID ALIVE — do NOT remove" || echo "PID dead — safe"
ps aux | grep -E 'mongo-drill|restore.drill' | grep -v grep # expect none
tail -n2 /tmp/mongo-drill-<PID>/drill.log 2>/dev/null # last activity timestamp
# Safe to remove — same as _cleanup() / the sweep (−v drops the restored-data volume)
docker rm -vf <name>
rm -rf /tmp/mongo-drill-<PID>
# Or just let the automated sweep do it:
sudo -u claude-runner /opt/p24-infra/scripts/mongodb-drill-orphan-sweep.shOrphan drill containers are not registered in dev_r_services — they are transient by
design — so a leftover one is untracked inventory a D2 compliance audit will flag.
Issue 1 — sops exec-env fails on *.env.sops extension
Symptom: Drill exits with:
Error unmarshalling input json: invalid character 'C' looking for beginning of value
Root cause: sops exec-env detects file format from extension only. The extension
*.env.sops is NOT recognized as dotenv (only *.env is recognized). SOPS falls back
to JSON parsing; the file starts with CF_API_TOKEN=C... → C is invalid JSON.
The --input-type flag is not available on the exec-env subcommand.
Fix (in mongodb-restore-drill-cron.sh): Replace sops exec-env with explicit decrypt:
_tmpenv=$(mktemp)
chmod 600 "${_tmpenv}"
trap 'rm -f "${_tmpenv}"' EXIT
sops --decrypt --input-type dotenv --output-type dotenv "${MONITORING_SOPS}" >> "${_tmpenv}"
sops --decrypt --input-type dotenv --output-type dotenv "${BMS_SERVERS_SOPS}" >> "${_tmpenv}"
# ... then inject into env (see Issue 2 and 3)Issue 2 — source fails on values with special characters
Symptom: After decrypting SOPS, env injection silently fails. Some variables not exported. The drill script exits with “SKIPPED (unconfigured)” even though SOPS decrypt succeeded.
Root cause: set -a; source "$tmpenv"; set +a calls source which parses each line
as a bash statement. Values like SUPABASE_GRAFANA_PASSWORD=.nSw^Jkn)Y?-(npgCwSW...
contain ) which bash interprets as syntax (end of subshell).
Fix: Use a safe line-by-line while loop:
while IFS= read -r _line; do
[[ -z "${_line}" || "${_line}" == \#* ]] && continue
_k="${_line%%=*}"
_v="${_line#*=}"
[[ "${_k}" =~ ^[a-zA-Z_][a-zA-Z_0-9]*$ ]] || continue # Issue 3
export "${_k}"="${_v}"
done < "${_tmpenv}"Issue 3 — Invalid bash identifiers in dotenv file
Symptom:
export: 'p24-infra-1h-check-claude-discord=...': not a valid identifier
Root cause: Some keys in monitoring.env.sops have hyphens (e.g.
p24-infra-1h-check-claude-discord) which are valid in dotenv but not as bash variable
names.
Fix: Guard with regex before export (included in Issue 2 fix):
[[ "${_k}" =~ ^[a-zA-Z_][a-zA-Z_0-9]*$ ]] || continueIssue 4 — MONGODB_ADMIN_PASSWORD not set for --compare-prod
Symptom: Drill logs “WARNING: MONGODB_ADMIN_PASSWORD not set — skipping production
comparison”. The --compare-prod step is silently skipped.
Root cause: bms-servers.env.sops uses key mongodb_rs0_admin_password (lowercase)
but the drill script expects MONGODB_ADMIN_PASSWORD. After the line-by-line injection,
only mongodb_rs0_admin_password is exported.
Fix (in mongodb-restore-drill-cron.sh): Add mapping after env injection:
export MONGODB_ADMIN_PASSWORD="${mongodb_rs0_admin_password:-}"
export MONGODB_ADMIN_USER="${mongodb_rs0_admin_user:-admin}"Issue 5 — _aws_s3 helper: redundant s3 prefix
Symptom:
aws: [ERROR]: An error occurred (ParamValidation): argument subcommand: Found invalid choice 's3'
Root cause: _aws_s3() already calls aws s3 .... All call sites were passing s3
as the first argument (e.g. _aws_s3 s3 ls), resulting in aws s3 s3 ls which fails
because s3 is not a valid subcommand of aws s3.
Fix: Change all call sites to pass only the subcommand:
_aws_s3 ls "s3://bucket/prefix/" # not: _aws_s3 s3 ls ...
_aws_s3 cp src dst # not: _aws_s3 s3 cp ...
_aws_s3 sync src dst # not: _aws_s3 s3 sync ...Issue 6 — Wrong Wasabi backup prefix
Symptom: STEP 1: Locating latest backup on Wasabi finds nothing, drill exits
immediately with “No backup found”.
Root cause: BACKUP_PREFIX="mongodb/full" in mongodb-restore-drill.sh but actual
backups are at s3://p24-infra/mongodb-backups/.
To list the actual structure:
aws s3 ls s3://p24-infra/mongodb-backups/ \
--endpoint-url https://s3.eu-central-2.wasabisys.com --region eu-central-2
# Expected: w3_db-YYYY-MM-DD.mongodump.gz, w4_db-YYYY-MM-DD.mongodump.gzFix: BACKUP_PREFIX="mongodb-backups" and update date extraction to use grep -Eo
(extracts date from filenames, not directory prefixes).
Issue 7 — Wrong restore format (directory vs archive)
Symptom: After the drill completes restore, collection counts are all 0 or the restore step fails silently.
Root cause: mongodb-restore-drill.sh was written for mongodump --out DIR --gzip
format (per-collection .bson.gz files inside a directory). But actual backups are created
with mongodump --archive=FILE --gzip format (single .mongodump.gz per database).
Fix: Restore with --archive flag:
# Inside Docker container:
mongorestore --archive="/tmp/${DB}.mongodump.gz" --gzip --drop --preserveUUID
# NOT: mongorestore --db "$DB" --dir "/dump/${DB}" --gzip ...Issue 8 — aws CLI not installed on bms-4
Symptom: bash: aws: command not found during STEP 1 or 2.
Fix:
curl -s https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o /tmp/awscliv2.zip
cd /tmp && unzip -q awscliv2.zip
./aws/install --install-dir /usr/local/aws-cli --bin-dir /usr/local/bin
aws --version # verifyRunning the drill manually
# On bms-4 as root:
sudo -u claude-runner /opt/p24-infra/scripts/mongodb-restore-drill-cron.sh
# Logs: /tmp/mongo-drill-$PID/drill.log (0700 dir; dumps are 0600)
# Duration: up to ~4h — w3_db restore alone measured 3397s on 2026-10-01 (#7609),
# plus w4_db + integrity checks. The unit's TimeoutStartSec is 14400s to match.Related
scripts/mongodb-restore-drill.sh— main drill logicscripts/mongodb-restore-drill-cron.sh— wrapper for SOPS injection + alertingscripts/mongodb-drill-orphan-sweep.sh— daily orphan-container safety net (#7609)deploy/p24-mongodb-restore-drill.{service,timer}— quarterly drill unitsdeploy/p24-mongodb-drill-orphan-sweep.{service,timer}— daily orphan sweep units (#7609)scripts/mongodb-backup.sh— backup script on bms-3 (Wasabi, weekly → daily after #2367)docs/playbooks/mongodb-rs0-full-restore.md— full DR playbook (Scenario B)- Issue #2367 — bms-3 SSH + daily cron fix
- Issue #2148 — backup gap tracking
- PR #2371 — all drill script fixes