Files
jorgecuadros-platform/apps/api/src/ops/ops.service.ts
T
rmancinasandClaude Opus 5 860d483bad
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m7s
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m7s
fix(ops): backup failed on the MariaDB client shipped in the API image
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>
2026-08-01 01:43:25 -07:00

468 lines
17 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
OnModuleInit,
} from "@nestjs/common";
import { spawn } from "node:child_process";
import { createReadStream, promises as fs } from "node:fs";
import * as path from "node:path";
import { OpsJobKind } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
/**
* Admin database operations. Everything long-running (mysqldump, mysql restore,
* the Python migration) runs as a detached child process recorded as one OpsJob
* row whose `log` is appended as the process talks; the web polls that row.
*
* Only ONE mutating job runs at a time (a RUNNING row blocks a new start) — a
* restore or re-import racing a migration would corrupt the database.
*/
/** The four legacy Access files. Uploads are allowlisted to exactly these
* names so an ingest write can never land at an arbitrary path. */
export const INGEST_FILES = [
"UTILITIES.accdb",
"SEGUROS 16.mdb",
"SEGUROS 16_be.mdb",
"SCOTHIA.mdb",
] 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;
user: string;
password: string;
database: string;
}
@Injectable()
export class OpsService implements OnModuleInit {
private readonly logger = new Logger(OpsService.name);
// Resolve from this source file so it works regardless of process.cwd()
// (the API runs from apps/api/, but the Python ETL lives at repo-root migration/).
private readonly migrationDir =
process.env.MIGRATION_DIR ??
path.resolve(__dirname, "..", "..", "..", "..", "migration");
private readonly ingestDir =
process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest");
private readonly backupDir =
process.env.BACKUP_DIR ?? path.join(this.migrationDir, "backups");
private readonly migrationEnv = process.env.MIGRATION_ENV ?? "dev";
constructor(private readonly prisma: PrismaService) {}
async onModuleInit(): Promise<void> {
await fs.mkdir(this.ingestDir, { recursive: true });
await fs.mkdir(this.backupDir, { recursive: true });
}
/* -------------------------------------------------------------- ingest */
private assertIngestName(name: string): IngestName {
if (!INGEST_FILES.includes(name as IngestName)) {
throw new BadRequestException(
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
);
}
return name as IngestName;
}
async listIngest(): Promise<
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
> {
return Promise.all(
INGEST_FILES.map(async (name) => {
try {
const st = await fs.stat(path.join(this.ingestDir, name));
return {
name,
present: true,
size: st.size,
modifiedAt: st.mtime.toISOString(),
};
} catch {
return { name, present: false, size: null, modifiedAt: null };
}
}),
);
}
async saveIngest(name: string, data: Buffer): Promise<void> {
const safe = this.assertIngestName(name);
await fs.writeFile(path.join(this.ingestDir, safe), data);
}
async deleteIngest(name: string): Promise<void> {
const safe = this.assertIngestName(name);
await fs.rm(path.join(this.ingestDir, safe), { force: true });
}
/* ------------------------------------------------------------- backups */
private assertBackupName(name: string): string {
// No path separators, must be a produced backup file.
if (!/^[A-Za-z0-9._-]+\.sql\.gz$/.test(name)) {
throw new BadRequestException("Nombre de respaldo inválido.");
}
return name;
}
async listBackups(): Promise<
{ name: string; size: number; createdAt: string }[]
> {
let names: string[];
try {
names = await fs.readdir(this.backupDir);
} catch {
return [];
}
const rows = await Promise.all(
names
.filter((n) => n.endsWith(".sql.gz"))
.map(async (name) => {
const st = await fs.stat(path.join(this.backupDir, name));
return { name, size: st.size, createdAt: st.mtime.toISOString() };
}),
);
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
backupStream(name: string) {
const safe = this.assertBackupName(name);
const full = path.join(this.backupDir, safe);
return { stream: createReadStream(full), name: safe };
}
async deleteBackup(name: string): Promise<void> {
const safe = this.assertBackupName(name);
await fs.rm(path.join(this.backupDir, safe), { force: true });
}
/* ---------------------------------------------------------------- jobs */
listJobs(limit = 20) {
return this.prisma.opsJob.findMany({
orderBy: { startedAt: "desc" },
take: limit,
});
}
async getJob(id: string) {
const job = await this.prisma.opsJob.findUnique({ where: { id } });
if (!job) throw new NotFoundException("Trabajo no encontrado.");
return job;
}
/**
* Start a mutating op. Refuses if another job is already RUNNING. Returns the
* new job row immediately; the process runs on in the background and appends
* to `log` until it exits.
*/
async startJob(
kind: OpsJobKind,
params: Record<string, unknown>,
userId: string | undefined,
) {
const running = await this.prisma.opsJob.count({ where: { status: "RUNNING" } });
if (running > 0) {
throw new ConflictException(
"Ya hay una operación en curso. Espere a que termine.",
);
}
const conn = this.opsConn();
const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
const job = await this.prisma.opsJob.create({
data: {
kind,
status: "RUNNING",
log: "",
params: resolvedParams as object,
createdById: userId,
},
});
this.run(job.id, cmd, conn.password);
return job;
}
/* --------------------------------------------------------- internals */
private parseDbUrl(): MysqlConn {
const raw = process.env.DATABASE_URL;
if (!raw) throw new BadRequestException("DATABASE_URL no está configurada.");
const u = new URL(raw);
return {
host: u.hostname,
port: u.port || "3306",
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ""),
};
}
/**
* 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 {
return `--host=${c.host} --port=${c.port} --user=${shq(c.user)}`;
}
private timestamp(): string {
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.
*
* The dumper is probed at runtime rather than assumed. This command runs
* inside the API image, whose `mysql-client` is Alpine's — i.e. MariaDB's —
* 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
* ~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 (
`DUMP=mysqldump; GTID=; ` +
`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} && ` +
`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>,
conn: MysqlConn,
): Promise<{ cmd: string; resolvedParams: Record<string, unknown> }> {
const flags = this.connFlags(conn);
const db = shq(conn.database);
if (kind === "BACKUP") {
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
const out = shq(path.join(this.backupDir, file));
return {
cmd: `${PIPEFAIL}${this.dumpCommand(flags, db, out)}`,
resolvedParams: { file },
};
}
if (kind === "RESTORE") {
const name = this.assertBackupName(String(params.file ?? ""));
const full = path.join(this.backupDir, name);
await fs.access(full).catch(() => {
throw new NotFoundException(`Respaldo no encontrado: ${name}`);
});
return {
// 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 },
};
}
if (kind === "SYNC") {
const file = `pre-sync-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
const out = shq(path.join(this.backupDir, file));
const py = await this.pythonBin();
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
const cmd =
`${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 } };
}
if (kind === "REIMPORT") {
// Safety backup first, then a full truncate+rebuild from the ingest files.
const file = `pre-reimport-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
const out = shq(path.join(this.backupDir, file));
const py = await this.pythonBin();
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
const cmd =
`${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 } };
}
throw new BadRequestException(`Operación no soportada: ${kind}`);
}
/** Prefer the migration venv python if it exists (local dev), else system. */
private async pythonBin(): Promise<string> {
const venv = path.join(this.migrationDir, ".venv", "bin", "python");
try {
await fs.access(venv);
return venv;
} catch {
return process.env.PYTHON_BIN ?? "python3";
}
}
/**
* `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,
env: {
...process.env,
MYSQL_PWD: password,
INGEST_DIR: this.ingestDir,
BACKUP_DIR: this.backupDir,
},
});
let buffer = "";
let pending = "";
let flushing = false;
let flushTimer: NodeJS.Timeout | null = null;
const flush = async () => {
if (flushing || !pending) return;
flushing = true;
const chunk = pending;
pending = "";
try {
await this.prisma.opsJob.update({
where: { id: jobId },
data: { log: { set: buffer } },
});
} catch (e) {
this.logger.warn(`ops job ${jobId} log flush failed: ${String(e)}`);
} finally {
flushing = false;
void chunk;
}
};
const onData = (d: Buffer) => {
const text = d.toString();
buffer += text;
pending += text;
if (!flushTimer) {
flushTimer = setTimeout(() => {
flushTimer = null;
void flush();
}, 1000);
}
};
child.stdout.on("data", onData);
child.stderr.on("data", onData);
const finalize = async (status: "SUCCESS" | "FAILED", tail: string) => {
if (flushTimer) clearTimeout(flushTimer);
buffer += tail;
await this.prisma.opsJob
.update({
where: { id: jobId },
data: { status, log: { set: buffer }, finishedAt: new Date() },
})
.catch((e) => this.logger.error(`ops job ${jobId} finalize failed: ${String(e)}`));
};
child.on("error", (err) => {
void finalize("FAILED", `\n[proceso no pudo iniciar] ${err.message}\n`);
});
child.on("close", (code) => {
if (code === 0) void finalize("SUCCESS", `\n[completado con éxito]\n`);
else void finalize("FAILED", `\n[terminó con código ${code}]\n`);
});
}
}
/** Single-quote a value for a POSIX shell command. */
function shq(v: string): string {
return `'${v.replace(/'/g, `'\\''`)}'`;
}