vps-health-api/backups.js
le king fu 1b619249e2
All checks were successful
CI / verification (pull_request) Successful in 36s
feat(backups): GET /backups and POST /backups/pull
Track the PostgreSQL cluster backups (la-compagnie-maximus#220) the same
way as /claude-usage: freshness is decided by the server.

- GET /backups (read token) serves { vps, lenovo }: the status.json the
  VPS script writes, rebuilt through a whitelist (closed error codes,
  rotation block included), and the last workstation pull report. Each
  block gets ageSeconds, stale and staleAfterSeconds from the server
  (BACKUPS_STALE_SECONDS, 36 h): VPS on generatedAt, workstation on the
  server-stamped receivedAt. A broken file gives a null block plus
  vpsError/lenovoError, never a 500; no_data only when both are absent.
- POST /backups/pull: fourth token kind, backups-ingest, on
  BACKUPS_INGEST_TOKEN (503 naming it when unset). Strict validation of
  { pulledAt, files, ok, error? }, server receivedAt, atomic write in
  mode 600 through writeJsonAtomic(), now shared with /claude-usage.
  Answers 204 and 413 like the two other ingest routes (the agent only
  checks for a 2xx), where the issue said 200 and 400.
- backups.js joins the Dockerfile COPY line. Tests: backups.test.js
  (101 cases), auth/writes pins extended, 6 socket cases in test-curl.sh.

Refs #58

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:43:07 -04:00

171 lines
6.3 KiB
JavaScript

// PostgreSQL backups (GET /backups, POST /backups/pull), issue #58, contract
// `schemaVersion: 1` shared with the VPS script (la-compagnie-maximus,
// scripts/backup/backup-postgres.sh) and the workstation pull agent
// (agent/pg-backup-pull/run-pg-backup-pull.sh).
//
// Pure module: no I/O, no config, no side effects. index.js wires the routes,
// reads status.json and owns the pull file.
//
// Two inputs, two rules:
// - status.json is written by the VPS script and only read here. It goes
// through a whitelist rebuild, as the Claude usage snapshot does: an
// unknown field is dropped, an invalid value is dropped (key left out),
// never coerced. Only what makes the file unusable refuses it as a whole:
// not an object, a `schemaVersion` other than 1, or no usable `generatedAt`
// (the age, hence `stale`, is computed from it).
// - the pull report is sent by the workstation agent, whose body is four
// fixed fields. It is validated strictly: any invalid or missing field is a
// 400, so a broken agent is told so instead of being half-stored.
// Error codes come from closed lists on both sides: a free-form `error` could
// carry pg_dump or rclone stderr (hosts, keys) all the way to the dashboard.
const { isIsoTimestamp } = require("./claude-usage.js");
const SCHEMA_VERSION = 1;
// The pull body is four short fields, under 120 bytes in practice.
const BACKUPS_MAX_BODY_BYTES = 1024;
// One cluster holds about ten databases; this bound is only there so a runaway
// file cannot make the response arbitrarily large.
const MAX_DATABASES = 64;
const VPS_ERROR_CODES = [
"dump_failed",
"dump_empty",
"restore_list_failed",
"invalid_name",
"encrypt_failed",
"mirror_upload_failed",
"mirror_purge_failed",
"rotation_failed",
];
const PULL_ERROR_CODES = ["pull_failed", "rotation_failed"];
// Same pattern as the VPS script's own check. A name it rejected is reported
// with `invalid_name` and may hold arbitrary text: that name is dropped here.
const DATABASE_NAME_RE = /^[a-z0-9_]{1,63}$/;
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cleanCount(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
}
function cleanBoolean(value) {
return typeof value === "boolean" ? value : undefined;
}
function cleanTimestamp(value) {
return isIsoTimestamp(value) ? value : undefined;
}
function cleanCode(value, codes) {
return codes.includes(value) ? value : undefined;
}
function cleanDatabaseName(value) {
return typeof value === "string" && DATABASE_NAME_RE.test(value) ? value : undefined;
}
// Copy `key` into `out` only when the cleaner kept a value.
function put(out, key, value) {
if (value !== undefined) out[key] = value;
}
// `null` is a legitimate value for the fields the contract declares nullable
// (a failed dump has no dumpedAt, a failed upload no uploadedAt).
function putNullable(out, key, raw, clean) {
if (raw === null) out[key] = null;
else put(out, key, clean(raw));
}
// { ok, error? } plus the given extra fields, for globals, mirror and rotation.
function sanitizeComponent(value, extra) {
if (!isPlainObject(value)) return undefined;
const out = {};
extra(out, value);
put(out, "ok", cleanBoolean(value.ok));
put(out, "error", cleanCode(value.error, VPS_ERROR_CODES));
return out;
}
function sanitizeDatabase(value) {
const out = {};
put(out, "name", cleanDatabaseName(value.name));
put(out, "sizeBytes", cleanCount(value.sizeBytes));
putNullable(out, "dumpedAt", value.dumpedAt, cleanTimestamp);
put(out, "ok", cleanBoolean(value.ok));
put(out, "error", cleanCode(value.error, VPS_ERROR_CODES));
return out;
}
function sanitizeRetention(value) {
if (!isPlainObject(value)) return undefined;
const out = {};
put(out, "local", cleanCount(value.local));
put(out, "offsite", cleanCount(value.offsite));
return out;
}
// Returns { status } or { error: <code> }, the code going out as `vpsError`.
// Idempotent: a sanitized status comes back unchanged.
function sanitizeBackupsStatus(payload) {
if (!isPlainObject(payload)) return { error: "invalid_contract" };
if (payload.schemaVersion !== SCHEMA_VERSION) return { error: "unsupported_schema" };
const generatedAt = cleanTimestamp(payload.generatedAt);
if (generatedAt === undefined) return { error: "invalid_contract" };
const status = { schemaVersion: SCHEMA_VERSION, generatedAt };
put(status, "ok", cleanBoolean(payload.ok));
put(status, "expectedCount", cleanCount(payload.expectedCount));
if (Array.isArray(payload.databases)) {
status.databases = payload.databases.filter(isPlainObject).slice(0, MAX_DATABASES).map(sanitizeDatabase);
}
put(status, "globals", sanitizeComponent(payload.globals, (out, value) => {
put(out, "sizeBytes", cleanCount(value.sizeBytes));
}));
put(status, "mirror", sanitizeComponent(payload.mirror, (out, value) => {
putNullable(out, "uploadedAt", value.uploadedAt, cleanTimestamp);
}));
put(status, "rotation", sanitizeComponent(payload.rotation, () => {}));
put(status, "retentionDays", sanitizeRetention(payload.retentionDays));
return { status };
}
// Returns { pull } or { error: <message> }. Strict: every field must be there
// and valid. Anything else in the body, a client `receivedAt` included, is
// dropped: the server stamps its own.
function validateBackupsPull(payload) {
if (!isPlainObject(payload)) return { error: "body must be a JSON object" };
if (!isIsoTimestamp(payload.pulledAt)) {
return { error: "pulledAt must be an ISO 8601 timestamp with an offset" };
}
if (cleanCount(payload.files) === undefined) {
return { error: "files must be a non-negative integer" };
}
if (typeof payload.ok !== "boolean") return { error: "ok must be a boolean" };
const pull = { pulledAt: payload.pulledAt, files: payload.files, ok: payload.ok };
if (payload.ok) {
if (payload.error !== undefined) return { error: "error is only allowed when ok is false" };
} else {
if (!PULL_ERROR_CODES.includes(payload.error)) {
return { error: `error must be one of ${PULL_ERROR_CODES.join(",")} when ok is false` };
}
pull.error = payload.error;
}
return { pull };
}
module.exports = {
sanitizeBackupsStatus,
validateBackupsPull,
BACKUPS_MAX_BODY_BYTES,
SCHEMA_VERSION,
MAX_DATABASES,
VPS_ERROR_CODES,
PULL_ERROR_CODES,
};