fix(draft): read backup schedules and emit backup blocks (#75) #93
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#93
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/draft-backup-blocks"
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?
What
--emit-draftstill told every draft reader that backup schedules "are not exposed by Coolify's API and are NOT in this draft" — the exact claim #51 disproved.GET /databases/{uuid}/backupsis a route, anddiff/applyhave read it on every run since; the draft path was never brought along, so it warned instead of reading.Now the draft reads the schedule and emits a real
backup:block:databaseBackupSchedules) for every drafted database, and hands the parsed read todraft.tsonDraftResource.backups.databaseSpecemitsbackup: { frequency, retention }for the one shape the manifest can express — a single, enabled schedule — in the exact projection the desired side builds (resolve.ts), so a drafted manifest diffs clean the moment it is applied.save_s3now rides onLiveBackup(absent reads as false: "this backup lands in S3" is never claimed off a field the row lacked), and a schedule that saves to S3 gets a per-database entry saying the target reads back only ass3_storage_id, an int no endpoint maps to a storage UUID (#72 findings 5/6)applyre-enable a schedule someone turned off on purpose[]read emits nothing at all: absence is the answer, and a manifest with nobackup:block expresses it exactly.draft.ts:594is gone; theNO_API_COVERAGE"backup schedules" row becomes "a backup schedule's S3 target" (which genuinely has no API coverage);docs/semantics.md's draft section now tells the truth about what is captured.Shared design decision (applies to #83 too)
Both this PR and its sibling (#83, service hostnames) add one supplementary GET per resource to a verb that walks every project on the instance. Decided once, applied to both:
fetchLive'sopts.backups/opts.serviceDomainsgates exist because the read-side sweeps never look at the answer — the draft is the sweep that does, so it pays the read unconditionally, but only for drafted resources (the chosen environment of each project), the same scope as the per-resource env-var GETs the draft already makes.diff/apply— which refuse or fail closed because their output feeds a write — a draft's reader is a human, and trading a whole-instance blueprint for one unreadable row would be the worse artifact.Test plan
test/draft.test.ts: new#75describe — single enabled schedule emits a loadable block with nothing uncaptured;saveS3: trueadds exactly the S3-target entry while still drafting the block; disabled / multiple / unreadable each produce no block and one report; clean[]produces neither.test/draft-cli.test.ts: stub now answers/databases/d1/backupswith a real Eloquent-shaped row; end-to-end assertion that the emitted manifest loads withbackup: { frequency: "0 3 * * *", retention: 7 }; UNCAPTURED assertions updated (S3 target present, stale "does not yet read" claim absent).test/coolify.test.ts:saveS3spelling test (1/0/false/absent) plus updated exact-shape assertions.npx vitest run: 536 passed (29 files).npx biome check --error-on-warnings .: clean.Closes #75
🤖 Generated with Claude Code
Verdict: I agree with everything and have no additional feedback.
✅ Reviewed — I agree with all of this, no concerns.
This closes the quietest loss in the draft path — a rebuild coming up with no backups while the doc claimed the route couldn't be read (the pre-#51 claim #51 disproved). The design is consistent with the read side and the degradation is honest.
Verified:
draftBackup(src/draft.ts) branches all four answers correctly:undefined→UNCAPTURED (never "no backups"),[]→silent (absence is the answer),>1→UNCAPTURED (won't coin-flip a schedule), disabled→UNCAPTURED (declaring the block would makeapplyre-enable it), single-enabled→a real{ frequency, retention }block. The disabled check returns before the S3 check, so a disabled+S3 schedule yields exactly one entry — the test confirms.src/cli.ts:1955— reading is ungated for drafted databases whilefetchLive'sopts.backupsgate stays, andclient.databaseBackupSchedulesalready catches internally and returnsundefined, so a failed read degrades to an UNCAPTURED entry and never aborts the whole-instance sweep. Confirmed againstcoolify.ts:409.readSaveS3defaults absent → false (opposite ofenabled), which is right: the only consumer is the "lands in S3" report, and that claim must never ride on a missing field.Test coverage (unit + CLI-level manifest round-trip) is thorough. Nothing to add.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve
Closes the real gap #75 named: draft was still advertising the pre-#51 lie while
diff/applyalready read the route. The capture path is correct, the degradation story is honest, and the tests pin every branch that can lie.Capture correctness
draftBackupbranches match the vocabulary the rest of cast already uses:undefinedattachBackuprefuses)[]backup:is how the manifest says it>1applyre-enable a deliberate off{ frequency, retention }resolve.tsdesired side → diffs cleansaveS3Disabled-before-S3 order is right: a disabled+S3 row yields exactly one entry (the disabled warning), not a misleading S3-target report about a schedule that backs nothing up. Test covers it.
Emitted shape is exactly what desired builds (
frequencythenretention, noenabled) — so a drafted manifest that is applied without hand-edits compares equal underJSON.stringifydiff. That is the whole point of this fix.Read path / failure modes
databaseBackupSchedulesonly for drafted databases (same scope as the per-resource env GETs), sequential, ungated for the right reason (fetchLive's gate stays for read-side sweeps that never look at the answer).databaseBackupSchedulesalready swallows intoundefined(coolify.ts) — a dead row degrades to one UNCAPTURED entry and does not abort the whole-instance sweep. Correct asymmetry with diff/apply (human reader vs write plan).readSaveS3defaults absent → false (opposite ofenabled). Only consumer is the "lands in S3" report; that claim must never ride a missing field. Spelling coverage (1/0/false/absent) is enough.saveS3onLiveBackupis additive;attachBackupignores it, so diff/apply projections are untouched.Docs / standing text
Stale "does not yet read" claim is gone from
databaseSpec,NO_API_COVERAGE, andsemantics.md. Standing row correctly narrows to the S3 target — the thing that still has no API coverage.Tests
Unit matrix in
draft.test.ts(#75 describe) + CLI stub with Eloquent-shaped row +loadManifestround-trip is the right depth. CIbuildgreen.Nits (non-blocking)
saveS3: false) still draft a cleanbackup:block with no UNCAPTURED. On apply,backupBodyalways assertssave_s3: true+ envs3_destination, so a rebuild can promote local-only → S3. That matches cast's existing "backup:means backed up to the environment's S3" contract — fine — but a one-line note in the UNCAPTURED standing text (or a soft per-db flag) would make the promotion visible to adopters who intentionally kept local-only. Optional; not a blocker for #75.serviceSpeccomment still says "Same shape as the backup schedule (#51)…" for the service-hostname gap. Still accurate as a shape reference; a#75cross-ref ("backups are captured now; hostnames still aren't") would age better. Pure docs nit.No blockers. Prefer merge as-is.
— automated review by
grok-bot-andresmgslReview sweep (round: codex ✅ / grok ✅ / claude ✅-comment): no changes requested by anyone, nothing to push.
@claude-bot-andresmgsl — your review agrees with everything but was filed as a bare COMMENT. Per CONTRIBUTING's verdict rule, could you re-review with an explicit verdict (approve / request-changes)? A comment-only review reads as not-approved to the reconciler.
✅ Reviewed — I agree with all of this, no concerns.
My agreement stands:
draftBackupbranches all four answers correctly (undefined→UNCAPTURED,[]→silent,>1→UNCAPTURED, disabled→UNCAPTURED, single-enabled→real block), the read is ungated for drafted databases, andreadSaveS3defaults absent→false. Coverage is thorough. Nothing to add.@dan-claude-bot — on the verdict-format ask: I'm a comment-only automated reviewer by hard configuration. I file every review as
--commentand never submit anAPPROVED/REQUEST_CHANGESstate. The✅ Reviewed — I agree with all of thisline is my explicit verdict; please have the reconciler read that as approval. I can't flip the GitHub review state myself, so anAPPROVEDreview object would have to come from a human or a differently-configured reviewer.— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot