Skip to content

Commit b164d0e

Browse files
andrewbakercloudscaleAndrew Bakerclaude
authored
fix(backup): make read-only quiesce work on MySQL/older-MariaDB containers (+ --db-check) (#2)
* fix(backup): surface real MariaDB error when read_only quiesce fails db_exec captured stderr to /dev/null, so a failed `SET GLOBAL read_only=ON` only logged "did not take effect" with no cause. Capture stderr into _DB_LAST_ERR and log it in db_lock_mysql's fallback branch so the actual MariaDB error (privilege, auth, etc.) is visible for diagnosis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(backup): add --db-check diagnostic mode Reports DB detection + whether the read-only quiesce engages (current user, version, prior/after read_only, SET errors), then exits without imaging. Briefly toggles read_only and restores it. Zero downtime; reusable for diagnosing why a backup falls back to STOP_DOCKER. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(backup): fall back to mysql client in container exec The container branch of db_exec hardcoded the `mariadb` client. MySQL and older MariaDB images ship only `mysql`, so `docker exec ... mariadb` failed with 'executable not found' and the read-only quiesce silently no-op'd — causing a fallback to STOP_DOCKER (downtime). Now tries mariadb then mysql, mirroring the native branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: changelog + --db-check in README/AGENTS/llms.txt Document the container mysql-client fallback fix and the new --db-check diagnostic mode across CHANGELOG (1.10.0 Fixed/Added), README options, AGENTS.md troubleshooting, and pi2s3.com/llms.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Andrew Baker <andrew@andrewbaker.ninja> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent be1da66 commit b164d0e

5 files changed

Lines changed: 100 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ auto-detecting it whether it runs in Docker or natively on the host:
9898
You normally don't touch any of this. Only edit `config.env` (`DB_ENGINE`,
9999
`DB_ROOT_PASSWORD`, `DB_PG_USER`) if the user reports the DB wasn't detected.
100100

101+
To confirm the quiesce will be zero-downtime *before* relying on it, run the
102+
diagnostic (no imaging, no downtime):
103+
```bash
104+
bash ~/pi2s3/pi-image-backup.sh --db-check
105+
```
106+
It prints the detected engine/container, the connecting user, and whether
107+
`read_only` actually engages. If it reports FAILED, the backup would fall back
108+
to stopping containers (brief downtime) — surface that to the user.
109+
101110
## Scheduling
102111

103112
`install.sh` installs a nightly cron job (2am by default). To change it, set

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ All notable changes to pi2s3 are documented here.
1212
- **Native (non-Docker) database detection** (`lib/containers.sh`) — `DB_CONTAINER="auto"` now detects a database running **natively on the host** (`mariadbd`/`mysqld`/`postgres` processes), not just in Docker. Native MySQL/MariaDB previously fell back to a stop-the-service downtime; it now uses the zero-downtime path (set `DB_ROOT_PASSWORD` — there is no container env to read it from). Native PostgreSQL with peer auth needs no password.
1313
- **`DB_ENGINE` and `DB_PG_USER` config** (`config.env.example`) — `DB_ENGINE` (`auto` | `mysql` | `mariadb` | `postgres`) forces the engine for an explicit native install where auto-detection can't see a container. `DB_PG_USER` is the PostgreSQL superuser used for `CHECKPOINT`.
1414
- **`AGENTS.md` + `pi2s3.com/llms.txt`** — agent-facing instructions so an AI assistant (e.g. Claude) pointed at the repo or the site can install pi2s3 and run a backup unattended ("backup my site with pi2s3").
15+
- **`--db-check` diagnostic mode** (`pi-image-backup.sh`) — reports DB detection (engine, container/native), connecting user, version, and whether the read-only quiesce actually engages, then exits without imaging. Briefly toggles `read_only` and restores it (zero downtime). Use it to confirm a backup will be zero-downtime before relying on it.
16+
17+
### Fixed
18+
19+
- **Container DB client fell back to `mysql`** (`pi-image-backup.sh`) — `db_exec` hardcoded the `mariadb` client for the Docker path. MySQL images (and MariaDB before 10.5) ship only the `mysql` binary, so `docker exec … mariadb` failed with "executable not found", the read-only quiesce silently no-op'd, and the backup fell back to `STOP_DOCKER` — causing avoidable downtime. Now tries `mariadb` then `mysql`, mirroring the native branch. (Found in production: a MySQL 8.0 analytics container was auto-detected and the quiesce silently failed.)
20+
- **Silent quiesce failures now logged** (`pi-image-backup.sh`) — `db_exec` captured stderr to `/dev/null`, so a failed quiesce gave no cause. It now captures stderr into `_DB_LAST_ERR` and `db_lock_mysql` logs the real MariaDB/MySQL error in its fallback branch.
1521

