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.
This commit is contained in:
parent
bab33b1e6f
commit
d8d5cf8397
11 changed files with 1337 additions and 80 deletions
|
|
@ -506,6 +506,80 @@ Coolify says so in the same response (*"can cause routing conflicts and unpredic
|
||||||
behavior"*). Nothing in cast can send that flag, and no retry may ever set it — if it
|
behavior"*). Nothing in cast can send that flag, and no retry may ever set it — if it
|
||||||
is ever wanted, it is an explicit operator act in the UI, not a tool's decision.
|
is ever wanted, it is an explicit operator act in the UI, not a tool's decision.
|
||||||
|
|
||||||
|
## Backup schedules
|
||||||
|
|
||||||
|
A manifest database's `backup` block (`frequency`, `retention`) is a **diffed
|
||||||
|
field like any other**: compared on every run, written on create *and* on
|
||||||
|
update. Declaring `backup:` on a database that already exists starts backing it
|
||||||
|
up — which is what every reader of that manifest already assumed it did.
|
||||||
|
|
||||||
|
It did not always work that way, and the reason is worth keeping: the schedule
|
||||||
|
used to be write-only, applied inside `apply`'s create branch and then never
|
||||||
|
looked at again, because *"live Coolify state doesn't expose it back"*. That was
|
||||||
|
false. A schedule is not on the database's own `GET` — but it was never supposed
|
||||||
|
to be. It has **its own route**, which cast had been POSTing to all along and had
|
||||||
|
simply never read:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /databases/{uuid}/backups ← list a database's schedules
|
||||||
|
POST /databases/{uuid}/backups ← create
|
||||||
|
PATCH /databases/{uuid}/backups/{scheduled_backup_uuid} ← update
|
||||||
|
```
|
||||||
|
|
||||||
|
The cost of not looking was exact: a database created before its `backup:` block
|
||||||
|
was declared never got one, a schedule deleted in the UI was invisible, and the
|
||||||
|
`--full` diff that gates a production cutover passed with an unbacked-up
|
||||||
|
production database (#51).
|
||||||
|
|
||||||
|
**What the route returns.** The vendored OpenAPI documents the body as *"Content
|
||||||
|
is very complex. Will be implemented later."*, so the shape comes from the source:
|
||||||
|
`DatabasesController@database_backup_details_uuid` (v4.1.2) returns
|
||||||
|
`ScheduledDatabaseBackup::…->with('executions')->where('database_id', …)->get()`
|
||||||
|
straight to `response()->json()` — a **JSON array of raw Eloquent rows** (no API
|
||||||
|
resource, no `removeSensitiveData`), whose columns are the model's `$fillable`:
|
||||||
|
`uuid`, `enabled`, `save_s3`, `frequency`,
|
||||||
|
`database_backup_retention_amount_locally`, … plus an eager-loaded `executions`
|
||||||
|
array cast ignores. `retention` is
|
||||||
|
`database_backup_retention_amount_locally` — the same field cast has always sent
|
||||||
|
on create.
|
||||||
|
|
||||||
|
**`frequency` round-trips verbatim**, which is what makes it diffable at all: the
|
||||||
|
controller *validates* it (`validate_cron_expression`, which only returns a bool)
|
||||||
|
and then stores `$request->only($backupConfigFields)` unchanged, with no mutator
|
||||||
|
on the model. `"0 3 * * *"` reads back as `"0 3 * * *"`; the preset words
|
||||||
|
(`daily`, `weekly`, …) read back as themselves. The old "diffing it would flag
|
||||||
|
spurious drift every run" fear was a guess about a read nobody had performed.
|
||||||
|
|
||||||
|
**A disabled schedule is not a backup.** A row with `enabled: false` exists but
|
||||||
|
backs nothing up, so it is neither clean (it diffs against a declared block) nor
|
||||||
|
absent (`apply` PATCHes it, rather than adding a second schedule). Every cast
|
||||||
|
write asserts `enabled: true`.
|
||||||
|
|
||||||
|
**What is still NOT compared**, and says so on screen when it applies:
|
||||||
|
|
||||||
|
- **An unreadable answer.** If the route is unreachable, or answers a shape cast
|
||||||
|
does not recognize, cast reports `backup schedule for database <name>
|
||||||
|
declared, NOT compared — verify in the Coolify UI` and treats it as neither
|
||||||
|
drift nor clean. An absence of evidence is not evidence of drift: cast will not
|
||||||
|
invent a change it cannot see, nor certify a database it could not read. On the
|
||||||
|
write side the same read failure **raises** rather than guessing — POSTing blind
|
||||||
|
would duplicate a schedule that may already exist, and skipping is the silent
|
||||||
|
no-op this whole section exists to kill.
|
||||||
|
- **More than one schedule.** A manifest declares one; a database carrying
|
||||||
|
several is outside that vocabulary, and choosing one to compare against would
|
||||||
|
be a coin toss reported as a fact. Reported, not compared, not written.
|
||||||
|
- **The S3 target.** Coolify returns the storage as `s3_storage_id` (an int) and
|
||||||
|
takes it as a UUID, the same unmappable pair as `destination_id` (see
|
||||||
|
Placement). cast **asserts** `save_s3: true` + the environment's
|
||||||
|
`s3_destination` on every write and can never verify it afterwards.
|
||||||
|
- **An undeclared schedule.** A live schedule on a database whose manifest says
|
||||||
|
nothing about backups is left alone, unremarked — `apply` never removes.
|
||||||
|
|
||||||
|
**A backup change redeploys the database.** `apply` redeploys any resource it
|
||||||
|
mutates, and a schedule change is a mutation of the database, so changing
|
||||||
|
`frequency` restarts the container. Consistent with every other field, and worth
|
||||||
|
knowing before you edit a schedule on a live production database.
|
||||||
|
|
||||||
## Instance selection
|
## Instance selection
|
||||||
|
|
||||||
**The Coolify a command talks to is an explicit, named value** — not a property
|
**The Coolify a command talks to is an explicit, named value** — not a property
|
||||||
|
|
@ -791,8 +865,12 @@ and rebuild a *different box*. Per resource, it names what was seen and could no
|
||||||
be written: `destination_id` (which Docker network — no destinations API in 4.1.2
|
be written: `destination_id` (which Docker network — no destinations API in 4.1.2
|
||||||
to resolve it to the UUID `destination_uuid:` wants, #21), service hostnames (no
|
to resolve it to the UUID `destination_uuid:` wants, #21), service hostnames (no
|
||||||
flat `domains` on a Coolify 4.1.2 service), Basic Auth / custom Traefik labels,
|
flat `domains` on a Coolify 4.1.2 service), Basic Auth / custom Traefik labels,
|
||||||
build and deploy command overrides, backup schedules (not exposed on a database's
|
build and deploy command overrides, backup schedules (**a rebuild has no backups
|
||||||
GET — **a rebuild has no backups until you declare them**), database kinds cast
|
until you declare them** — *not* because they cannot be read, which is what this
|
||||||
|
line used to say and #51 disproved, but because `inventory --emit-draft` has not
|
||||||
|
yet been taught to read them: `GET /databases/{uuid}/backups` answers, and `diff`
|
||||||
|
and `apply` now use it. Until the draft path does too, a blueprint still omits
|
||||||
|
them and still says so), database kinds cast
|
||||||
does not model (MySQL, MariaDB, MongoDB, KeyDB, Dragonfly, ClickHouse — named,
|
does not model (MySQL, MariaDB, MongoDB, KeyDB, Dragonfly, ClickHouse — named,
|
||||||
never silently dropped), env var names a cast template cannot express, names
|
never silently dropped), env var names a cast template cannot express, names
|
||||||
**reserved by the platform** (`SOURCE_COMMIT`, `COOLIFY_*` — suppressed, never
|
**reserved by the platform** (`SOURCE_COMMIT`, `COOLIFY_*` — suppressed, never
|
||||||
|
|
@ -946,15 +1024,23 @@ one used — verified against a live private clone.
|
||||||
|
|
||||||
**Known limitations, not defects:**
|
**Known limitations, not defects:**
|
||||||
|
|
||||||
- **Backup schedules are create-time only.** A manifest database's `backup`
|
- **~~Backup schedules are create-time only.~~** **Corrected (#51).** This entry
|
||||||
block (`frequency`, `retention`) is applied only when the database is
|
used to claim that a database's `backup` block was create-time only and kept
|
||||||
first created; it is deliberately kept out of the diffed `fields` (live
|
out of the diffed `fields` because *"live Coolify state doesn't expose it
|
||||||
Coolify state doesn't expose it back, so diffing it would flag spurious
|
back"*. The parenthesis was load-bearing and it was false — `GET
|
||||||
drift every run — breaking idempotency). Changing a schedule on an
|
/databases/{uuid}/backups` is a route, and cast had been POSTing to it all
|
||||||
existing database is a runbook act, done by hand in the Coolify UI.
|
along without ever reading it. Backup schedules are now compared on every run
|
||||||
|
and written on create *and* update; see **Backup schedules** above for what
|
||||||
|
is still not compared (an unreadable answer, several schedules, the S3
|
||||||
|
target) and how each says so out loud. Kept here, struck through, because
|
||||||
|
this entry is *why nobody looked*: a limitation filed as a defect gets fixed,
|
||||||
|
and a defect filed as a limitation does not.
|
||||||
- **A service's `domains` cannot be applied via the API in Coolify 4.1.2,
|
- **A service's `domains` cannot be applied via the API in Coolify 4.1.2,
|
||||||
and is deliberately kept out of the diffed `fields` for the same
|
and is deliberately kept out of the diffed `fields` for idempotency.** (This
|
||||||
idempotency reason as backup schedules above.** The `/services`
|
used to cite backup schedules as its precedent; it can't any more — that
|
||||||
|
reasoning was disproved above. This one was re-checked and holds: Coolify
|
||||||
|
4.1.2 exposes no flat `domains` on a service, on any route. If that is ever
|
||||||
|
disproved the same way, `domains` belongs in `fields` too.) The `/services`
|
||||||
create/update payload takes a structured per-container `urls` list, not
|
create/update payload takes a structured per-container `urls` list, not
|
||||||
the manifest's flat `domains: string[]`, and the manifest has no
|
the manifest's flat `domains: string[]`, and the manifest has no
|
||||||
per-container name to build that list correctly from — so cast
|
per-container name to build that list correctly from — so cast
|
||||||
|
|
|
||||||
206
src/cli.ts
206
src/cli.ts
|
|
@ -384,10 +384,63 @@ export type LiveLookup =
|
||||||
environment: string;
|
environment: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Attach the live backup schedule to a database, or the reason there isn't one
|
||||||
|
// to attach. Split out from fetchLive so the "cast could not read this" paths —
|
||||||
|
// the ones that must never lie in either direction — are all visible together.
|
||||||
|
//
|
||||||
|
// The four answers, and why each is what it is:
|
||||||
|
//
|
||||||
|
// unreadable -> backupNotCompared. Says nothing, claims nothing, prints.
|
||||||
|
// no schedule -> no `backup` in fields. Read cleanly: a declared backup is
|
||||||
|
// then REAL drift, and apply creates the schedule. This is
|
||||||
|
// the case the old side-channel design could never see, and
|
||||||
|
// the reason a rebuilt database silently had no backups.
|
||||||
|
// one schedule -> compared, like any other field.
|
||||||
|
// >1 schedule -> backupNotCompared. cast's manifest declares ONE schedule;
|
||||||
|
// a database carrying several is outside that vocabulary,
|
||||||
|
// and picking one to compare against would be a coin toss
|
||||||
|
// reported as a fact.
|
||||||
|
//
|
||||||
|
// A DISABLED schedule is deliberately NOT treated as "no schedule": the row
|
||||||
|
// exists (so apply must PATCH it, not POST a second one) but it backs nothing
|
||||||
|
// up (so it must not read as clean). Carrying `enabled: false` into the compared
|
||||||
|
// value gets both — it diffs against a desired block that implies enabled, and
|
||||||
|
// the update path re-enables it.
|
||||||
|
export async function attachBackup(
|
||||||
|
client: CoolifyClient,
|
||||||
|
db: Live,
|
||||||
|
): Promise<void> {
|
||||||
|
const read = await client.databaseBackupSchedules(db.uuid);
|
||||||
|
if (read === undefined) {
|
||||||
|
db.backupNotCompared =
|
||||||
|
"GET /databases/{uuid}/backups was unreachable or returned a shape cast does not recognize";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (read.length > 1) {
|
||||||
|
db.backupNotCompared = `Coolify holds ${read.length} schedules for this database; a manifest declares one`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const schedule = read[0];
|
||||||
|
if (!schedule) return; // read cleanly: no schedule. Absence IS the answer.
|
||||||
|
db.fields.backup = {
|
||||||
|
// Same key order as the desired side (resolve.ts) — computeDiff compares
|
||||||
|
// by JSON.stringify. `enabled` rides along only when false, so the ordinary
|
||||||
|
// healthy case is a two-key object on both sides and compares equal.
|
||||||
|
frequency: schedule.frequency,
|
||||||
|
retention: schedule.retention,
|
||||||
|
...(schedule.enabled ? {} : { enabled: false }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchLive(
|
export async function fetchLive(
|
||||||
client: CoolifyClient,
|
client: CoolifyClient,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
envName: string,
|
envName: string,
|
||||||
|
// Backups cost one extra GET per database, so only the callers that actually
|
||||||
|
// compare them ask for them: `diff` and `apply`. The read-side sweeps
|
||||||
|
// (inventory, capture, smoke) walk every project on a box and would pay it on
|
||||||
|
// every database for an answer they never look at.
|
||||||
|
opts: { backups?: boolean } = {},
|
||||||
): Promise<LiveLookup> {
|
): Promise<LiveLookup> {
|
||||||
const projects = (await client.get("/projects")) as Array<{
|
const projects = (await client.get("/projects")) as Array<{
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
|
@ -454,15 +507,18 @@ export async function fetchLive(
|
||||||
destinationId:
|
destinationId:
|
||||||
typeof i.destination_id === "number" ? i.destination_id : undefined,
|
typeof i.destination_id === "number" ? i.destination_id : undefined,
|
||||||
}));
|
}));
|
||||||
return {
|
const live = [
|
||||||
found: true,
|
...map("application", env.applications),
|
||||||
live: [
|
...map("database", env.postgresqls),
|
||||||
...map("application", env.applications),
|
...map("database", env.redis),
|
||||||
...map("database", env.postgresqls),
|
...map("service", env.services),
|
||||||
...map("database", env.redis),
|
];
|
||||||
...map("service", env.services),
|
if (opts.backups) {
|
||||||
],
|
for (const db of live.filter((l) => l.kind === "database")) {
|
||||||
};
|
await attachBackup(client, db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { found: true, live };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Why `diff` refuses instead of reporting an empty live side: see LiveLookup.
|
// Why `diff` refuses instead of reporting an empty live side: see LiveLookup.
|
||||||
|
|
@ -910,7 +966,7 @@ async function runProject(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const secrets = decryptSecrets(store, keyFileFor(ctx.envName));
|
const secrets = decryptSecrets(store, keyFileFor(ctx.envName));
|
||||||
let { desired, resolvedEnvs, backupSchedules } = desiredFromManifest(
|
let { desired, resolvedEnvs } = desiredFromManifest(
|
||||||
checkout,
|
checkout,
|
||||||
ctx.envName,
|
ctx.envName,
|
||||||
secrets,
|
secrets,
|
||||||
|
|
@ -930,7 +986,10 @@ async function runProject(
|
||||||
parseYaml(readFileSync(ctx.hostnameOverlay, "utf8")),
|
parseYaml(readFileSync(ctx.hostnameOverlay, "utf8")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const lookup = await fetchLive(ctx.client, projectName, coolifyEnv);
|
// `backups: true` — this is the one path that compares them (see fetchLive).
|
||||||
|
const lookup = await fetchLive(ctx.client, projectName, coolifyEnv, {
|
||||||
|
backups: true,
|
||||||
|
});
|
||||||
// apply and diff take opposite (and both correct) positions on absence:
|
// apply and diff take opposite (and both correct) positions on absence:
|
||||||
// apply is *allowed* to be the thing that brings a project into existence,
|
// apply is *allowed* to be the thing that brings a project into existence,
|
||||||
// so [] is a legitimate starting point. diff is only ever a claim about
|
// so [] is a legitimate starting point. diff is only ever a claim about
|
||||||
|
|
@ -1012,7 +1071,6 @@ async function runProject(
|
||||||
bindingEnv: ctx.envName,
|
bindingEnv: ctx.envName,
|
||||||
destinationUuid: projectBinding?.destination_uuid,
|
destinationUuid: projectBinding?.destination_uuid,
|
||||||
s3DestinationUuid: ctx.binding.s3_destination,
|
s3DestinationUuid: ctx.binding.s3_destination,
|
||||||
backupSchedules,
|
|
||||||
visibleUuids,
|
visibleUuids,
|
||||||
});
|
});
|
||||||
const { mutated } = await applyPlan(report, desired, exec);
|
const { mutated } = await applyPlan(report, desired, exec);
|
||||||
|
|
@ -2422,13 +2480,36 @@ export function defaultDatabaseImage(type: string, version: string): string {
|
||||||
return `${repo}:${version}-alpine`;
|
return `${repo}:${version}-alpine`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The desired backup schedule, narrowed out of the untyped `fields` bag that
|
||||||
|
// both createResource and updateFields are handed. Anything that is not a
|
||||||
|
// complete, well-typed schedule reads as "none declared" — the manifest schema
|
||||||
|
// (manifest.ts) already requires both keys, so a partial object here would mean
|
||||||
|
// a bug upstream, and writing half a schedule is worse than writing none.
|
||||||
|
export function desiredBackup(
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
): { frequency: string; retention: number } | undefined {
|
||||||
|
const b = fields.backup as
|
||||||
|
| { frequency?: unknown; retention?: unknown }
|
||||||
|
| undefined;
|
||||||
|
if (!b || typeof b !== "object") return undefined;
|
||||||
|
if (typeof b.frequency !== "string" || typeof b.retention !== "number")
|
||||||
|
return undefined;
|
||||||
|
return { frequency: b.frequency, retention: b.retention };
|
||||||
|
}
|
||||||
|
|
||||||
export function databaseApiFields(
|
export function databaseApiFields(
|
||||||
fields: Record<string, unknown>,
|
fields: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
// /databases/postgresql and /databases/redis accept no `type` param (the
|
// /databases/postgresql and /databases/redis accept no `type` param (the
|
||||||
// endpoint path already encodes it) and no `version` param at all — only
|
// endpoint path already encodes it) and no `version` param at all — only
|
||||||
// `image`, a literal Docker image string.
|
// `image`, a literal Docker image string.
|
||||||
const { type, version, ...rest } = fields;
|
//
|
||||||
|
// `backup` is stripped because it is not a column on the database at all: it
|
||||||
|
// is a row on a different route (/databases/{uuid}/backups), written by
|
||||||
|
// writeBackupSchedule. It rides in `fields` so that it can be DIFFED like
|
||||||
|
// any other field; it must never reach the database's own create/update body,
|
||||||
|
// which rejects unknown fields.
|
||||||
|
const { type, version, backup: _backup, ...rest } = fields;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
...(typeof version === "string"
|
...(typeof version === "string"
|
||||||
|
|
@ -2796,7 +2877,6 @@ export function buildExecutor(
|
||||||
// hosts two projects, is the right answer.
|
// hosts two projects, is the right answer.
|
||||||
destinationUuid?: string;
|
destinationUuid?: string;
|
||||||
s3DestinationUuid?: string; // raw UUID from environments.yaml — no storage API exists to resolve names
|
s3DestinationUuid?: string; // raw UUID from environments.yaml — no storage API exists to resolve names
|
||||||
backupSchedules: Record<string, { frequency: string; retention: number }>;
|
|
||||||
// The live resources of THIS project + environment, by uuid — everything cast
|
// The live resources of THIS project + environment, by uuid — everything cast
|
||||||
// can see. Read by exactly one thing: the domain-conflict message, which has to
|
// can see. Read by exactly one thing: the domain-conflict message, which has to
|
||||||
// say whether the resource holding the domain is inside the applied project or
|
// say whether the resource holding the domain is inside the applied project or
|
||||||
|
|
@ -2834,6 +2914,69 @@ export function buildExecutor(
|
||||||
ctx.projectName,
|
ctx.projectName,
|
||||||
ctx.envName,
|
ctx.envName,
|
||||||
);
|
);
|
||||||
|
// The body of a backup-schedule write, shared by the create and update paths
|
||||||
|
// so the two cannot drift apart — the create path having been the only one
|
||||||
|
// for so long is precisely how the update path came to not exist.
|
||||||
|
//
|
||||||
|
// `save_s3` + `s3_storage_uuid` are asserted on every write, not compared:
|
||||||
|
// Coolify returns the storage as `s3_storage_id` (an int) and takes it as a
|
||||||
|
// uuid, the same unmappable pair as destination_id (see Placement), so cast
|
||||||
|
// can state its intent here but can never verify it afterwards. Declaring
|
||||||
|
// `backup:` in a manifest means "backed up, to the environment's S3" — that
|
||||||
|
// is what this writes, on create and on update alike.
|
||||||
|
const backupBody = (
|
||||||
|
label: string,
|
||||||
|
schedule: { frequency: string; retention: number },
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
if (!ctx.s3DestinationUuid) {
|
||||||
|
throw new Error(
|
||||||
|
`database ${label} declares a backup schedule but environments.yaml has no s3_destination UUID for this environment`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
frequency: schedule.frequency,
|
||||||
|
database_backup_retention_amount_locally: schedule.retention,
|
||||||
|
save_s3: true,
|
||||||
|
s3_storage_uuid: ctx.s3DestinationUuid,
|
||||||
|
// Asserted on every write. A schedule row that exists with enabled=false
|
||||||
|
// backs nothing up, and a manifest that declares `backup:` is asking for
|
||||||
|
// backups, not for a disabled row that looks like backups.
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// Make a database's live schedule match the manifest — the half of this that
|
||||||
|
// did not exist. `apply` used to write a schedule ONLY inside the create
|
||||||
|
// branch, so adding `backup:` to an already-live database produced a clean
|
||||||
|
// run and zero backups; that is the defect (#51).
|
||||||
|
//
|
||||||
|
// POST creates, PATCH updates, and which one is right depends on a read — so
|
||||||
|
// this reads first. When the read fails it RAISES rather than guessing: the
|
||||||
|
// alternatives are POSTing (which duplicates the schedule if one was in fact
|
||||||
|
// there) or skipping (which is the silent no-op being fixed). An apply that
|
||||||
|
// promised to set a backup schedule and could not must say so and stop.
|
||||||
|
const reconcileBackupSchedule = async (
|
||||||
|
dbUuid: string,
|
||||||
|
schedule: { frequency: string; retention: number },
|
||||||
|
): Promise<void> => {
|
||||||
|
const body = backupBody(dbUuid, schedule);
|
||||||
|
const existing = await client.databaseBackupSchedules(dbUuid);
|
||||||
|
if (existing === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`database ${dbUuid}: cannot set the declared backup schedule — GET /databases/${dbUuid}/backups was unreachable or returned an unrecognized shape, so cast cannot tell whether a schedule already exists (creating one blindly would risk a duplicate). Set it in the Coolify UI, or re-run when the API is reachable.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existing.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`database ${dbUuid}: Coolify holds ${existing.length} backup schedules for this database and a manifest declares one — cast will not guess which to update. Resolve in the Coolify UI (runbook act).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const current = existing[0];
|
||||||
|
if (current) {
|
||||||
|
await client.patch(`/databases/${dbUuid}/backups/${current.uuid}`, body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.post(`/databases/${dbUuid}/backups`, body);
|
||||||
|
};
|
||||||
// The two instance-wide constraints a create can die on, both of them invisible
|
// The two instance-wide constraints a create can die on, both of them invisible
|
||||||
// from cast's project-scoped view, both of them arriving at the FIRST create —
|
// from cast's project-scoped view, both of them arriving at the FIRST create —
|
||||||
// after apply has already made the project and the environment. One wrapper, and
|
// after apply has already made the project and the environment. One wrapper, and
|
||||||
|
|
@ -2926,19 +3069,15 @@ export function buildExecutor(
|
||||||
...databaseApiFields(fields),
|
...databaseApiFields(fields),
|
||||||
},
|
},
|
||||||
)) as { uuid: string };
|
)) as { uuid: string };
|
||||||
const schedule = ctx.backupSchedules[change.name];
|
// A database that was created a moment ago provably has no schedule,
|
||||||
|
// so this POSTs rather than going through reconcileBackupSchedule —
|
||||||
|
// no read to do, and no read that could fail and abort a create.
|
||||||
|
const schedule = desiredBackup(fields);
|
||||||
if (schedule) {
|
if (schedule) {
|
||||||
if (!ctx.s3DestinationUuid) {
|
await client.post(
|
||||||
throw new Error(
|
`/databases/${res.uuid}/backups`,
|
||||||
`database ${change.name} declares a backup schedule but environments.yaml has no s3_destination UUID for this environment`,
|
backupBody(change.name, schedule),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
await client.post(`/databases/${res.uuid}/backups`, {
|
|
||||||
frequency: schedule.frequency,
|
|
||||||
database_backup_retention_amount_locally: schedule.retention,
|
|
||||||
save_s3: true,
|
|
||||||
s3_storage_uuid: ctx.s3DestinationUuid,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return res.uuid;
|
return res.uuid;
|
||||||
}
|
}
|
||||||
|
|
@ -2966,7 +3105,20 @@ export function buildExecutor(
|
||||||
: kind === "service"
|
: kind === "service"
|
||||||
? serviceApiFields(fields)
|
? serviceApiFields(fields)
|
||||||
: databaseApiFields(fields);
|
: databaseApiFields(fields);
|
||||||
await client.patch(`/${base}/${uuid}`, apiFields);
|
// A backup-only drift strips to an empty body (databaseApiFields drops
|
||||||
|
// `backup`, which belongs to another route) — and PATCHing a database
|
||||||
|
// with `{}` is a write that says nothing. Skip it; the schedule below is
|
||||||
|
// the actual change.
|
||||||
|
if (Object.keys(apiFields).length > 0) {
|
||||||
|
await client.patch(`/${base}/${uuid}`, apiFields);
|
||||||
|
}
|
||||||
|
// The declared schedule is applied on UPDATE, not only on create. This is
|
||||||
|
// what makes adding `backup:` to an existing database do what every reader
|
||||||
|
// of that manifest already assumes it does (#51).
|
||||||
|
if (kind === "database") {
|
||||||
|
const schedule = desiredBackup(fields);
|
||||||
|
if (schedule) await reconcileBackupSchedule(uuid, schedule);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async syncEnv(uuid, kind, env) {
|
async syncEnv(uuid, kind, env) {
|
||||||
// The reserved-name rule at the wire (reserved.ts). Nothing can reach here
|
// The reserved-name rule at the wire (reserved.ts). Nothing can reach here
|
||||||
|
|
|
||||||
121
src/coolify.ts
121
src/coolify.ts
|
|
@ -20,6 +20,105 @@ export class HttpError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A database's scheduled backup, as cast is able to read it back.
|
||||||
|
//
|
||||||
|
// `retention` is Coolify's `database_backup_retention_amount_locally` — the
|
||||||
|
// same field cast has always POSTed on create. `enabled` is carried because a
|
||||||
|
// DISABLED schedule is a row that exists and backs nothing up: reporting that
|
||||||
|
// database as backed-up is the one lie this whole path exists to prevent.
|
||||||
|
export type LiveBackup = {
|
||||||
|
uuid: string;
|
||||||
|
frequency: string;
|
||||||
|
retention: number;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The result of trying to read a database's schedules. The two absences are
|
||||||
|
// NOT the same fact and must never collapse into one another (the LiveLookup
|
||||||
|
// lesson, one level down):
|
||||||
|
//
|
||||||
|
// [] — read cleanly, this database has NO schedule. Trustworthy, and
|
||||||
|
// therefore real drift if the manifest declares one.
|
||||||
|
// undefined — NOT READ: transport error, or a body cast does not recognize.
|
||||||
|
// Says nothing. Must never become "no backups" (which would
|
||||||
|
// invent drift, and make apply POST a duplicate schedule) nor
|
||||||
|
// "backed up" (which would pass a cutover on an unbacked-up db).
|
||||||
|
export type BackupRead = LiveBackup[] | undefined;
|
||||||
|
|
||||||
|
// Coolify's int columns arrive as ints, but a tinyint `enabled` has no cast on
|
||||||
|
// ScheduledDatabaseBackup (v4.1.2 casts() covers only the two float storage
|
||||||
|
// fields), so it can serialize as 1/0 rather than true/false. Accept both; only
|
||||||
|
// an explicit falsey value disables. An ABSENT `enabled` is read as enabled —
|
||||||
|
// Coolify's own create path defaults it to true (DatabasesController, v4.1.2).
|
||||||
|
function readEnabled(raw: Record<string, unknown>): boolean {
|
||||||
|
const v = raw.enabled;
|
||||||
|
if (v === undefined || v === null) return true;
|
||||||
|
return !(v === false || v === 0 || v === "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict on purpose: a value cast cannot read EXACTLY is not coerced into a
|
||||||
|
// guess, it collapses the whole read to `undefined` (= "not compared", said out
|
||||||
|
// loud). Silence about a backup is the failure being fixed here; a wrong number
|
||||||
|
// about one would be worse than the silence.
|
||||||
|
function readInt(v: unknown): number | undefined {
|
||||||
|
if (typeof v === "number" && Number.isInteger(v)) return v;
|
||||||
|
if (typeof v === "string" && /^\d+$/.test(v)) return Number(v);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse GET /databases/{uuid}/backups.
|
||||||
|
//
|
||||||
|
// The vendored OpenAPI documents this body as "Content is very complex. Will be
|
||||||
|
// implemented later." — so the shape here comes from the source instead:
|
||||||
|
// DatabasesController@database_backup_details_uuid (v4.1.2) ends with
|
||||||
|
//
|
||||||
|
// $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)
|
||||||
|
// ->with('executions')->where('database_id', $database->id)->get();
|
||||||
|
// return response()->json($backupConfig);
|
||||||
|
//
|
||||||
|
// i.e. a raw Eloquent collection — a JSON ARRAY of ScheduledDatabaseBackup rows
|
||||||
|
// (no API resource, no removeSensitiveData), whose columns are the model's
|
||||||
|
// $fillable: uuid, enabled, save_s3, frequency,
|
||||||
|
// database_backup_retention_amount_locally, ... plus an eager-loaded
|
||||||
|
// `executions` array cast ignores.
|
||||||
|
//
|
||||||
|
// `frequency` round-trips VERBATIM: the controller validates it
|
||||||
|
// (validate_cron_expression, which only returns a bool) and then stores
|
||||||
|
// $request->only($backupConfigFields) unchanged — there is no mutator on the
|
||||||
|
// model. So "0 3 * * *" reads back as "0 3 * * *", and the preset words
|
||||||
|
// (daily, weekly, ...) read back as themselves. That is what makes this field
|
||||||
|
// diffable at all, and it is the fact the old "spurious drift" fear assumed
|
||||||
|
// away without checking.
|
||||||
|
export function parseBackupSchedules(raw: unknown): BackupRead {
|
||||||
|
// Not an array = not the documented collection. Unknown answer, not "none".
|
||||||
|
if (!Array.isArray(raw)) return undefined;
|
||||||
|
const schedules: LiveBackup[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item !== "object" || item === null) return undefined;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const uuid = row.uuid;
|
||||||
|
const frequency = row.frequency;
|
||||||
|
const retention = readInt(row.database_backup_retention_amount_locally);
|
||||||
|
// One unreadable row makes the whole read unreadable. A partial list would
|
||||||
|
// be indistinguishable from a complete one to every caller downstream, and
|
||||||
|
// the caller most worth protecting is the one asking "is this backed up?".
|
||||||
|
if (
|
||||||
|
typeof uuid !== "string" ||
|
||||||
|
typeof frequency !== "string" ||
|
||||||
|
retention === undefined
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
schedules.push({
|
||||||
|
uuid,
|
||||||
|
frequency,
|
||||||
|
retention,
|
||||||
|
enabled: readEnabled(row),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return schedules;
|
||||||
|
}
|
||||||
|
|
||||||
export class CoolifyClient {
|
export class CoolifyClient {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly baseUrl: string,
|
private readonly baseUrl: string,
|
||||||
|
|
@ -282,6 +381,28 @@ export class CoolifyClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The SAME route as databaseBackups above, PARSED for the diff/apply half of
|
||||||
|
// the story (#51). destroy reads the raw body because it decides shape-meaning
|
||||||
|
// itself (readBackupState); diff/apply need a settled `frequency`/`retention`
|
||||||
|
// to compare and write, so this parses on top of the one HTTP call rather than
|
||||||
|
// duplicating it — one place fetches, two callers read it their own way.
|
||||||
|
//
|
||||||
|
// `undefined` means "could not read", which is a DIFFERENT fact from "has
|
||||||
|
// none" (see BackupRead). Every failure lands on `undefined`, INCLUDING a 404:
|
||||||
|
// it is tempting to read 404 as "no backups" (fetchLive does exactly that for a
|
||||||
|
// missing environment), but here a 404 is Coolify saying *the database* was not
|
||||||
|
// found, never "the database has no schedules" — the handler returns a plain
|
||||||
|
// `[]` for that, with a 200. Reading 404 as "none" would let a mistyped uuid
|
||||||
|
// report an unbacked-up database as clean, and let apply POST a second schedule
|
||||||
|
// onto a database that already had one.
|
||||||
|
async databaseBackupSchedules(uuid: string): Promise<BackupRead> {
|
||||||
|
try {
|
||||||
|
return parseBackupSchedules(await this.databaseBackups(uuid));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deploy(uuid: string): Promise<void> {
|
async deploy(uuid: string): Promise<void> {
|
||||||
await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`);
|
await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
src/diff.ts
46
src/diff.ts
|
|
@ -24,6 +24,18 @@ export type Live = {
|
||||||
// Putting it in `fields` would diff a UUID against an int and report drift
|
// Putting it in `fields` would diff a UUID against an int and report drift
|
||||||
// that can never be resolved. See Placement.
|
// that can never be resolved. See Placement.
|
||||||
destinationId?: number;
|
destinationId?: number;
|
||||||
|
// Set ONLY when this database declares a `backup` block that cast could not
|
||||||
|
// read back (GET /databases/{uuid}/backups was unreachable, or answered a
|
||||||
|
// shape cast does not recognize — see BackupRead). The string is the reason,
|
||||||
|
// printed verbatim.
|
||||||
|
//
|
||||||
|
// Presence of this means: DO NOT COMPARE `backup` for this resource. Leaving
|
||||||
|
// `backup` merely absent from `fields` would NOT be equivalent — it would
|
||||||
|
// diff desired-against-nothing and report confident drift on a database that
|
||||||
|
// may well be perfectly backed up. An unreadable answer must produce neither
|
||||||
|
// drift nor a clean bill; it produces a line on the report. computeDiff is
|
||||||
|
// where that is enforced.
|
||||||
|
backupNotCompared?: string;
|
||||||
};
|
};
|
||||||
export type FieldDiff = {
|
export type FieldDiff = {
|
||||||
field: string;
|
field: string;
|
||||||
|
|
@ -94,6 +106,11 @@ export type DiffReport = {
|
||||||
// structural mode reads no env vars at all, so it can find none — and says so.
|
// structural mode reads no env vars at all, so it can find none — and says so.
|
||||||
reserved: ReservedVar[];
|
reserved: ReservedVar[];
|
||||||
placement: Placement;
|
placement: Placement;
|
||||||
|
// Databases whose declared `backup` block cast could not verify this run.
|
||||||
|
// NOT drift (nothing was read, so nothing can be claimed) and so NOT counted
|
||||||
|
// against `clean` — but printed on every run that has any, because the whole
|
||||||
|
// point is that the assumption goes on screen at the moment it is made.
|
||||||
|
backupsNotCompared: { name: string; reason: string }[];
|
||||||
clean: boolean;
|
clean: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -210,6 +227,7 @@ export function computeDiff(
|
||||||
opts: { declaredDestination?: string } = {},
|
opts: { declaredDestination?: string } = {},
|
||||||
): DiffReport {
|
): DiffReport {
|
||||||
const changes: Change[] = [];
|
const changes: Change[] = [];
|
||||||
|
const backupsNotCompared: { name: string; reason: string }[] = [];
|
||||||
for (const d of desired) {
|
for (const d of desired) {
|
||||||
const l = live.find((x) => x.kind === d.kind && x.name === d.name);
|
const l = live.find((x) => x.kind === d.kind && x.name === d.name);
|
||||||
if (!l) {
|
if (!l) {
|
||||||
|
|
@ -233,7 +251,17 @@ export function computeDiff(
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The unreadable-backup escape hatch. `backup` is dropped from the
|
||||||
|
// comparison entirely — not diffed against `undefined`, which is what
|
||||||
|
// "just leave it out of live.fields" would silently mean, and which would
|
||||||
|
// report drift cast has no evidence for and let apply write a schedule it
|
||||||
|
// never checked for. See Live.backupNotCompared.
|
||||||
|
if (l.backupNotCompared && "backup" in d.fields) {
|
||||||
|
backupsNotCompared.push({ name: d.name, reason: l.backupNotCompared });
|
||||||
|
}
|
||||||
|
const skipBackup = l.backupNotCompared !== undefined;
|
||||||
const fieldDiffs: FieldDiff[] = Object.entries(d.fields)
|
const fieldDiffs: FieldDiff[] = Object.entries(d.fields)
|
||||||
|
.filter(([field]) => !(skipBackup && field === "backup"))
|
||||||
.filter(([field, value]) => !eq(value, l.fields[field]))
|
.filter(([field, value]) => !eq(value, l.fields[field]))
|
||||||
.map(([field, value]) => ({
|
.map(([field, value]) => ({
|
||||||
field,
|
field,
|
||||||
|
|
@ -265,6 +293,7 @@ export function computeDiff(
|
||||||
orphans,
|
orphans,
|
||||||
reserved,
|
reserved,
|
||||||
placement,
|
placement,
|
||||||
|
backupsNotCompared,
|
||||||
// A split project is drift, and drift is not clean — the same disposition
|
// A split project is drift, and drift is not clean — the same disposition
|
||||||
// as an orphan: reported, counted, and NOT repaired (apply moves nothing
|
// as an orphan: reported, counted, and NOT repaired (apply moves nothing
|
||||||
// between networks; see renderDiff).
|
// between networks; see renderDiff).
|
||||||
|
|
@ -273,6 +302,12 @@ export function computeDiff(
|
||||||
// it is a live defect. The box it sits on is deploying green and reporting
|
// it is a live defect. The box it sits on is deploying green and reporting
|
||||||
// the wrong commit, and a `diff` that answered "clean" over it would be the
|
// the wrong commit, and a `diff` that answered "clean" over it would be the
|
||||||
// last chance anyone had to notice.
|
// last chance anyone had to notice.
|
||||||
|
//
|
||||||
|
// `backupsNotCompared` is deliberately NOT in this sum, and it is the one
|
||||||
|
// exception to the rule the line above states: it is an absence of evidence,
|
||||||
|
// not evidence of drift, and a run that failed because a read failed would be
|
||||||
|
// a run operators learn to force past. It gets a line instead — an
|
||||||
|
// unmissable one — rather than a non-zero exit.
|
||||||
clean:
|
clean:
|
||||||
changes.length === 0 &&
|
changes.length === 0 &&
|
||||||
orphans.length === 0 &&
|
orphans.length === 0 &&
|
||||||
|
|
@ -348,6 +383,17 @@ export function renderDiff(report: DiffReport): string {
|
||||||
" and `apply` never deletes — so this one is yours to remove, by hand, in the UI.",
|
" and `apply` never deletes — so this one is yours to remove, by hand, in the UI.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The honest fallback, on the same principle as the placement line below: cast
|
||||||
|
// read for the schedule and did not understand the answer, so it says so here
|
||||||
|
// rather than dropping the field and letting a clean report imply a backed-up
|
||||||
|
// database. A `backup:` block that produced no line above and no line here IS
|
||||||
|
// compared, and IS clean.
|
||||||
|
for (const b of report.backupsNotCompared) {
|
||||||
|
lines.push(
|
||||||
|
`backup schedule for database ${b.name} declared, NOT compared — verify in the Coolify UI`,
|
||||||
|
` (${b.reason})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const { placement } = report;
|
const { placement } = report;
|
||||||
if (placement.split) {
|
if (placement.split) {
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,6 @@ export function desiredFromManifest(
|
||||||
): {
|
): {
|
||||||
desired: Desired[];
|
desired: Desired[];
|
||||||
resolvedEnvs: Record<string, ResolvedEnv>;
|
resolvedEnvs: Record<string, ResolvedEnv>;
|
||||||
backupSchedules: Record<string, { frequency: string; retention: number }>;
|
|
||||||
} {
|
} {
|
||||||
const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml"));
|
const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml"));
|
||||||
const envSpec = manifest.environments[envName];
|
const envSpec = manifest.environments[envName];
|
||||||
|
|
@ -352,10 +351,6 @@ export function desiredFromManifest(
|
||||||
}
|
}
|
||||||
const desired: Desired[] = [];
|
const desired: Desired[] = [];
|
||||||
const resolvedEnvs: Record<string, ResolvedEnv> = {};
|
const resolvedEnvs: Record<string, ResolvedEnv> = {};
|
||||||
const backupSchedules: Record<
|
|
||||||
string,
|
|
||||||
{ frequency: string; retention: number }
|
|
||||||
> = {};
|
|
||||||
const reserved: ReservedHit[] = [];
|
const reserved: ReservedHit[] = [];
|
||||||
const resolveEnvFile = (
|
const resolveEnvFile = (
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -428,13 +423,33 @@ export function desiredFromManifest(
|
||||||
desired.push({
|
desired.push({
|
||||||
kind: "database",
|
kind: "database",
|
||||||
name,
|
name,
|
||||||
fields: { type: db.type, ...(db.version ? { version: db.version } : {}) },
|
fields: {
|
||||||
|
type: db.type,
|
||||||
|
...(db.version ? { version: db.version } : {}),
|
||||||
|
// `backup` is a DIFFED FIELD, like any other.
|
||||||
|
//
|
||||||
|
// It used to be routed around `fields` into a side channel, on the
|
||||||
|
// stated grounds that "live Coolify state doesn't expose it back" — so
|
||||||
|
// diffing it would flag spurious drift forever. That premise was false:
|
||||||
|
// it is not on the database's own GET, but GET /databases/{uuid}/backups
|
||||||
|
// is a route (cast has always POSTed to it), and frequency/retention
|
||||||
|
// round-trip verbatim through it. The side channel is what made a
|
||||||
|
// `backup:` block added to an EXISTING database do nothing, silently,
|
||||||
|
// and made `diff --full` pass on a production database with no backups.
|
||||||
|
//
|
||||||
|
// Key order matters: computeDiff compares by JSON.stringify, and the
|
||||||
|
// live side (fetchLive in cli.ts) builds this same object in this same
|
||||||
|
// order. Do not reorder one without the other.
|
||||||
|
...(db.backup
|
||||||
|
? {
|
||||||
|
backup: {
|
||||||
|
frequency: db.backup.frequency,
|
||||||
|
retention: db.backup.retention,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (db.backup)
|
|
||||||
backupSchedules[name] = {
|
|
||||||
frequency: db.backup.frequency,
|
|
||||||
retention: db.backup.retention,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
for (const [name, svc] of Object.entries(envSpec.services ?? {})) {
|
for (const [name, svc] of Object.entries(envSpec.services ?? {})) {
|
||||||
if (svc.domains && svc.domains.length > 0) {
|
if (svc.domains && svc.domains.length > 0) {
|
||||||
|
|
@ -450,12 +465,19 @@ export function desiredFromManifest(
|
||||||
desired.push({
|
desired.push({
|
||||||
kind: "service",
|
kind: "service",
|
||||||
name,
|
name,
|
||||||
// domains dropped from fields, same as database `backup` above: the
|
// domains dropped from fields: the live side (projectLiveFields in
|
||||||
// live side (projectLiveFields in cli.ts) can't read service domains
|
// cli.ts) can't read service domains and the write side
|
||||||
// and the write side (serviceApiFields) drops them, so keeping
|
// (serviceApiFields) drops them, so keeping domains in fields makes
|
||||||
// domains in fields makes every domain-bearing service diff as a
|
// every domain-bearing service diff as a perpetual update. Hostnames
|
||||||
// perpetual update. Hostnames stay a manual Coolify UI act (warned
|
// stay a manual Coolify UI act (warned above).
|
||||||
// above).
|
//
|
||||||
|
// This USED to cite database `backup` as its precedent. It no longer
|
||||||
|
// can: `backup` was dropped on the same reasoning and the reasoning
|
||||||
|
// turned out to be false there (a read route existed, unlooked-for —
|
||||||
|
// see the databases loop above). The difference is that this one was
|
||||||
|
// re-checked: Coolify 4.1.2 genuinely exposes no flat `domains` on a
|
||||||
|
// service, on any route. If that is ever disproved the same way, this
|
||||||
|
// belongs in `fields` too.
|
||||||
fields: { type: svc.type },
|
fields: { type: svc.type },
|
||||||
env: resolveEnvFile(name, svc.env_template),
|
env: resolveEnvFile(name, svc.env_template),
|
||||||
});
|
});
|
||||||
|
|
@ -464,5 +486,5 @@ export function desiredFromManifest(
|
||||||
// resolved env that carries a reserved name is not desired state, it is a
|
// resolved env that carries a reserved name is not desired state, it is a
|
||||||
// suppression of the platform's own value dressed up as one. See reserved.ts.
|
// suppression of the platform's own value dressed up as one. See reserved.ts.
|
||||||
assertNoReservedEnvNames(reserved);
|
assertNoReservedEnvNames(reserved);
|
||||||
return { desired, resolvedEnvs, backupSchedules };
|
return { desired, resolvedEnvs };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
254
test/backup-cli.test.ts
Normal file
254
test/backup-cli.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,23 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { CoolifyClient } from "../src/coolify.js";
|
import { CoolifyClient, parseBackupSchedules } from "../src/coolify.js";
|
||||||
|
|
||||||
|
// A row as Coolify actually serializes one: a raw ScheduledDatabaseBackup
|
||||||
|
// Eloquent model (DatabasesController@database_backup_details_uuid, v4.1.2),
|
||||||
|
// so every column is present and `executions` is eager-loaded alongside.
|
||||||
|
const row = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: 3,
|
||||||
|
uuid: "sched-1",
|
||||||
|
team_id: 1,
|
||||||
|
enabled: true,
|
||||||
|
save_s3: true,
|
||||||
|
frequency: "0 3 * * *",
|
||||||
|
database_backup_retention_amount_locally: 7,
|
||||||
|
database_id: 9,
|
||||||
|
database_type: "App\\Models\\StandalonePostgresql",
|
||||||
|
s3_storage_id: 2,
|
||||||
|
executions: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
function mockFetch(routes: Record<string, unknown>) {
|
function mockFetch(routes: Record<string, unknown>) {
|
||||||
return vi.fn(async (url: string | URL, init?: RequestInit) => {
|
return vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||||
|
|
@ -60,4 +78,93 @@ describe("CoolifyClient", () => {
|
||||||
const c = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
|
const c = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
|
||||||
await expect(c.version()).resolves.toBe("4.1.2");
|
await expect(c.version()).resolves.toBe("4.1.2");
|
||||||
});
|
});
|
||||||
|
it("reads a database's backup schedules", async () => {
|
||||||
|
const c = new CoolifyClient(
|
||||||
|
"https://coolify.test",
|
||||||
|
"tok",
|
||||||
|
mockFetch({ "GET /api/v1/databases/db-1/backups": [row()] }),
|
||||||
|
);
|
||||||
|
await expect(c.databaseBackupSchedules("db-1")).resolves.toEqual([
|
||||||
|
{ uuid: "sched-1", frequency: "0 3 * * *", retention: 7, enabled: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
// The dangerous 404. fetchLive reads a 404 as "no live environment" three
|
||||||
|
// routes up, and that instinct is WRONG here: Coolify answers a database with
|
||||||
|
// no schedules with 200 [], never 404 — a 404 means the database wasn't found.
|
||||||
|
// Reading it as "no backups" would report an unbacked-up database as clean and
|
||||||
|
// make apply POST a duplicate schedule onto one that already had one.
|
||||||
|
it("does not turn a failed read into 'this database has no backups'", async () => {
|
||||||
|
const c = new CoolifyClient("https://coolify.test", "tok", mockFetch({}));
|
||||||
|
await expect(c.databaseBackupSchedules("db-1")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shape cast cannot afford to be wrong about. Every unreadable answer must
|
||||||
|
// land on `undefined` ("cast cannot say"), and ONLY a genuinely empty list may
|
||||||
|
// land on `[]` ("there are none") — the two mean opposite things to every
|
||||||
|
// caller downstream, and to the operator staring at a cutover.
|
||||||
|
describe("parseBackupSchedules", () => {
|
||||||
|
it("reads a well-formed collection", () => {
|
||||||
|
expect(parseBackupSchedules([row()])).toEqual([
|
||||||
|
{ uuid: "sched-1", frequency: "0 3 * * *", retention: 7, enabled: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("reads an empty list as a trustworthy 'no schedule', not as unknown", () => {
|
||||||
|
expect(parseBackupSchedules([])).toEqual([]);
|
||||||
|
});
|
||||||
|
it("reads the spec's own placeholder body as unknown, not as 'no schedule'", () => {
|
||||||
|
// What the vendored OpenAPI literally documents for this route. If Coolify
|
||||||
|
// ever really answered this, cast must not read it as "no backups".
|
||||||
|
expect(
|
||||||
|
parseBackupSchedules(
|
||||||
|
"Content is very complex. Will be implemented later.",
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
["a non-array object", { data: [] }],
|
||||||
|
["null", null],
|
||||||
|
["a row that is not an object", ["nope"]],
|
||||||
|
["a row with no frequency", [row({ frequency: undefined })]],
|
||||||
|
["a row with a non-string frequency", [row({ frequency: 3 })]],
|
||||||
|
[
|
||||||
|
"a row with a null retention",
|
||||||
|
[row({ database_backup_retention_amount_locally: null })],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a row with a non-numeric retention",
|
||||||
|
[row({ database_backup_retention_amount_locally: "many" })],
|
||||||
|
],
|
||||||
|
])("reads %s as unknown", (_label, body) => {
|
||||||
|
expect(parseBackupSchedules(body)).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("collapses the WHOLE read when any one row is unreadable", () => {
|
||||||
|
// A partial list is indistinguishable from a complete one downstream, and
|
||||||
|
// the caller worth protecting is the one asking "is this backed up?".
|
||||||
|
expect(
|
||||||
|
parseBackupSchedules([row(), row({ uuid: 7, frequency: null })]),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("reads a disabled schedule as disabled, however Coolify spells it", () => {
|
||||||
|
// `enabled` has no cast on the model (v4.1.2 casts() covers only the two
|
||||||
|
// float storage fields), so a tinyint column can serialize as 1/0.
|
||||||
|
expect(parseBackupSchedules([row({ enabled: 0 })])?.[0].enabled).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(parseBackupSchedules([row({ enabled: false })])?.[0].enabled).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(parseBackupSchedules([row({ enabled: 1 })])?.[0].enabled).toBe(true);
|
||||||
|
// Absent reads as enabled: Coolify's create path defaults it to true.
|
||||||
|
expect(
|
||||||
|
parseBackupSchedules([row({ enabled: undefined })])?.[0].enabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it("accepts an integer retention however it is serialized", () => {
|
||||||
|
expect(
|
||||||
|
parseBackupSchedules([
|
||||||
|
row({ database_backup_retention_amount_locally: "7" }),
|
||||||
|
])?.[0].retention,
|
||||||
|
).toBe(7);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -413,3 +413,136 @@ describe("renderDiff", () => {
|
||||||
expect(out).toMatch(/env vars not compared \(structural mode/);
|
expect(out).toMatch(/env vars not compared \(structural mode/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A database's backup schedule is a field like any other — the whole point of
|
||||||
|
// #51. These pin the four answers a live read can give, and above all pin the
|
||||||
|
// two that must never be confused: "there is no schedule" (drift, fixable) and
|
||||||
|
// "cast could not read the schedule" (not drift, not clean, said out loud).
|
||||||
|
describe("backup schedules", () => {
|
||||||
|
const wantBackup = {
|
||||||
|
kind: "database" as const,
|
||||||
|
name: "postgres",
|
||||||
|
fields: {
|
||||||
|
type: "postgresql",
|
||||||
|
backup: { frequency: "0 3 * * *", retention: 7 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const liveDb = (fields: Record<string, unknown>, extra = {}) => ({
|
||||||
|
kind: "database" as const,
|
||||||
|
name: "postgres",
|
||||||
|
uuid: "db-1",
|
||||||
|
fields: { type: "postgresql", ...fields },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is clean when the live schedule matches", () => {
|
||||||
|
const r = computeDiff(
|
||||||
|
[wantBackup],
|
||||||
|
[liveDb({ backup: { frequency: "0 3 * * *", retention: 7 } })],
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
expect(r.clean).toBe(true);
|
||||||
|
expect(r.backupsNotCompared).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports drift when the schedule differs", () => {
|
||||||
|
const r = computeDiff(
|
||||||
|
[wantBackup],
|
||||||
|
[liveDb({ backup: { frequency: "0 5 * * *", retention: 3 } })],
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
expect(r.changes[0].fieldDiffs).toEqual([
|
||||||
|
{
|
||||||
|
field: "backup",
|
||||||
|
desired: { frequency: "0 3 * * *", retention: 7 },
|
||||||
|
live: { frequency: "0 5 * * *", retention: 3 },
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(r.clean).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The defect in one test: a live database with NO schedule, declared in the
|
||||||
|
// manifest, used to be invisible. It is drift, and apply can fix it.
|
||||||
|
it("reports drift when the database has no schedule at all", () => {
|
||||||
|
const r = computeDiff([wantBackup], [liveDb({})], "full");
|
||||||
|
expect(r.clean).toBe(false);
|
||||||
|
expect(r.changes[0].fieldDiffs[0]).toMatchObject({
|
||||||
|
field: "backup",
|
||||||
|
live: undefined,
|
||||||
|
updatable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A schedule row that exists but is switched off backs nothing up. It must
|
||||||
|
// not read as clean, and it must not read as absent (apply PATCHes it rather
|
||||||
|
// than POSTing a second one).
|
||||||
|
it("reports drift when the schedule exists but is disabled", () => {
|
||||||
|
const r = computeDiff(
|
||||||
|
[wantBackup],
|
||||||
|
[
|
||||||
|
liveDb({
|
||||||
|
backup: { frequency: "0 3 * * *", retention: 7, enabled: false },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
expect(r.clean).toBe(false);
|
||||||
|
expect(r.changes[0].fieldDiffs[0].field).toBe("backup");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shape-mismatch path — the one that must not lie in EITHER direction.
|
||||||
|
it("invents no drift when the live schedule could not be read", () => {
|
||||||
|
const r = computeDiff(
|
||||||
|
[wantBackup],
|
||||||
|
[liveDb({}, { backupNotCompared: "unrecognized shape" })],
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
// Not drift: cast read nothing, so it may claim nothing. In particular it
|
||||||
|
// must NOT diff the declared block against `undefined` and report a
|
||||||
|
// confident change on a database that may be perfectly backed up.
|
||||||
|
expect(r.changes).toEqual([]);
|
||||||
|
// And not silence either.
|
||||||
|
expect(r.backupsNotCompared).toEqual([
|
||||||
|
{ name: "postgres", reason: "unrecognized shape" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so on screen, on a run that is otherwise clean", () => {
|
||||||
|
const out = renderDiff(
|
||||||
|
computeDiff(
|
||||||
|
[wantBackup],
|
||||||
|
[liveDb({}, { backupNotCompared: "unrecognized shape" })],
|
||||||
|
"full",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).toContain(
|
||||||
|
"backup schedule for database postgres declared, NOT compared — verify in the Coolify UI",
|
||||||
|
);
|
||||||
|
expect(out).toContain("(unrecognized shape)");
|
||||||
|
// Reported, but not counted as drift — an absence of evidence is not
|
||||||
|
// evidence of drift, and a run that fails on it is a run operators learn
|
||||||
|
// to force past.
|
||||||
|
expect(out).toMatch(/^clean$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about backups when none is declared", () => {
|
||||||
|
// An undeclared schedule is uncompared, not deleted: a live schedule on a
|
||||||
|
// database whose manifest is silent is left alone, and unremarked.
|
||||||
|
const out = renderDiff(
|
||||||
|
computeDiff(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "database" as const,
|
||||||
|
name: "postgres",
|
||||||
|
fields: { type: "postgresql" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[liveDb({}, { backupNotCompared: "unrecognized shape" })],
|
||||||
|
"full",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).not.toContain("backup");
|
||||||
|
expect(out).toMatch(/^clean$/m);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { fetchLive, renderAbsentTarget } from "../src/cli.js";
|
import { attachBackup, fetchLive, renderAbsentTarget } from "../src/cli.js";
|
||||||
import { CoolifyClient } from "../src/coolify.js";
|
import { CoolifyClient } from "../src/coolify.js";
|
||||||
|
|
||||||
// A Coolify that answers GET /projects with `projects`, and
|
// A Coolify that answers GET /projects with `projects`, and
|
||||||
|
|
@ -134,3 +134,110 @@ describe("renderAbsentTarget", () => {
|
||||||
expect(msg).toMatch(/--env/);
|
expect(msg).toMatch(/--env/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The read half of #51: a database's backup schedule is on its own route, so
|
||||||
|
// the live side has to go and get it. These pin the mapping from what that
|
||||||
|
// route answers onto what the diff is allowed to conclude.
|
||||||
|
describe("attachBackup", () => {
|
||||||
|
const db = () => ({
|
||||||
|
kind: "database" as const,
|
||||||
|
name: "postgres",
|
||||||
|
uuid: "db-1",
|
||||||
|
fields: { type: "postgresql" },
|
||||||
|
});
|
||||||
|
// A Coolify whose GET /databases/db-1/backups answers with `body` (or a
|
||||||
|
// status, to exercise the unreachable path).
|
||||||
|
const client = (body: unknown, status = 200) =>
|
||||||
|
new CoolifyClient(
|
||||||
|
"https://coolify.test",
|
||||||
|
"tok",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(status === 200 ? JSON.stringify(body) : "boom", {
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
) as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("reads a schedule onto the live fields, in the desired key order", async () => {
|
||||||
|
const l = db();
|
||||||
|
await attachBackup(
|
||||||
|
client([
|
||||||
|
{
|
||||||
|
uuid: "s1",
|
||||||
|
frequency: "0 3 * * *",
|
||||||
|
database_backup_retention_amount_locally: 7,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
l,
|
||||||
|
);
|
||||||
|
// Key order matters: computeDiff compares by JSON.stringify, against the
|
||||||
|
// object resolve.ts builds. Same keys, same order, or every run drifts.
|
||||||
|
expect(JSON.stringify(l.fields.backup)).toBe(
|
||||||
|
JSON.stringify({ frequency: "0 3 * * *", retention: 7 }),
|
||||||
|
);
|
||||||
|
expect(l.backupNotCompared).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves fields.backup absent when the database genuinely has none", async () => {
|
||||||
|
const l = db();
|
||||||
|
await attachBackup(client([]), l);
|
||||||
|
// Absence IS the answer here, and a trustworthy one: a declared backup is
|
||||||
|
// then real drift, and apply creates the schedule.
|
||||||
|
expect("backup" in l.fields).toBe(false);
|
||||||
|
expect(l.backupNotCompared).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries a disabled schedule through as disabled", async () => {
|
||||||
|
const l = db();
|
||||||
|
await attachBackup(
|
||||||
|
client([
|
||||||
|
{
|
||||||
|
uuid: "s1",
|
||||||
|
frequency: "0 3 * * *",
|
||||||
|
database_backup_retention_amount_locally: 7,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
l,
|
||||||
|
);
|
||||||
|
// The row exists (so apply PATCHes rather than POSTing a second one) but it
|
||||||
|
// backs nothing up (so it must not compare equal to a declared block).
|
||||||
|
expect(l.fields.backup).toEqual({
|
||||||
|
frequency: "0 3 * * *",
|
||||||
|
retention: 7,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the read not-compared when Coolify cannot be read", async () => {
|
||||||
|
const l = db();
|
||||||
|
await attachBackup(client(null, 500), l);
|
||||||
|
expect("backup" in l.fields).toBe(false);
|
||||||
|
expect(l.backupNotCompared).toMatch(/unreachable|does not recognize/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the read not-compared when a database carries several schedules", async () => {
|
||||||
|
const l = db();
|
||||||
|
await attachBackup(
|
||||||
|
client([
|
||||||
|
{
|
||||||
|
uuid: "s1",
|
||||||
|
frequency: "0 3 * * *",
|
||||||
|
database_backup_retention_amount_locally: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: "s2",
|
||||||
|
frequency: "0 9 * * *",
|
||||||
|
database_backup_retention_amount_locally: 2,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
l,
|
||||||
|
);
|
||||||
|
// A manifest declares one schedule. Picking one of two to compare against
|
||||||
|
// would be a coin toss reported as a fact.
|
||||||
|
expect(l.backupNotCompared).toMatch(/2 schedules/);
|
||||||
|
expect("backup" in l.fields).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -137,13 +137,9 @@ environments:
|
||||||
join(dir, ".infra", "env", "core-api.staging.env.template"),
|
join(dir, ".infra", "env", "core-api.staging.env.template"),
|
||||||
"PORT=3000\nMG=${MG}\n",
|
"PORT=3000\nMG=${MG}\n",
|
||||||
);
|
);
|
||||||
const { desired, resolvedEnvs, backupSchedules } = desiredFromManifest(
|
const { desired, resolvedEnvs } = desiredFromManifest(dir, "staging", {
|
||||||
dir,
|
MG: "secret-v",
|
||||||
"staging",
|
});
|
||||||
{
|
|
||||||
MG: "secret-v",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(desired).toHaveLength(1);
|
expect(desired).toHaveLength(1);
|
||||||
expect(desired[0]).toMatchObject({
|
expect(desired[0]).toMatchObject({
|
||||||
kind: "application",
|
kind: "application",
|
||||||
|
|
@ -162,9 +158,13 @@ environments:
|
||||||
value: "secret-v",
|
value: "secret-v",
|
||||||
secret: true,
|
secret: true,
|
||||||
});
|
});
|
||||||
expect(backupSchedules).toEqual({});
|
|
||||||
});
|
});
|
||||||
it("routes a database backup block into backupSchedules, not fields", () => {
|
// The reverse of what this file used to assert. `backup` was deliberately
|
||||||
|
// routed AROUND `fields` into a side channel, because live Coolify was
|
||||||
|
// believed not to expose a schedule back; it does (GET
|
||||||
|
// /databases/{uuid}/backups), and the side channel is what made a `backup:`
|
||||||
|
// block added to an existing database silently do nothing (#51).
|
||||||
|
it("puts a database backup block in fields, so it is diffed like any other", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|
@ -180,16 +180,35 @@ environments:
|
||||||
backup: { frequency: "0 3 * * *", retention: 7 }
|
backup: { frequency: "0 3 * * *", retention: 7 }
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
const { desired, backupSchedules } = desiredFromManifest(
|
const { desired } = desiredFromManifest(dir, "staging", {});
|
||||||
dir,
|
expect(desired[0].fields).toEqual({
|
||||||
"staging",
|
type: "postgresql",
|
||||||
{},
|
version: "17",
|
||||||
);
|
backup: { frequency: "0 3 * * *", retention: 7 },
|
||||||
expect(desired[0].fields).toEqual({ type: "postgresql", version: "17" });
|
|
||||||
expect(backupSchedules).toEqual({
|
|
||||||
postgres: { frequency: "0 3 * * *", retention: 7 },
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it("leaves `backup` out of fields entirely when none is declared", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||||
|
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".infra", "manifest.yaml"),
|
||||||
|
`project: widget
|
||||||
|
environments:
|
||||||
|
staging:
|
||||||
|
applications: {}
|
||||||
|
databases:
|
||||||
|
postgres:
|
||||||
|
type: postgresql
|
||||||
|
version: "17"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const { desired } = desiredFromManifest(dir, "staging", {});
|
||||||
|
expect(desired[0].fields).toEqual({ type: "postgresql", version: "17" });
|
||||||
|
// Undeclared means uncompared, NOT "delete whatever is there": a live
|
||||||
|
// schedule on a database whose manifest says nothing about backups is left
|
||||||
|
// alone, like every other thing apply never removes.
|
||||||
|
expect("backup" in desired[0].fields).toBe(false);
|
||||||
|
});
|
||||||
it("warns when a service declares domains (unhonorable by apply on Coolify 4.1.2)", () => {
|
it("warns when a service declares domains (unhonorable by apply on Coolify 4.1.2)", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||||
mkdirSync(join(dir, ".infra"), { recursive: true });
|
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,6 @@ describe("buildExecutor createResource (application, dockercompose)", () => {
|
||||||
serverName: "prod-box",
|
serverName: "prod-box",
|
||||||
orgRepo: "acme/widget",
|
orgRepo: "acme/widget",
|
||||||
bindingEnv: "prod",
|
bindingEnv: "prod",
|
||||||
backupSchedules: {},
|
|
||||||
});
|
});
|
||||||
const uuid = await exec.createResource({
|
const uuid = await exec.createResource({
|
||||||
kind: "application",
|
kind: "application",
|
||||||
|
|
@ -255,7 +254,6 @@ describe("buildExecutor createResource (application, dockercompose)", () => {
|
||||||
serverName: "prod-box",
|
serverName: "prod-box",
|
||||||
orgRepo: "acme/widget",
|
orgRepo: "acme/widget",
|
||||||
bindingEnv: "prod",
|
bindingEnv: "prod",
|
||||||
backupSchedules: {},
|
|
||||||
});
|
});
|
||||||
await exec.createResource({
|
await exec.createResource({
|
||||||
kind: "application",
|
kind: "application",
|
||||||
|
|
@ -361,7 +359,6 @@ describe("buildExecutor createResource (destination placement)", () => {
|
||||||
orgRepo: "acme/widget",
|
orgRepo: "acme/widget",
|
||||||
bindingEnv: "prod",
|
bindingEnv: "prod",
|
||||||
destinationUuid: "dest-abc",
|
destinationUuid: "dest-abc",
|
||||||
backupSchedules: {},
|
|
||||||
});
|
});
|
||||||
await exec.createResource(change);
|
await exec.createResource(change);
|
||||||
expect(bodies[path]?.destination_uuid).toBe("dest-abc");
|
expect(bodies[path]?.destination_uuid).toBe("dest-abc");
|
||||||
|
|
@ -390,7 +387,6 @@ describe("buildExecutor createResource (destination placement)", () => {
|
||||||
serverName: "prod-box",
|
serverName: "prod-box",
|
||||||
orgRepo: "acme/widget",
|
orgRepo: "acme/widget",
|
||||||
bindingEnv: "prod",
|
bindingEnv: "prod",
|
||||||
backupSchedules: {},
|
|
||||||
});
|
});
|
||||||
await exec.createResource(change);
|
await exec.createResource(change);
|
||||||
expect(bodies[path]).not.toHaveProperty("destination_uuid");
|
expect(bodies[path]).not.toHaveProperty("destination_uuid");
|
||||||
|
|
@ -562,7 +558,6 @@ describe("buildExecutor createResource (environment reconcile)", () => {
|
||||||
serverName: "prod-box",
|
serverName: "prod-box",
|
||||||
orgRepo: "acme/widget",
|
orgRepo: "acme/widget",
|
||||||
bindingEnv: "prod",
|
bindingEnv: "prod",
|
||||||
backupSchedules: {},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -796,7 +791,6 @@ describe("buildExecutor createResource (multi-destination 400, #41)", () => {
|
||||||
serverName: "prod-box",
|
serverName: "prod-box",
|
||||||
orgRepo: "heavy-duty/incubator",
|
orgRepo: "heavy-duty/incubator",
|
||||||
bindingEnv: "prod",
|
bindingEnv: "prod",
|
||||||
backupSchedules: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const kinds = [
|
const kinds = [
|
||||||
|
|
@ -893,3 +887,219 @@ describe("databaseVersionFromImage / defaultDatabaseImage", () => {
|
||||||
expect(databaseVersionFromImage(image)).toBe("17");
|
expect(databaseVersionFromImage(image)).toBe("17");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The write half of #51. Backup schedules live on their own route
|
||||||
|
// (/databases/{uuid}/backups), so `apply` has to read that route to know
|
||||||
|
// whether to POST or PATCH — and used to do neither outside the create branch.
|
||||||
|
describe("buildExecutor backup schedules", () => {
|
||||||
|
type Call = { method: string; path: string; body?: unknown };
|
||||||
|
|
||||||
|
// Records every request so a test can assert not just what was written, but
|
||||||
|
// what was NOT — a schedule silently skipped is the whole defect.
|
||||||
|
function recorder(handler: (path: string, init?: RequestInit) => Response): {
|
||||||
|
calls: Call[];
|
||||||
|
fetchImpl: typeof fetch;
|
||||||
|
} {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||||
|
const path = new URL(String(url)).pathname;
|
||||||
|
calls.push({
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
path,
|
||||||
|
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||||
|
});
|
||||||
|
return handler(path, init);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
return { calls, fetchImpl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
projectName: "widget",
|
||||||
|
envName: "prod",
|
||||||
|
serverUuid: "srv-1",
|
||||||
|
githubAppUuid: "gh-1",
|
||||||
|
serverName: "prod-box",
|
||||||
|
orgRepo: "acme/widget",
|
||||||
|
bindingEnv: "prod",
|
||||||
|
s3DestinationUuid: "s3-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = { frequency: "0 3 * * *", retention: 7 };
|
||||||
|
const liveRow = {
|
||||||
|
uuid: "sched-1",
|
||||||
|
frequency: "0 5 * * *",
|
||||||
|
database_backup_retention_amount_locally: 3,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// THE FIX. A database that exists and has no schedule gets one on update —
|
||||||
|
// before this, `apply` wrote a schedule only inside the create branch, so
|
||||||
|
// adding `backup:` to a live database was a clean run and zero backups.
|
||||||
|
it("CREATES the schedule on update when the database has none", async () => {
|
||||||
|
const { calls, fetchImpl } = recorder((path, init) => {
|
||||||
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "POST")
|
||||||
|
return new Response(JSON.stringify({ uuid: "sched-new" }), {
|
||||||
|
status: 201,
|
||||||
|
});
|
||||||
|
if (path === "/api/v1/databases/db-1/backups")
|
||||||
|
return new Response(JSON.stringify([]), { status: 200 }); // read: none
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
});
|
||||||
|
const exec = buildExecutor(
|
||||||
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await exec.updateFields("db-1", "database", { backup: schedule });
|
||||||
|
const post = calls.find((c) => c.method === "POST");
|
||||||
|
expect(post?.path).toBe("/api/v1/databases/db-1/backups");
|
||||||
|
expect(post?.body).toEqual({
|
||||||
|
frequency: "0 3 * * *",
|
||||||
|
database_backup_retention_amount_locally: 7,
|
||||||
|
save_s3: true,
|
||||||
|
s3_storage_uuid: "s3-1",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCHES the existing schedule rather than adding a second one", async () => {
|
||||||
|
const { calls, fetchImpl } = recorder((path, init) => {
|
||||||
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
||||||
|
return new Response(JSON.stringify([liveRow]), { status: 200 });
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
});
|
||||||
|
const exec = buildExecutor(
|
||||||
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await exec.updateFields("db-1", "database", { backup: schedule });
|
||||||
|
expect(calls.filter((c) => c.method === "POST")).toEqual([]);
|
||||||
|
const patch = calls.find((c) => c.method === "PATCH");
|
||||||
|
expect(patch?.path).toBe("/api/v1/databases/db-1/backups/sched-1");
|
||||||
|
expect(patch?.body).toMatchObject({
|
||||||
|
frequency: "0 3 * * *",
|
||||||
|
database_backup_retention_amount_locally: 7,
|
||||||
|
// Re-enabled: declaring `backup:` asks for backups, not for a disabled
|
||||||
|
// row that looks like backups.
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not PATCH the database itself for a backup-only change", async () => {
|
||||||
|
const { calls, fetchImpl } = recorder((path, init) => {
|
||||||
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
||||||
|
return new Response(JSON.stringify([liveRow]), { status: 200 });
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
});
|
||||||
|
const exec = buildExecutor(
|
||||||
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await exec.updateFields("db-1", "database", { backup: schedule });
|
||||||
|
// `backup` is not a column on the database — it must never reach the
|
||||||
|
// database's own update body, and an empty body is not worth a write.
|
||||||
|
expect(calls.some((c) => c.path === "/api/v1/databases/db-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Degrade honestly: an apply that promised a backup schedule and cannot tell
|
||||||
|
// whether one already exists must STOP, not guess. POSTing blind would
|
||||||
|
// duplicate an existing schedule; skipping is the silent no-op being fixed.
|
||||||
|
it("refuses to guess when the schedule read fails", async () => {
|
||||||
|
const { calls, fetchImpl } = recorder((path, init) => {
|
||||||
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
||||||
|
return new Response("gateway timeout", { status: 504 });
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
});
|
||||||
|
const exec = buildExecutor(
|
||||||
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
exec.updateFields("db-1", "database", { backup: schedule }),
|
||||||
|
).rejects.toThrow(/cannot set the declared backup schedule/);
|
||||||
|
expect(calls.filter((c) => c.method === "POST")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to guess which of several schedules the manifest meant", async () => {
|
||||||
|
const { fetchImpl } = recorder((path, init) => {
|
||||||
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify([liveRow, { ...liveRow, uuid: "sched-2" }]),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
return new Response("{}", { status: 200 });
|
||||||
|
});
|
||||||
|
const exec = buildExecutor(
|
||||||
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
exec.updateFields("db-1", "database", { backup: schedule }),
|
||||||
|
).rejects.toThrow(/holds 2 backup schedules/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an undeclared schedule alone (apply never removes)", async () => {
|
||||||
|
const { calls, fetchImpl } = recorder(
|
||||||
|
() => new Response("{}", { status: 200 }),
|
||||||
|
);
|
||||||
|
const exec = buildExecutor(
|
||||||
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await exec.updateFields("db-1", "database", { version: "17" });
|
||||||
|
expect(calls.some((c) => c.path.includes("/backups"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still POSTs the schedule on create, without a read", async () => {
|
||||||
|
const { calls, fetchImpl } = recorder((path) => {
|
||||||
|
if (path === "/api/v1/projects")
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
if (path === "/api/v1/projects/proj-1/environments")
|
||||||
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
if (path === "/api/v1/databases/postgresql")
|
||||||
|
return new Response(JSON.stringify({ uuid: "db-9" }), { status: 201 });
|
||||||
|
return new Response(JSON.stringify({ uuid: "sched-9" }), { status: 201 });
|
||||||
|
});
|
||||||
|
const exec = buildExecutor(
|
||||||
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await exec.createResource({
|
||||||
|
kind: "database",
|
||||||
|
name: "postgres",
|
||||||
|
op: "create",
|
||||||
|
fieldDiffs: [
|
||||||
|
{ field: "type", desired: "postgresql", updatable: false },
|
||||||
|
{ field: "backup", desired: schedule, updatable: true },
|
||||||
|
],
|
||||||
|
envDiffs: [],
|
||||||
|
});
|
||||||
|
// A database created a moment ago provably has no schedule: POST straight
|
||||||
|
// out, with no read that could fail and abort the create.
|
||||||
|
expect(
|
||||||
|
calls.some((c) => c.method === "GET" && c.path.includes("/backups")),
|
||||||
|
).toBe(false);
|
||||||
|
const post = calls.find((c) => c.path === "/api/v1/databases/db-9/backups");
|
||||||
|
expect(post?.body).toMatchObject({ frequency: "0 3 * * *", save_s3: true });
|
||||||
|
// And `backup` never reaches the database's own create body.
|
||||||
|
const create = calls.find((c) => c.path === "/api/v1/databases/postgresql");
|
||||||
|
expect(create?.body).not.toHaveProperty("backup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a declared schedule with no s3_destination configured", async () => {
|
||||||
|
const { fetchImpl } = recorder(
|
||||||
|
() => new Response(JSON.stringify([]), { status: 200 }),
|
||||||
|
);
|
||||||
|
const exec = buildExecutor(
|
||||||
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
||||||
|
{ ...ctx, s3DestinationUuid: undefined },
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
exec.updateFields("db-1", "database", { backup: schedule }),
|
||||||
|
).rejects.toThrow(/no s3_destination UUID/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue