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>
468 lines
17 KiB
TypeScript
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, `'\\''`)}'`;
|
|
}
|