fix(ops): run backups as an admin login, and stop recording failed dumps as good

The Operaciones panel (backup, restore, sync, re-import) shelled out to
mysqldump as the application user, parsed straight out of DATABASE_URL.
`--single-transaction` issues FLUSH TABLES, which needs the global RELOAD
privilege, and the app user is granted only ALL ON jorgecuadros.* plus
USAGE ON *.*. BACKUP failed outright; SYNC and REIMPORT failed with it,
since both take a safety backup first.

An admin credential is now supplied out of band via OPS_DB_ADMIN_USER /
OPS_DB_ADMIN_PASSWORD, mirroring what deploy/scripts/pre-migrate-backup.mjs
already does, rather than permanently elevating the user the API serves
requests as. Host, port and database still come from DATABASE_URL, so the
override can only change who logs in, never which server. Unset, it falls
back to the DATABASE_URL credentials and warns — local development is
unaffected.

Two defects in the dumps themselves, both shared with the deploy backup
before it was rewritten:

- No --set-gtid-purged=OFF. The production server is the replication source
  with GTID on, so every dump embedded SET @@GLOBAL.GTID_PURGED and was
  unrestorable onto the server it came from — the one thing the restore
  screen is for.

- The pipeline's exit status was gzip's, and gzip succeeded. A mysqldump
  that died on its first statement left a small, perfectly valid archive
  that the job recorded as SUCCESS and the restore screen listed as an
  ordinary restore point. Dumps now run under `set -o pipefail`, assert a
  CREATE TABLE count, and delete their own output on failure. Verified with
  a stubbed mysqldump: a failing dump exits 1, surfaces the real error,
  removes the partial file, and — critically — stops SYNC/REIMPORT before
  the ETL touches anything.

