All checks were successful
CI / verification (pull_request) Successful in 36s
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>
171 lines
6.3 KiB
JavaScript
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,
|
|
};
|