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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user