#!/usr/bin/env node /** * Take a mysqldump immediately before a deploy runs `prisma migrate deploy`. * * Why it is done THIS way and not with a plain `mysqldump` on the CI runner: * a dump is only useful if a human can restore it, and the only restore path * this platform has is the "Operaciones" admin screen, which lists and replays * whatever `*.sql.gz` files sit in the API container's BACKUP_DIR volume * (apps/api/src/ops/ops.service.ts — listBackups / RESTORE). A dump written on * the runner would land nowhere anybody can reach. So we drive the dump INSIDE * the still-running old API container, via Portainer's Docker API proxy: the * container already has mysql-client baked in (docker/api.Dockerfile), and the * file lands in the exact directory the restore UI reads. * * It must therefore run BEFORE the app stack is re-applied, while the previous * container is still up. * * Required env: * PORTAINER_URL https://:9443 * PORTAINER_API_KEY Portainer access token * PORTAINER_ENDPOINT_ID numeric endpoint id (galactus = 3) * DATABASE_URL mysql://user:pass@host:port/db * BACKUP_TAG label for the filename, e.g. the deployed tag * Optional env: * ALLOW_MISSING_CONTAINER=true exit 0 when no API container exists yet * (first-ever deploy — nothing to back up) * API_CONTAINER_LABEL=io.jorgecuadros.role=api * EXEC_TIMEOUT_SECONDS=1800 * * TLS: Portainer here uses a self-signed certificate. The caller is expected to * set NODE_TLS_REJECT_UNAUTHORIZED=0 for this step; see the workflow. That * disables verification for the whole process, so nothing else should run in it. */ const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, ""); const API_KEY = required("PORTAINER_API_KEY"); const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID"); const DATABASE_URL = required("DATABASE_URL"); const BACKUP_TAG = required("BACKUP_TAG"); const CONTAINER_LABEL = process.env.API_CONTAINER_LABEL ?? "io.jorgecuadros.role=api"; const ALLOW_MISSING = process.env.ALLOW_MISSING_CONTAINER === "true"; const TIMEOUT_MS = Number(process.env.EXEC_TIMEOUT_SECONDS ?? 1800) * 1000; function required(name) { const v = process.env[name]; if (!v) { console.error(`missing required env: ${name}`); process.exit(1); } return v; } const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`; async function docker(path, init = {}) { const res = await fetch(`${DOCKER}${path}`, { ...init, headers: { "X-API-Key": API_KEY, ...(init.body ? { "Content-Type": "application/json" } : {}), ...(init.headers ?? {}), }, }); const text = await res.text(); if (!res.ok) { throw new Error(`docker ${path} -> ${res.status} ${text.slice(0, 400)}`); } return text ? JSON.parse(text) : null; } /** Single-quote for `sh -c`, the same discipline ops.service.ts uses. */ function shq(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } function parseDbUrl(raw) { 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(/^\//, ""), }; } /** Matches ops.service.ts's own naming: ISO, colons and dots flattened. */ function timestamp() { return new Date() .toISOString() .replace(/[:.]/g, "-") .replace("T", "_") .slice(0, 19); } /** * ops.service.ts refuses to restore any name outside this character set, so a * file we write with, say, a `+` in the tag would be permanently unrestorable * through the UI. Sanitise before writing, not after. */ function safeTag(tag) { return tag.replace(/[^A-Za-z0-9._-]/g, "-"); } async function findApiContainer() { const [key, value] = CONTAINER_LABEL.split("="); const filters = encodeURIComponent( JSON.stringify({ label: [`${key}=${value}`], status: ["running"] }), ); const list = await docker(`/containers/json?filters=${filters}`); return list.length ? list[0] : null; } /** * Run a command in the container and return its exit code. Detach:true keeps * this to plain HTTP — a non-detached exec start hijacks the connection into a * raw stream, which fetch cannot read. The cost is that we get no stdout, so * every check below has to be expressed as an exit code. */ async function execInContainer(containerId, cmd, env = []) { const created = await docker(`/containers/${containerId}/exec`, { method: "POST", body: JSON.stringify({ AttachStdout: false, AttachStderr: false, Tty: false, Env: env, Cmd: ["sh", "-c", cmd], }), }); await docker(`/exec/${created.Id}/start`, { method: "POST", body: JSON.stringify({ Detach: true, Tty: false }), }); const deadline = Date.now() + TIMEOUT_MS; for (;;) { const info = await docker(`/exec/${created.Id}/json`); if (!info.Running) return info.ExitCode ?? 1; if (Date.now() > deadline) { throw new Error(`exec timed out after ${TIMEOUT_MS / 1000}s`); } await new Promise((r) => setTimeout(r, 3000)); } } /** * Run a SHORT command and return its combined output. * * Detached exec cannot report why anything failed, which once reduced a real * failure to the single line "mysqldump exited 2" and cost a manual * reproduction on the host to discover it was a missing auth plugin. Tty:true * makes the start response a plain (non-multiplexed) stream that can just be * read, at the cost of holding the connection open — fine for reading a small * error file, which is why the dump itself still runs detached. */ async function execCapture(containerId, cmd) { const created = await docker(`/containers/${containerId}/exec`, { method: "POST", body: JSON.stringify({ AttachStdout: true, AttachStderr: true, Tty: true, Cmd: ["sh", "-c", cmd], }), }); const res = await fetch(`${DOCKER}/exec/${created.Id}/start`, { method: "POST", headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ Detach: false, Tty: true }), }); return (await res.text()).trim(); } async function main() { const container = await findApiContainer(); if (!container) { const message = `no running container matching label ${CONTAINER_LABEL}`; if (ALLOW_MISSING) { console.warn(`skipping pre-migrate backup: ${message}`); return; } throw new Error( `${message} — pass bootstrap=true only if this is the first deploy and ` + `there is genuinely no data to lose`, ); } const conn = parseDbUrl(DATABASE_URL); const file = `pre-migrate-${safeTag(BACKUP_TAG)}-${timestamp()}.sql.gz`; const out = `/data/backups/${file}`; console.log(`container : ${container.Id.slice(0, 12)}`); console.log(`database : ${conn.user}@${conn.host}:${conn.port}/${conn.database}`); console.log(`writing : ${out}`); // Password via MYSQL_PWD in the exec env, never on the command line — argv is // world-readable through `ps` inside the container. const flags = `--host=${conn.host} --port=${conn.port} --user=${shq(conn.user)}`; const errFile = "/tmp/pre-migrate-backup.err"; const dump = `set -o pipefail; mysqldump ${flags} --single-transaction --routines ` + `--triggers --no-tablespaces ${shq(conn.database)} 2>${errFile} ` + `| gzip -c > ${shq(out)}`; const code = await execInContainer(container.Id, dump, [ `MYSQL_PWD=${conn.password}`, ]); if (code !== 0) { // An exit code alone is not actionable — surface what mysqldump actually // said. Leave the truncated file behind for inspection, but never let the // deploy proceed believing it has a restore point. const stderr = await execCapture( container.Id, `tail -20 ${errFile} 2>/dev/null`, ); throw new Error( `mysqldump exited ${code} — refusing to migrate` + (stderr ? `\n--- mysqldump stderr ---\n${stderr}` : ""), ); } // Detached exec gives no stdout, so prove the artefact separately: non-empty // file, and gzip that actually decompresses. A dump that fails mid-stream can // still leave a plausible-looking file. const verify = await execInContainer( container.Id, `test -s ${shq(out)} && gzip -t ${shq(out)}`, ); if (verify !== 0) { throw new Error(`backup ${file} is empty or corrupt (check exited ${verify})`); } console.log(`ok: ${file} written and verified in the API backup volume`); } main().catch((err) => { console.error(`pre-migrate backup FAILED: ${err.message}`); process.exit(1); });