Files
jorgecuadros-platform/deploy/scripts/pre-migrate-backup.mjs
T
rmancinasandClaude Opus 5 19f03198d6
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m56s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m3s
fix(docker): install the MySQL 8.4 auth plugin; report why a dump fails
The pre-migrate backup failed with "mysqldump exited 2" and nothing else.
Reproduced on the host with stderr captured:

  ERROR 1045: Plugin caching_sha2_password could not be loaded:
    /usr/lib/mariadb/plugin/caching_sha2_password.so: No such file or directory

Alpine's `mysql-client` is MariaDB's client and ships an EMPTY plugin
directory, so it cannot perform caching_sha2_password — MySQL 8.4's default and
effectively only auth method. `mariadb-connector-c` provides the plugin.

This was never about the deploy backup alone. Every mysqldump/mysql call from
the API container was broken, which means the whole Operaciones panel — backup,
restore, sync, re-import — could not work in a container. It went unnoticed
because that feature had only ever been run with the API on a developer
machine, where the Oracle client is installed. Verified after the fix: dump
exits 0, gzip valid, 31 CREATE TABLEs.

Also fixed, both found while chasing the above:

- The backup script reported an exit code and nothing else, because a detached
  exec captures no output — which is precisely why this needed a manual
  reproduction. mysqldump's stderr is now redirected to a file and read back
  through a short attached exec on failure, so the deploy log states the cause.
  Verified against live prod: the log now carries the 1045 line itself.

- Listing ONLY 100.100.100.100 as the containers' resolver costs them public
  DNS, since MagicDNS does not forward upstream unless the tailnet defines
  global nameservers. Nothing at runtime needed it, but `apk` inside the
  container stopped resolving, and anything outbound would have too. A public
  fallback resolver is now listed after MagicDNS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:52:03 -07:00

245 lines
8.5 KiB
JavaScript

#!/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://<host>: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);
});