Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
254 lines
8.2 KiB
TypeScript
254 lines
8.2 KiB
TypeScript
import { execFileSync, spawn } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
import { createServer } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
// Backup schedules, end to end: manifest -> `cast diff` -> what it prints and
|
|
// what it exits with. The unit tests prove each half (coolify.ts parses the
|
|
// route's body; computeDiff compares it; the executor writes it) — this proves
|
|
// they are wired to each other through the real binary, which is the half a
|
|
// type checker cannot see.
|
|
//
|
|
// The case that matters most is the third one. Before #51, a live database with
|
|
// NO backup schedule and a manifest that declared one produced a CLEAN diff:
|
|
// the field was never read, so the drift did not exist. A `--full` diff gating a
|
|
// production cutover passed on an unbacked-up production database.
|
|
|
|
let recipient: string;
|
|
let keyFile: string;
|
|
|
|
beforeAll(() => {
|
|
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
|
keyFile = join(dir, "age.key");
|
|
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
|
recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
});
|
|
|
|
type Stub = { url: string; close: () => Promise<void> };
|
|
const stubs: Stub[] = [];
|
|
|
|
// `backups` is the knob: what GET /databases/db-1/backups answers. The live
|
|
// payload is otherwise a byte-for-byte match for the manifest below, so anything
|
|
// the diff reports is the backup schedule and nothing else.
|
|
//
|
|
// an array -> Coolify's real answer shape (raw ScheduledDatabaseBackup rows)
|
|
// "boom" -> a 500, i.e. cast asked and could not be answered
|
|
async function stubCoolify(backups: unknown[] | "boom"): Promise<Stub> {
|
|
const server = createServer((req, res) => {
|
|
const path = (req.url ?? "").replace("/api/v1", "");
|
|
const json = (body: unknown) => {
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(JSON.stringify(body));
|
|
};
|
|
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
|
|
if (path === "/projects") return json([{ uuid: "p1", name: "incubator" }]);
|
|
if (path === "/projects/p1/staging")
|
|
return json({
|
|
postgresqls: [
|
|
{
|
|
name: "postgres",
|
|
uuid: "db-1",
|
|
database_type: "standalone-postgresql",
|
|
image: "postgres:17-alpine",
|
|
destination_id: 1,
|
|
},
|
|
],
|
|
});
|
|
if (path === "/databases/db-1/backups") {
|
|
if (backups === "boom") {
|
|
res.writeHead(500);
|
|
return res.end("boom");
|
|
}
|
|
return json(backups);
|
|
}
|
|
res.writeHead(404);
|
|
res.end("{}");
|
|
});
|
|
await new Promise<void>((r) => {
|
|
server.listen(0, "127.0.0.1", r);
|
|
});
|
|
const stub: Stub = {
|
|
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
|
close: () =>
|
|
new Promise<void>((r) => {
|
|
server.close(() => r());
|
|
}),
|
|
};
|
|
stubs.push(stub);
|
|
return stub;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(stubs.splice(0).map((s) => s.close()));
|
|
});
|
|
|
|
// A row as Coolify serializes one (DatabasesController@database_backup_details_uuid
|
|
// returns raw Eloquent rows, v4.1.2).
|
|
const row = (over: Record<string, unknown> = {}) => ({
|
|
uuid: "sched-1",
|
|
enabled: true,
|
|
save_s3: true,
|
|
frequency: "0 3 * * *",
|
|
database_backup_retention_amount_locally: 7,
|
|
...over,
|
|
});
|
|
|
|
const MANIFEST = `project: incubator
|
|
environments:
|
|
staging:
|
|
applications: {}
|
|
databases:
|
|
postgres:
|
|
type: postgresql
|
|
version: "17"
|
|
backup: { frequency: "0 3 * * *", retention: 7 }
|
|
`;
|
|
|
|
function fixture(url: string) {
|
|
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
|
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
|
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
|
|
|
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
|
mkdirSync(join(state, "secrets"));
|
|
writeFileSync(
|
|
join(state, ".coolify.env"),
|
|
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
|
);
|
|
execFileSync("age", ["-r", recipient, "-o", "incubator.staging.env.age"], {
|
|
input: "\n",
|
|
cwd: join(state, "secrets"),
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
writeFileSync(
|
|
join(state, "environments.yaml"),
|
|
[
|
|
"environments:",
|
|
" staging:",
|
|
" server: shared-box",
|
|
" team: { id: 0, name: Root Team }",
|
|
" s3_destination: s3-abc",
|
|
"github_apps:",
|
|
" incubator: hdb-coolify",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
return { checkout, state };
|
|
}
|
|
|
|
function run(f: {
|
|
checkout: string;
|
|
state: string;
|
|
}): Promise<{ code: number; output: string }> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(
|
|
"node",
|
|
[
|
|
"dist/cli.js",
|
|
"diff",
|
|
"heavy-duty/incubator",
|
|
"--env",
|
|
"staging",
|
|
"--path",
|
|
f.checkout,
|
|
"--state",
|
|
f.state,
|
|
],
|
|
{
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: { ...process.env, CAST_AGE_KEY_FILE_STAGING: keyFile },
|
|
},
|
|
);
|
|
let output = "";
|
|
child.stdout.on("data", (d) => {
|
|
output += String(d);
|
|
});
|
|
child.stderr.on("data", (d) => {
|
|
output += String(d);
|
|
});
|
|
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
|
});
|
|
}
|
|
|
|
describe("cast diff — backup schedules (#51)", () => {
|
|
it("is clean when the live schedule matches the manifest", async () => {
|
|
const r = await run(fixture((await stubCoolify([row()])).url));
|
|
expect(r.code).toBe(0);
|
|
expect(r.output).toContain("clean");
|
|
// Compared, so it says nothing. Silence here is now EARNED, which is the
|
|
// whole difference: before #51 it was silence about a read never made.
|
|
expect(r.output).not.toMatch(/NOT compared/);
|
|
});
|
|
|
|
// The defect, end to end.
|
|
it("reports drift when the live database has no schedule at all", async () => {
|
|
const r = await run(fixture((await stubCoolify([])).url));
|
|
expect(r.code).toBe(1); // this exact run used to exit 0
|
|
expect(r.output).toContain("update database postgres");
|
|
expect(r.output).toMatch(/backup:/);
|
|
expect(r.output).toContain('"frequency":"0 3 * * *"');
|
|
});
|
|
|
|
it("reports drift when the live schedule differs", async () => {
|
|
const r = await run(
|
|
fixture(
|
|
(
|
|
await stubCoolify([
|
|
row({
|
|
frequency: "0 9 * * *",
|
|
database_backup_retention_amount_locally: 2,
|
|
}),
|
|
])
|
|
).url,
|
|
),
|
|
);
|
|
expect(r.code).toBe(1);
|
|
expect(r.output).toMatch(/backup:/);
|
|
});
|
|
|
|
it("reports drift when the schedule exists but is switched off", async () => {
|
|
const r = await run(
|
|
fixture((await stubCoolify([row({ enabled: false })])).url),
|
|
);
|
|
// A disabled schedule backs nothing up. It must not read as clean.
|
|
expect(r.code).toBe(1);
|
|
expect(r.output).toMatch(/backup:/);
|
|
});
|
|
|
|
// The honest fallback. cast asked, could not be answered, and says so — on a
|
|
// run it still calls clean, because an absence of evidence is not evidence of
|
|
// drift. What it must never do is stay quiet and let the clean line imply a
|
|
// backed-up database.
|
|
it("says 'declared, NOT compared' out loud when the read fails", async () => {
|
|
const r = await run(fixture((await stubCoolify("boom")).url));
|
|
expect(r.output).toContain(
|
|
"backup schedule for database postgres declared, NOT compared — verify in the Coolify UI",
|
|
);
|
|
// Not invented drift: cast read nothing, so it claims nothing.
|
|
expect(r.output).not.toContain("update database postgres");
|
|
expect(r.code).toBe(0);
|
|
});
|
|
|
|
// The same failure to read, but from a body cast does not recognize rather
|
|
// than a transport error — including the literal placeholder the vendored
|
|
// OpenAPI documents for this route.
|
|
it("says 'NOT compared' on a body it cannot recognize, rather than guessing", async () => {
|
|
const r = await run(
|
|
fixture(
|
|
(
|
|
await stubCoolify([
|
|
"Content is very complex. Will be implemented later.",
|
|
])
|
|
).url,
|
|
),
|
|
);
|
|
expect(r.output).toMatch(/NOT compared/);
|
|
expect(r.output).not.toContain("update database postgres");
|
|
expect(r.code).toBe(0);
|
|
});
|
|
});
|