1622
### Changed
1723

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ pi-image-backup.sh [options]
355355
--stale-check Ntfy alert if latest backup is older than STALE_BACKUP_HOURS
356356
--cost Show S3 storage used and estimated monthly cost
357357
--no-stop-docker Skip Docker stop (for daytime test runs with no downtime)
358+
--db-check Diagnose DB detection + read-only quiesce, then exit (no imaging)
358359
--help Show usage
359360
```
360361

pi-image-backup.sh

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ VERIFY=false
120120
VERIFY_DATE=""
121121
STALE_CHECK=false
122122
COST=false
123+
DB_CHECK=false
123124

124125
for arg in "$@"; do
125126
case "$arg" in
@@ -132,6 +133,7 @@ for arg in "$@"; do
132133
--no-stop-docker) STOP_DOCKER=false ;;
133134
--stale-check) STALE_CHECK=true ;;
134135
--cost) COST=true ;;
136+
--db-check) DB_CHECK=true ;;
135137
--help) echo "Usage: pi-image-backup.sh [options]
136138
(no args) Run nightly backup
137139
--force Skip duplicate-check (run even if today's backup exists)
@@ -143,6 +145,7 @@ for arg in "$@"; do
143145
--stale-check Alert via ntfy if latest backup is older than STALE_BACKUP_HOURS
144146
--cost Show S3 storage used and estimated monthly cost
145147
--no-stop-docker Skip Docker stop (for daytime test runs, no downtime)
148+
--db-check Diagnose DB detection + read-only quiesce, then exit (no imaging)
146149
--help Show this help
147150
148151
Config: ${SCRIPT_DIR}/config.env
@@ -163,6 +166,7 @@ _DB_ROOT_PASSWORD=""
163166
_DB_LOCK_PID=""
164167
_DB_CONN_ID=""
165168
_DB_LOCK_TAG="pi2s3-lock-$$"
169+
_DB_LAST_ERR=""
166170
_DB_RO_CHANGED=false
167171
# Sentinel recording that WE flipped the server read-only. Lets the next backup
168172
# recover a stale read-only state if a previous run was hard-killed (SIGKILL/power
@@ -356,13 +360,25 @@ _write_standby_sync_marker() {
356360
# If container is empty, runs mariadb/mysql locally with MYSQL_PWD in the environment.
357361
db_exec() {
358362
local _c="$1" _pw="$2"; shift 2
363+
# Capture stderr into _DB_LAST_ERR (not /dev/null) so callers can log the
364+
# real MariaDB error on failure, while stdout still carries only query output.
365+
local _ef _rc; _ef=$(mktemp 2>/dev/null || echo "/tmp/pi2s3-dbexec.$$")
359366
if [[ -n "${_c}" ]]; then
367+
# Try the `mariadb` client, falling back to `mysql` — older MariaDB and
368+
# MySQL images ship only the `mysql` binary (no `mariadb`), so a hardcoded
369+
# `mariadb` exec fails with "executable not found" and the quiesce silently
370+
# no-ops. Mirrors the native branch below.
360371
docker exec -e "MYSQL_PWD=${_pw}" "${_c}" \
361-
mariadb -u root --batch --silent "$@" 2>/dev/null
372+
mariadb -u root --batch --silent "$@" 2>"${_ef}" \
373+
|| docker exec -e "MYSQL_PWD=${_pw}" "${_c}" \
374+
mysql -u root --batch --silent "$@" 2>"${_ef}"
362375
else
363-
MYSQL_PWD="${_pw}" mariadb -u root --batch --silent "$@" 2>/dev/null \
364-
|| MYSQL_PWD="${_pw}" mysql -u root --batch --silent "$@" 2>/dev/null
376+
MYSQL_PWD="${_pw}" mariadb -u root --batch --silent "$@" 2>"${_ef}" \
377+
|| MYSQL_PWD="${_pw}" mysql -u root --batch --silent "$@" 2>"${_ef}"
365378
fi
379+
_rc=$?
380+
_DB_LAST_ERR=$(cat "${_ef}" 2>/dev/null); rm -f "${_ef}"
381+
return ${_rc}
366382
}
367383

368384
# Run a single PostgreSQL statement.
@@ -522,18 +538,21 @@ db_lock_mysql() {
522538

523539
# Flip to read-only. read_only blocks the (non-SUPER) app user; super_read_only
524540
# (MySQL only) additionally blocks SUPER users — best-effort so MariaDB is fine.
541+
local _set_err=""
525542
db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" \
526-
-e "SET GLOBAL read_only=ON;" 2>/dev/null || true
543+
-e "SET GLOBAL read_only=ON;" >/dev/null || true
544+
_set_err="${_DB_LAST_ERR}"
527545
db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" \
528-
-e "SET GLOBAL super_read_only=ON;" 2>/dev/null || true
546+
-e "SET GLOBAL super_read_only=ON;" >/dev/null || true
529547
db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" \
530-
-e "FLUSH LOGS;" 2>/dev/null || true
548+
-e "FLUSH LOGS;" >/dev/null || true
531549

532550
# Verify it took effect before trusting the snapshot.
533551
_ro=$(db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" \
534552
-e "SELECT @@global.read_only;" | tail -1 || true)
535553
if [[ "${_ro}" != "1" ]]; then
536554
log " WARNING: SET GLOBAL read_only did not take effect — falling back to STOP_DOCKER"
555+
[[ -n "${_set_err}" ]] && log " mariadb: ${_set_err}"
537556
_DB_CONTAINER=""; _DB_ENGINE=""; return 0
538557
fi
539558

@@ -621,6 +640,64 @@ db_unlock() {
621640
log " DB read-write — writes unblocked."
622641
}
623642

643+
# ── DB diagnostic (--db-check) ────────────────────────────────────────────────
644+
# Reports how the DB would be detected and whether the read-only quiesce works,
645+
# without imaging anything. For MySQL/MariaDB it briefly toggles read_only and
646+
# restores it, logging the connecting user and any error. Safe to run any time.
647+
db_check() {
648+
log "========================================================"
649+
log " pi2s3 — DB quiesce check"
650+
log "========================================================"
651+
log " DB_CONTAINER=${DB_CONTAINER} DB_ENGINE=${DB_ENGINE}"
652+
if ! db_resolve_target; then
653+
log " RESULT: no DB resolved — backup would use STOP_DOCKER (downtime)."
654+
exit 0
655+
fi
656+
log " Resolved: engine=${_DB_ENGINE} location=${_DB_CONTAINER:-<native host>}"
657+
658+
if [[ "${_DB_ENGINE}" == "postgres" ]]; then
659+
local _v
660+
_v=$(db_exec_pg "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" "SELECT version();" || true)
661+
if [[ -n "${_v}" ]]; then
662+
log " Connected. ${_v}"
663+
log " RESULT: OK — PostgreSQL CHECKPOINT path will be used (zero downtime)."
664+
else
665+
log " RESULT: could not connect (check DB_PG_USER) — would use STOP_DOCKER."
666+
fi
667+
exit 0
668+
fi
669+
670+
# MySQL / MariaDB
671+
log " current_user: $(db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" -e "SELECT CURRENT_USER();" | tail -1)"
672+
log " version: $(db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" -e "SELECT VERSION();" | tail -1)"
673+
local _ro0 _ro1
674+
_ro0=$(db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" -e "SELECT @@global.read_only;" | tail -1 || true)
675+
log " prior read_only=${_ro0:-<none>} super_read_only=$(db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" -e "SELECT @@global.super_read_only;" | tail -1 || true)"
676+
if [[ "${_ro0}" == "1" ]]; then
677+
log " Server is already read-only (replica?) — pi2s3 would leave it untouched. OK."
678+
exit 0
679+
fi
680+
681+
log " Attempting SET GLOBAL read_only=ON ..."
682+
db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" -e "SET GLOBAL read_only=ON;" >/dev/null || true
683+
log " read_only SET error: ${_DB_LAST_ERR:-<none>}"
684+
db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" -e "SET GLOBAL super_read_only=ON;" >/dev/null || true
685+
log " super_read_only SET error: ${_DB_LAST_ERR:-<none>}"
686+
_ro1=$(db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" -e "SELECT @@global.read_only;" | tail -1 || true)
687+
log " read_only after SET=${_ro1:-<none>}"
688+
# Restore
689+
db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" -e "SET GLOBAL super_read_only=OFF;" >/dev/null || true
690+
db_exec "${_DB_CONTAINER}" "${_DB_ROOT_PASSWORD}" -e "SET GLOBAL read_only=OFF;" >/dev/null || true
691+
692+
if [[ "${_ro1}" == "1" ]]; then
693+
log " RESULT: OK — read-only quiesce works (zero downtime). Restored to read-write."
694+
else
695+
log " RESULT: FAILED — read_only would not engage; backup falls back to STOP_DOCKER (downtime)."
696+
fi
697+
exit 0
698+
}
699+
[[ "${DB_CHECK}" == "true" ]] && db_check
700+
624701
# ── Site availability probe ───────────────────────────────────────────────────
625702
# Pings the site every PROBE_INTERVAL seconds during partition imaging.
626703
# Cache-busted via query param + no-cache headers so every request hits PHP/DB.

website/llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Never run `pi-image-restore.sh` unless explicitly asked — it overwrites a disk
2828

2929
- `bash pi-image-backup.sh --force` — run a backup now
3030
- `bash pi-image-backup.sh --dry-run` — show the plan, upload nothing
31+
- `bash pi-image-backup.sh --db-check` — confirm DB detection + zero-downtime quiesce (no imaging)
3132
- `bash pi-image-backup.sh --list` / `--verify` — list / verify S3 backups
3233
- `bash pi-image-backup.sh --cost` — S3 usage and estimated monthly cost
3334
- `bash install.sh --iam-policy` — print the minimum AWS IAM policy

0 commit comments

Comments
 (0)