fix(ops): backup failed on the MariaDB client shipped in the API image
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m7s
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m7s

Every backup on galactus died with:

  mysqldump: unknown variable 'set-gtid-purged=OFF'
  respaldo incompleto eliminado

Alpine's mysql-client is MariaDB's, so `mysqldump` inside the API
container is a shim over `mariadb-dump`, which has no --set-gtid-purged.
That took out BACKUP and, because they take a safety dump first, SYNC
and REIMPORT too.

Probe `mysqldump --help` and pass the flag only when it is advertised,
calling `mariadb-dump` directly otherwise — MariaDB writes no GTID state
unless asked with --gtid, so there is nothing to suppress. Testing
whether mariadb-dump merely exists would be wrong: on a host carrying
both clients it would shadow a perfectly good MySQL mysqldump.

The probe uses a command substitution rather than `--help | grep -q`
because PIPEFAIL is in effect for these commands and grep closing the
pipe early would report a supported flag as unsupported.

pre-migrate-backup.mjs is unaffected — it dumps from a real mysql:8.4
image, not from the API container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 01:43:25 -07:00
co-authored by Claude Opus 5
parent 783ec83464
commit 860d483bad
2 changed files with 39 additions and 9 deletions
+26 -5
View File
@@ -262,9 +262,26 @@ export class OpsService implements OnModuleInit {
* deploy/scripts/pre-migrate-backup.mjs — the two write into the same volume * deploy/scripts/pre-migrate-backup.mjs — the two write into the same volume
* and both are listed as restore points by this same screen. * and both are listed as restore points by this same screen.
* *
* --set-gtid-purged=OFF: the production server is the replication SOURCE with * The dumper is probed at runtime rather than assumed. This command runs
* GTID on, so without it every dump embeds SET @@GLOBAL.GTID_PURGED and is * inside the API image, whose `mysql-client` is Alpine's — i.e. MariaDB's —
* unrestorable onto the very server it came from. * where `mysqldump` is a deprecation-warning shim over `mariadb-dump` that
* rejects --set-gtid-purged outright:
* mysqldump: unknown variable 'set-gtid-purged=OFF'
* which failed every backup, including the safety backups SYNC and REIMPORT
* take first. MariaDB's dumper emits no GTID state unless asked (--gtid), so
* there is nothing to suppress there; the flag is passed only when the dumper
* on PATH advertises it, and the real binary is called directly only in the
* MariaDB case (calling `mariadb-dump` whenever it merely exists would pick
* it over a MySQL `mysqldump` earlier in PATH on a host carrying both).
*
* The probe is a command substitution, not `--help | grep -q`: PIPEFAIL is in
* effect and grep closing the pipe early would make a supported flag look
* unsupported.
*
* --set-gtid-purged=OFF (MySQL only): the production server is the
* replication SOURCE with GTID on, so without it every dump embeds
* SET @@GLOBAL.GTID_PURGED and is unrestorable onto the very server it came
* from.
* *
* The table-count assertion is not belt-and-braces: `gzip -t` passes on the * The table-count assertion is not belt-and-braces: `gzip -t` passes on the
* ~372-byte output of a mysqldump that died on its first statement, so a * ~372-byte output of a mysqldump that died on its first statement, so a
@@ -277,8 +294,12 @@ export class OpsService implements OnModuleInit {
*/ */
private dumpCommand(flags: string, db: string, out: string): string { private dumpCommand(flags: string, db: string, out: string): string {
return ( return (
`( mysqldump ${flags} --single-transaction --routines --triggers ` + `DUMP=mysqldump; GTID=; ` +
`--no-tablespaces --set-gtid-purged=OFF ${db} | gzip -c > ${out} && ` + `case "$(mysqldump --help 2>/dev/null || true)" in ` +
`*set-gtid-purged*) GTID=--set-gtid-purged=OFF;; ` +
`*) command -v mariadb-dump >/dev/null 2>&1 && DUMP=mariadb-dump;; esac; ` +
`( $DUMP ${flags} --single-transaction --routines --triggers ` +
`--no-tablespaces $GTID ${db} | gzip -c > ${out} && ` +
`gzip -t ${out} && ` + `gzip -t ${out} && ` +
`TABLAS=$(gunzip -c ${out} | grep -c 'CREATE TABLE') && ` + `TABLAS=$(gunzip -c ${out} | grep -c 'CREATE TABLE') && ` +
`echo "tablas capturadas: $TABLAS" && ` + `echo "tablas capturadas: $TABLAS" && ` +
+13 -4
View File
@@ -294,10 +294,19 @@ a warning, which is what local development wants.
Two more things the panel's dumps now do, for the same reasons the pre-migrate Two more things the panel's dumps now do, for the same reasons the pre-migrate
backup does them (see `deploy/scripts/pre-migrate-backup.mjs`): backup does them (see `deploy/scripts/pre-migrate-backup.mjs`):
- **`--set-gtid-purged=OFF`.** galactus is the replication *source* with GTID - **`--set-gtid-purged=OFF`, but only when the dumper supports it.** galactus is
on, so without this every dump embeds `SET @@GLOBAL.GTID_PURGED` and cannot be the replication *source* with GTID on, so on a MySQL client this flag is what
restored onto the server it came from — which is precisely what the restore keeps every dump from embedding `SET @@GLOBAL.GTID_PURGED` and becoming
screen exists to do. unrestorable onto the server it came from — which is precisely what the
restore screen exists to do. The panel, however, dumps from *inside the API
container*, where Alpine's `mysql-client` is MariaDB's: there `mysqldump` is a
shim over `mariadb-dump`, the flag does not exist, and passing it failed every
backup with `mysqldump: unknown variable 'set-gtid-purged=OFF'`. So the panel
probes `mysqldump --help` and passes the flag only if it is advertised,
invoking `mariadb-dump` directly otherwise (MariaDB writes no GTID state
unless asked with `--gtid`, so there is nothing to suppress). The pre-migrate
backup keeps the flag unconditionally — it runs in a real `mysql:8.4` image,
not in the API container.
- **`set -o pipefail` and a `CREATE TABLE` count.** `mysqldump | gzip` reports - **`set -o pipefail` and a `CREATE TABLE` count.** `mysqldump | gzip` reports
gzip's exit status, and a `mysqldump` that dies on its first statement still gzip's exit status, and a `mysqldump` that dies on its first statement still
produces a ~372-byte perfectly valid archive that passes `gzip -t`. Without produces a ~372-byte perfectly valid archive that passes `gzip -t`. Without