#!/usr/bin/env node /** * Take a mysqldump immediately before a deploy runs `prisma migrate deploy`. * * The dump runs in a DEDICATED, throwaway container built from the MySQL image, * with the API's backup volume mounted — not inside the API container. Three * reasons, each learned the hard way: * * 1. Deadlock. Dumping inside the API container makes the backup depend on * whatever toolchain that image happens to carry. When the image shipped a * MySQL client that could not authenticate, the backup failed, which blocked * the very deploy that would have replaced the broken image. The backup must * not depend on the thing being deployed. * 2. The right client. Alpine's `mysql-client` is MariaDB's and cannot perform * caching_sha2_password (MySQL 8.4's default auth). The official MySQL image * obviously can. * 3. Diagnosability. A container's logs can simply be read, whereas a detached * exec reports nothing but an exit code. * * The file still lands in the API's BACKUP_DIR volume, because the only restore * path this platform has is the "Operaciones" admin screen, which lists whatever * `*.sql.gz` sits there (apps/api/src/ops/ops.service.ts). * * It must run BEFORE the app stack is re-applied, while the old container is up * — that container is how the backup volume's name is discovered. * * 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 — host/port/db only * MYSQL_ROOT_PASSWORD the dump runs as root, see below * 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 * BACKUP_VOLUME override the auto-discovered volume name * DUMP_IMAGE default mysql:8.4 * API_CONTAINER_LABEL default io.jorgecuadros.role=api * TAILSCALE_DNS / FALLBACK_DNS / TAILNET_SUFFIX * EXEC_TIMEOUT_SECONDS default 1800 * * Why root: mysqldump --single-transaction issues FLUSH TABLES, which needs the * global RELOAD (or FLUSH_TABLES) privilege. The application user is granted * only ALL ON ``.* by the MySQL image and deliberately has no global rights, * so it cannot take a consistent dump. Backups are an administrative operation; * elevating the app's own runtime user instead would be the worse trade. * * TLS: Portainer here is self-signed; the caller sets * NODE_TLS_REJECT_UNAUTHORIZED=0 for this step. */ function required(name) { const v = process.env[name]; if (!v) { console.error(`missing required env: ${name}`); process.exit(1); } return v; } 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 ROOT_PASSWORD = required("MYSQL_ROOT_PASSWORD"); 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 DUMP_IMAGE = process.env.DUMP_IMAGE ?? "mysql:8.4"; const DNS = [ process.env.TAILSCALE_DNS ?? "100.100.100.100", process.env.FALLBACK_DNS ?? "1.1.1.1", ]; const DNS_SEARCH = [process.env.TAILNET_SUFFIX ?? "tail01aa2.ts.net"]; const TIMEOUT_MS = Number(process.env.EXEC_TIMEOUT_SECONDS ?? 1800) * 1000; 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", 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 written 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; } /** The named volume the API mounts at /data/backups — where restores look. */ function backupVolumeOf(container) { const mount = (container.Mounts ?? []).find( (m) => m.Destination === "/data/backups", ); return mount?.Name ?? null; } /** * Ensure the dump image is present. A `scope: app` deploy never touches the db * stack, so a host can legitimately be missing it — and container/create fails * with a bare 404 that reads like a Portainer problem rather than a missing * image. The image is public, so no registry auth is involved. */ async function ensureDumpImage() { const [repo, tag = "latest"] = DUMP_IMAGE.split(":"); const existing = await docker(`/images/${encodeURIComponent(DUMP_IMAGE)}/json`) .then(() => true) .catch(() => false); if (existing) return; console.log(`pulling ${DUMP_IMAGE} (not present on the host)...`); const res = await fetch( `${DOCKER}/images/create?fromImage=${encodeURIComponent(repo)}&tag=${encodeURIComponent(tag)}`, { method: "POST", headers: { "X-API-Key": API_KEY } }, ); const body = await res.text(); if (!res.ok) { throw new Error(`pull ${DUMP_IMAGE} -> HTTP ${res.status} ${body.slice(0, 300)}`); } for (const line of body.split("\n").filter((l) => l.trim())) { try { const obj = JSON.parse(line); if (obj.error) throw new Error(`pull ${DUMP_IMAGE} failed: ${obj.error}`); } catch (e) { if (e.message.startsWith("pull ")) throw e; } } } async function runDumpContainer(cmd, env) { await ensureDumpImage(); const created = await docker(`/containers/create`, { method: "POST", body: JSON.stringify({ Image: DUMP_IMAGE, Entrypoint: ["sh", "-c"], Cmd: [cmd], Env: env, HostConfig: { AutoRemove: false, // we read the logs before removing it ourselves Binds: [`${BACKUP_VOLUME}:/data/backups`], Dns: DNS, DnsSearch: DNS_SEARCH, }, }), }); const id = created.Id; try { await docker(`/containers/${id}/start`, { method: "POST" }); const deadline = Date.now() + TIMEOUT_MS; for (;;) { const info = await docker(`/containers/${id}/json`); if (!info.State.Running) { const logs = await fetch( `${DOCKER}/containers/${id}/logs?stdout=true&stderr=true&tail=40`, { headers: { "X-API-Key": API_KEY } }, ).then((r) => r.text()); // Strip Docker's 8-byte stream framing and any stray control bytes. const clean = logs .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") .trim(); return { code: info.State.ExitCode ?? 1, logs: clean }; } if (Date.now() > deadline) { throw new Error(`dump timed out after ${TIMEOUT_MS / 1000}s`); } await new Promise((r) => setTimeout(r, 3000)); } } finally { await docker(`/containers/${id}?force=true`, { method: "DELETE" }).catch( () => {}, ); } } let BACKUP_VOLUME = process.env.BACKUP_VOLUME ?? null; 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`, ); } BACKUP_VOLUME = BACKUP_VOLUME ?? backupVolumeOf(container); if (!BACKUP_VOLUME) { throw new Error( "could not determine the backup volume from the API container's mounts; " + "set BACKUP_VOLUME explicitly", ); } const conn = parseDbUrl(DATABASE_URL); const file = `pre-migrate-${safeTag(BACKUP_TAG)}-${timestamp()}.sql.gz`; const out = `/data/backups/${file}`; console.log(`database : ${conn.host}:${conn.port}/${conn.database}`); console.log(`volume : ${BACKUP_VOLUME}`); console.log(`image : ${DUMP_IMAGE}`); console.log(`writing : ${out}`); // --set-gtid-purged=OFF because this server is the replication SOURCE with // GTID on. Without it the dump embeds SET @@GLOBAL.GTID_PURGED, which makes // the file unrestorable onto the very server it came from. // // pipefail is essential: without it the exit status is gzip's, so a dump that // failed on the first statement still produces a small, perfectly valid .gz — // a "successful" backup containing nothing. // // The table count is asserted for the same reason: valid gzip is not evidence // of a usable dump. It is echoed so the log records how much was captured. // // A failed attempt deletes its own output. Otherwise every failure leaves a // truncated .sql.gz sitting in the volume, and the Operaciones restore screen // lists it as a perfectly ordinary restore point. const dump = `set -o pipefail; ` + `( mysqldump --host=${conn.host} --port=${conn.port} --user=root ` + `--single-transaction --routines --triggers --no-tablespaces ` + `--set-gtid-purged=OFF ${shq(conn.database)} | gzip -c > ${shq(out)} && ` + `gzip -t ${shq(out)} && ` + `TABLES=$(gunzip -c ${shq(out)} | grep -c 'CREATE TABLE') && ` + `echo "tables captured: $TABLES" && ` + `[ "$TABLES" -ge 1 ] ); ` + `rc=$?; ` + `if [ $rc -ne 0 ]; then rm -f ${shq(out)}; ` + `echo "removed incomplete backup ${file}"; fi; ` + `exit $rc`; const { code, logs } = await runDumpContainer(dump, [ // Password via MYSQL_PWD, never argv — argv is readable through `ps`. `MYSQL_PWD=${ROOT_PASSWORD}`, ]); if (logs) console.log(logs); if (code !== 0) { throw new Error( `dump failed (exit ${code}) — refusing to migrate. See the output above.`, ); } console.log(`ok: ${file} written and verified in ${BACKUP_VOLUME}`); } main().catch((err) => { console.error(`pre-migrate backup FAILED: ${err.message}`); process.exit(1); });