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
+8
View File
@@ -4,6 +4,14 @@ SESSION_SECRET=change-me-to-a-random-string
WEB_ORIGIN=http://localhost:3000 WEB_ORIGIN=http://localhost:3000
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001 NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
# Login the "Operaciones" screen runs mysqldump/mysql as. Optional locally: when
# unset it falls back to the DATABASE_URL credentials, which a dev MySQL usually
# grants enough for. Required in any deployment, where the application user has
# only ALL ON jorgecuadros.* and mysqldump --single-transaction needs the global
# RELOAD privilege. Host/port/database always come from DATABASE_URL.
OPS_DB_ADMIN_USER=
OPS_DB_ADMIN_PASSWORD=
# Company info — printed in the header of every report (PDF + browser # Company info — printed in the header of every report (PDF + browser
# print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is # print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is
# optional; when unset the API falls back to apps/api/assets/company_logo.png. # optional; when unset the API falls back to apps/api/assets/company_logo.png.
+2
View File
@@ -257,6 +257,8 @@ jobs:
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}", "DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
"SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}", "SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}",
"SESSION_COOKIE_SECURE": "false", "SESSION_COOKIE_SECURE": "false",
"OPS_DB_ADMIN_USER": "root",
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
} }
+2
View File
@@ -264,6 +264,8 @@ jobs:
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}", "S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
"DATABASE_URL": "${{ secrets.DATABASE_URL }}", "DATABASE_URL": "${{ secrets.DATABASE_URL }}",
"SESSION_SECRET": "${{ secrets.SESSION_SECRET }}", "SESSION_SECRET": "${{ secrets.SESSION_SECRET }}",
"OPS_DB_ADMIN_USER": "root",
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
} }
+89 -7
View File
@@ -31,6 +31,14 @@ export const INGEST_FILES = [
] as const; ] as const;
export type IngestName = (typeof INGEST_FILES)[number]; 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 { interface MysqlConn {
host: string; host: string;
port: 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 { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
const job = await this.prisma.opsJob.create({ 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 /** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in
* the child env, never on the command line (which would leak via `ps`). */ * the child env, never on the command line (which would leak via `ps`). */
private connFlags(c: MysqlConn): string { 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); 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( private async buildCommand(
kind: OpsJobKind, kind: OpsJobKind,
params: Record<string, unknown>, params: Record<string, unknown>,
@@ -229,7 +299,7 @@ export class OpsService implements OnModuleInit {
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`; const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
const out = shq(path.join(this.backupDir, file)); const out = shq(path.join(this.backupDir, file));
return { return {
cmd: `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out}`, cmd: `${PIPEFAIL}${this.dumpCommand(flags, db, out)}`,
resolvedParams: { file }, resolvedParams: { file },
}; };
} }
@@ -241,7 +311,10 @@ export class OpsService implements OnModuleInit {
throw new NotFoundException(`Respaldo no encontrado: ${name}`); throw new NotFoundException(`Respaldo no encontrado: ${name}`);
}); });
return { 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 }, resolvedParams: { file: name },
}; };
} }
@@ -252,8 +325,8 @@ export class OpsService implements OnModuleInit {
const py = await this.pythonBin(); const py = await this.pythonBin();
const runAll = shq(path.join(this.migrationDir, "run_all.py")); const runAll = shq(path.join(this.migrationDir, "run_all.py"));
const cmd = const cmd =
`echo '== Respaldo de seguridad previo ==' && ` + `${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` + `${this.dumpCommand(flags, db, out)} && ` +
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` + `echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`; `${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
return { cmd, resolvedParams: { safetyBackup: file } }; return { cmd, resolvedParams: { safetyBackup: file } };
@@ -266,8 +339,8 @@ export class OpsService implements OnModuleInit {
const py = await this.pythonBin(); const py = await this.pythonBin();
const runAll = shq(path.join(this.migrationDir, "run_all.py")); const runAll = shq(path.join(this.migrationDir, "run_all.py"));
const cmd = const cmd =
`echo '== Respaldo de seguridad previo ==' && ` + `${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` + `${this.dumpCommand(flags, db, out)} && ` +
`echo '== Reimportación desde carpeta de ingesta ==' && ` + `echo '== Reimportación desde carpeta de ingesta ==' && ` +
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`; `${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
return { cmd, resolvedParams: { safetyBackup: file } }; 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 { private run(jobId: string, cmd: string, password: string): void {
const child = spawn("sh", ["-c", cmd], { const child = spawn("sh", ["-c", cmd], {
cwd: this.migrationDir, cwd: this.migrationDir,
@@ -61,6 +61,14 @@ services:
INGEST_DIR: /data/ingest INGEST_DIR: /data/ingest
BACKUP_DIR: /data/backups BACKUP_DIR: /data/backups
MIGRATION_ENV: prod MIGRATION_ENV: prod
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
# application user: --single-transaction needs the global RELOAD privilege
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
# and re-import fails without this. Host/port/database still come from
# DATABASE_URL — this only changes who logs in. See opsConn() in
# apps/api/src/ops/ops.service.ts.
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set} S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents} S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
+8
View File
@@ -41,6 +41,14 @@ services:
INGEST_DIR: /data/ingest INGEST_DIR: /data/ingest
BACKUP_DIR: /data/backups BACKUP_DIR: /data/backups
MIGRATION_ENV: prod MIGRATION_ENV: prod
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
# application user: --single-transaction needs the global RELOAD privilege
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
# and re-import fails without this. Host/port/database still come from
# DATABASE_URL — this only changes who logs in. See opsConn() in
# apps/api/src/ops/ops.service.ts.
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
# Object storage — internal endpoint the API (server-side) uses to reach # Object storage — internal endpoint the API (server-side) uses to reach
# the minio stack. Not browser-facing (downloads proxy through the API). # the minio stack. Not browser-facing (downloads proxy through the API).
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set} S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
+37
View File
@@ -253,6 +253,43 @@ had only ever been exercised with the API running on a developer machine, where
the Oracle client is installed, which is why this went unnoticed until the the Oracle client is installed, which is why this went unnoticed until the
first containerised deploy. first containerised deploy.
## The Operaciones panel needs its own database login
The panel's four jobs all shell out to `mysqldump`/`mysql`, and they cannot do
so as the application user. `mysqldump --single-transaction` issues
`FLUSH TABLES`, which requires the **global** `RELOAD` privilege; the MySQL
image grants the app user only `ALL PRIVILEGES ON jorgecuadros.*` plus
`USAGE ON *.*`. `--skip-lock-tables` does not avoid it. BACKUP therefore failed
outright, and SYNC and RE-IMPORT with it, because both take a safety backup
first.
The API is given an admin login out of band rather than permanently elevating
the user it serves requests as:
```
OPS_DB_ADMIN_USER=root
OPS_DB_ADMIN_PASSWORD=<MYSQL_ROOT_PASSWORD>
```
Both deploy workflows pass these into the app stack from the existing
`MYSQL_ROOT_PASSWORD` secret. Host, port and database still come from
`DATABASE_URL` — the override changes *who logs in*, never *which server*. With
the pair unset the service falls back to the `DATABASE_URL` credentials and logs
a warning, which is what local development wants.
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`):
- **`--set-gtid-purged=OFF`.** galactus is the replication *source* with GTID
on, so without this every dump embeds `SET @@GLOBAL.GTID_PURGED` and cannot be
restored onto the server it came from — which is precisely what the restore
screen exists to do.
- **`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
produces a ~372-byte perfectly valid archive that passes `gzip -t`. Without
both checks a failed backup was recorded as a successful one and listed as an
ordinary restore point. A dump that fails now deletes its own output.
## Known caveats in the deploy path ## Known caveats in the deploy path
- The pre-migrate backup step sets `NODE_TLS_REJECT_UNAUTHORIZED=0` because - The pre-migrate backup step sets `NODE_TLS_REJECT_UNAUTHORIZED=0` because