Restores gained pipefail too: a corrupt archive made gunzip fail while
mysql, fed a truncated stream, could still exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:21:00 -07:00
co-authored by Claude Opus 5
parent d5ebb86cae
commit 30dfc7dc3e
7 changed files with 154 additions and 7 deletions
+89 -7
View File
@@ -31,6 +31,14 @@ export const INGEST_FILES = [
] as const;
export type IngestName = (typeof INGEST_FILES)[number];
/**
* Prefix for every command containing a pipe. Without it the exit status of
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
* like a successful job. Both Alpine's busybox ash (the API image) and macOS
* `sh` (dev) support it; POSIX does not require it, so `sh -c` is the contract.
*/
const PIPEFAIL = "set -o pipefail; ";
interface MysqlConn {
host: string;
port: string;
@@ -175,7 +183,7 @@ export class OpsService implements OnModuleInit {
);
}
const conn = this.parseDbUrl();
const conn = this.opsConn();
const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
const job = await this.prisma.opsJob.create({
@@ -207,6 +215,37 @@ export class OpsService implements OnModuleInit {
};
}
/**
* The credentials mysqldump/mysql run as — deliberately NOT the application
* user. `--single-transaction` issues FLUSH TABLES, which needs the global
* RELOAD privilege, and the app user is granted only `ALL ON jorgecuadros.*`
* plus `USAGE ON *.*`; `--skip-lock-tables` does not avoid it. A restore of a
* dump taken before --set-gtid-purged=OFF likewise needs SUPER to replay its
* SET @@GLOBAL.GTID_PURGED. So an admin credential is supplied out of band
* rather than elevating the runtime user for the sake of one admin screen —
* the same choice deploy/scripts/pre-migrate-backup.mjs makes.
*
* Host, port and database always come from DATABASE_URL: the ops user is a
* different login on the SAME server, never a way to point at another one.
*
* With the vars unset this falls back to the DATABASE_URL credentials, which
* is what local development wants — a dev MySQL grants the app user far more.
*/
private opsConn(): MysqlConn {
const conn = this.parseDbUrl();
const user = process.env.OPS_DB_ADMIN_USER;
const password = process.env.OPS_DB_ADMIN_PASSWORD;
if (!user || !password) {
this.logger.warn(
"OPS_DB_ADMIN_USER/OPS_DB_ADMIN_PASSWORD no configuradas; " +
`usando el usuario de la aplicación (${conn.user}) para mysqldump. ` +
"En producción esto falla por falta del privilegio RELOAD.",
);
return conn;
}
return { ...conn, user, password };
}
/** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in
* the child env, never on the command line (which would leak via `ps`). */
private connFlags(c: MysqlConn): string {
@@ -217,6 +256,37 @@ export class OpsService implements OnModuleInit {
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
}
/**
* One hardened mysqldump, shared by BACKUP and by the safety backups SYNC and
* REIMPORT take first. Kept byte-for-byte in spirit with the dump in
* deploy/scripts/pre-migrate-backup.mjs — the two write into the same volume
* and both are listed as restore points by this same screen.
*
* --set-gtid-purged=OFF: 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
* ~372-byte output of a mysqldump that died on its first statement, so a
* failed dump would otherwise be recorded as a successful backup. (`set -o
* pipefail` is set by the caller for the same reason — without it the exit
* status of the pipeline is gzip's, and gzip succeeded.)
*
* A failed attempt deletes its own output, so a truncated file never appears
* in the restore list looking like an ordinary restore point.
*/
private dumpCommand(flags: string, db: string, out: string): string {
return (
`( mysqldump ${flags} --single-transaction --routines --triggers ` +
`--no-tablespaces --set-gtid-purged=OFF ${db} | gzip -c > ${out} && ` +
`gzip -t ${out} && ` +
`TABLAS=$(gunzip -c ${out} | grep -c 'CREATE TABLE') && ` +
`echo "tablas capturadas: $TABLAS" && ` +
`[ "$TABLAS" -ge 1 ] ) || ` +
`{ rm -f ${out}; echo 'respaldo incompleto eliminado'; exit 1; }`
);
}
private async buildCommand(
kind: OpsJobKind,
params: Record<string, unknown>,
@@ -229,7 +299,7 @@ export class OpsService implements OnModuleInit {
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
const out = shq(path.join(this.backupDir, file));
return {
cmd: `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out}`,
cmd: `${PIPEFAIL}${this.dumpCommand(flags, db, out)}`,
resolvedParams: { file },
};
}
@@ -241,7 +311,10 @@ export class OpsService implements OnModuleInit {
throw new NotFoundException(`Respaldo no encontrado: ${name}`);
});
return {
cmd: `gunzip -c ${shq(full)} | mysql ${flags} ${db}`,
// pipefail matters here too: a corrupt archive makes gunzip fail while
// mysql, fed a truncated stream, can still exit 0 — a restore that
// reported success having replayed only part of the dump.
cmd: `${PIPEFAIL}gunzip -c ${shq(full)} | mysql ${flags} ${db}`,
resolvedParams: { file: name },
};
}
@@ -252,8 +325,8 @@ export class OpsService implements OnModuleInit {
const py = await this.pythonBin();
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
const cmd =
`echo '== Respaldo de seguridad previo ==' && ` +
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
`${this.dumpCommand(flags, db, out)} && ` +
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
return { cmd, resolvedParams: { safetyBackup: file } };
@@ -266,8 +339,8 @@ export class OpsService implements OnModuleInit {
const py = await this.pythonBin();
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
const cmd =
`echo '== Respaldo de seguridad previo ==' && ` +
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
`${this.dumpCommand(flags, db, out)} && ` +
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
return { cmd, resolvedParams: { safetyBackup: file } };
@@ -287,6 +360,15 @@ export class OpsService implements OnModuleInit {
}
}
/**
* `password` is the ops credential from opsConn(), exported as MYSQL_PWD so it
* never reaches argv (which `ps` exposes to every process on the host).
*
* It does not leak into the Python ETL that SYNC and REIMPORT go on to run:
* migration/dbenv.py connects with pymysql using the credentials inside
* DATABASE_URL and never consults MYSQL_PWD. The ETL keeps running as the
* application user, which is what it should be doing.
*/
private run(jobId: string, cmd: string, password: string): void {
const child = spawn("sh", ["-c", cmd], {
cwd: this.migrationDir,