feat: diff and apply a database's backup schedule (#51)
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.
2026-07-14 22:37:01 +00:00
|
|
|
import { execFileSync, spawn } from "node:child_process";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
feat: diff and apply a database's backup schedule (#51)
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.
2026-07-14 22:37:01 +00:00
|
|
|
import { createServer } from "node:http";
|
|
|
|
|
import type { AddressInfo } from "node:net";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { tmp } from "./helpers/tmp.js";
|
feat: diff and apply a database's backup schedule (#51)
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.
2026-07-14 22:37:01 +00:00
|
|
|
|
|
|
|
|
// 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(() => {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const dir = tmp("cast-age-");
|
feat: diff and apply a database's backup schedule (#51)
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.
2026-07-14 22:37:01 +00:00
|
|
|
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) {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const checkout = tmp("cast-co-");
|
feat: diff and apply a database's backup schedule (#51)
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.
2026-07-14 22:37:01 +00:00
|
|
|
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
|
|
|
|
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
|
|
|
|
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const state = tmp("cast-state-");
|
feat: diff and apply a database's backup schedule (#51)
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.
2026-07-14 22:37:01 +00:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|