feat: diff and apply a database's backup schedule (#51) #61
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#61
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/backup-schedule-diff"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The false premise
docs/semantics.mdfiled backup schedules under "Known limitations, not defects", on the claim that a manifest database'sbackupblock is kept out of the diff because "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, and cast had been POSTing to it all along (create branch) without ever reading it. "Coolify doesn't expose it back" meant "it isn't on the resource I happened to read."The cost was live, not hypothetical:
backup:block was declared never got one —applyset the schedule only inside the create branch, so addingbackup:to an existing Postgres was a clean run and zero backups.diff.--fulldiff that gates a production cutover passed on an unbacked-up production database.What the source actually says (citations)
I could not probe a live Coolify (no credentials; cast is operator-run against real boxes). The vendored OpenAPI documents the GET body as "Content is very complex. Will be implemented later.", so I read the v4.1.2 source instead:
app/Http/Controllers/Api/DatabasesController.php—database_backup_details_uuid()ends: A raw Eloquent collection → a JSON array ofScheduledDatabaseBackuprows (no API resource, noremoveSensitiveData), plus an eager-loadedexecutionsarray cast ignores. A database with no schedule returns200 [], never a 404.app/Models/ScheduledDatabaseBackup.php—$fillablegives the field names:uuid,enabled,save_s3,frequency,database_backup_retention_amount_locally(the retention field cast already POSTs on create), …casts()covers only the two float storage fields, soenabled(tinyint) can serialize as1/0.frequencyround-trips verbatim: the POST/PATCH handlersvalidate_cron_expression($request->frequency)(returns a bool) and then store$request->only($backupConfigFields)unchanged — no mutator on the model."0 3 * * *"reads back as"0 3 * * *"; the preset words (daily, …) read back as themselves. This is what makes the field diffable, and it is exactly what the "spurious drift" fear assumed away without checking.Fields settled on:
frequency(string, verbatim) andretention(=database_backup_retention_amount_locally, integer), plusenabledcarried so a disabled schedule cannot read as backed-up.Branch taken: 2 + 3 (real read), not the branch-4 fallback
The source proves the GET is readable, so this reads it into the live side and diffs
frequency+retentionlike any other field, and makesapplyset the schedule on update as well as create (POSTorPATCH, decided by a read). Branch 4 ("declared, NOT compared") is kept, but only as the honest degradation for a read that fails — see below.What changed
resolve.ts—backupis now a normal entry in a database'sfields, not a side channel.desiredFromManifestno longer returnsbackupSchedules.coolify.ts—parseBackupSchedules(a pure parser) +client.databaseBackups(uuid). Every unreadable answer collapses toundefined("cannot say"); only a genuine[]means "no schedule". A failed read (including a 404 — which here means database not found, never "no schedules") returnsundefined, never "none".cli.ts—fetchLive(..., { backups: true })attaches the live schedule fordiff/apply(opt-in, so the read-side sweeps don't pay for it);attachBackupmaps the four answers. The executor sets the schedule on update viareconcileBackupSchedule(POST if none, PATCH if one), anddatabaseApiFieldsstripsbackupso it never reaches the database's own body.diff.ts—backupdiffs like any field; abackupNotComparedcarrier removes it from the comparison entirely (never diffed againstundefined) and prints a line.docs/semantics.md— new Backup schedules section; the:606"Known limitations" entry struck through and corrected; the:541disaster-recovery line's reason corrected (the draft path still doesn't read them — noted honestly, see below).Degrades honestly (mandatory, since no live probe): an unreadable or unrecognized response can only ever print
backup schedule for database <name> declared, NOT compared — verify in the Coolify UI— never invented drift, 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; skipping is the silent no-op being fixed.>1schedule on one database is also "NOT compared" (a manifest declares one). Disabled schedules diff as drift and are re-enabled.OPERATOR ACT — confirm the shape against your own box
cast's read is built to the v4.1.2 source above. Before trusting a backup diff on a live box, confirm that box agrees:
Expected: a JSON array; each element carries
"frequency"(string) and"database_backup_retention_amount_locally"(integer), and"enabled".If the output disagrees:
declared, NOT comparedon every run (it will not invent drift or a false clean). Open a follow-up with the real shape; the parser (parseBackupSchedulesinsrc/coolify.ts) is the one place to adjust.enabledis a string like"1"/"0"— already handled.200 []— cast reads any failed GET as "unreadable, NOT compared", so it stays safe; but tell us, because it means the "no schedule → drift" path can't fire and a missing backup would go unreported.Tests added (+42; 294 → 336, all green)
coolify.test.ts—parseBackupSchedulesacross every unreadable shape (non-array, the spec's literal placeholder string, bad/partial rows, mixed list), theenabled1/0/absent cases, and the dangerous-404 client path.diff.test.ts— clean / drift / no-schedule / disabled, and the two that must not lie: unreadable invents no drift and is not silent.live-lookup.test.ts—attachBackup's four answers and desired-side key order.wire.test.ts— the executor: create-on-update (the fix), PATCH-not-duplicate, no empty PATCH on the database, refuse-on-failed-read, refuse-on-multiple, leave-undeclared-alone, still-POST-on-create.backup-cli.test.ts(new) — end to end throughdist/cli.js: the no-schedule case that used to exit 0 now exits 1, and the failed read printsNOT comparedwhile staying clean.Not done, and why
inventory --emit-draftstill does not read backups —draft.tsis outside this issue's file list and a sibling PR touches that area. The DR/UNCAPTURED text is corrected to say the reason is "the draft path hasn't been taught to read them yet", not "they can't be read". Worth a follow-up so a rebuilt-from-draft box carries its schedule.--fullrequirement for reading backups is unchanged. Backups read ondiff/apply; a structural diff still can't see them. In scope would be creeping; noted here instead.Heads up for the merge: this is scheduled last of the parallel set and touches
diff.ts/cli.ts, so expect to rebase. Thebackup-cli.test.tsfile is new (no conflict); thediff.tschanges are additive (a new report field + a render block).Closes #51.