Repository navigation
feat(selfupdate): snapshot state.db + config before each update - #141
Conversation
First slice of #140 — build the safety net every update relies on before we build the rollback UI. Before the main container hands off to ftw-updater, the self-update handler now captures a point-in-time snapshot of state.db (via SQLite VACUUM INTO) plus a copy of config.yaml into <data>/snapshots/<timestamp>_<fromVer>_to_<toVer>/ along with a small meta.json so the directory is self-describing for a future rollback flow. Retention: last 5 snapshots. VACUUM INTO is point-in-time consistent under live writers, so nothing needs to pause. A snapshot failure aborts the update with 500 — the whole promise of the "Update" button is that the operator keeps a rollback point, and shipping without one would quietly break that promise. Operators who explicitly opted out (empty SnapshotDir) get the old no-snapshot flow. New endpoint: GET /api/version/snapshots lists what's on disk (newest-first) so the upcoming rollback UI in PR 2 has a source to drive the picker from. UI touch: a one-line "🛟 A snapshot of your data and config is saved before each update" hint in the update modal so the operator sees the safety net promise before clicking Update. Tests: - TestSnapshotToCapturesLiveState — VACUUM INTO produces a usable copy with the seeded rows; source DB is still writable after; snapshot is point-in-time. - TestSnapshotToRefusesExistingFile — can't overwrite, guards against timestamp collisions. - TestVersionUpdate_CreatesSnapshotBeforeTrigger — snapshot is created even when the sidecar trigger fails (no sidecar in tests); rollback point exists regardless of update outcome. - TestVersionUpdate_SnapshotIncludesConfigWhenPathSet — config.yaml is copied when ConfigPath is wired. - TestVersionUpdate_SnapshotFailureAborts — unreachable snapshot dir ⇒ 500, no trigger attempted. - TestSnapshotsPruneToKeepNewest — retention cap honored after many snapshots. - TestVersionSnapshots_ListsNewestFirst — list endpoint returns newest-first. Follows up with the rollback-by-tag flow + warning UI in a separate PR linked from #140. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a98402a05
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const snapshotHint = hasUpdate | ||
| ? `<p class="snapshot-hint">🛟 A snapshot of your data and config is saved before each update so you can roll back if needed.</p>` |
There was a problem hiding this comment.
Gate rollback hint on actual snapshot support
This text is shown whenever an update exists, but snapshot creation is optional in the backend (handleVersionUpdate skips it when SnapshotDir is empty). In deployments that intentionally disable snapshots, the UI still promises a rollback point will be saved, which can mislead operators into taking an update they believe is reversible when it is not.
Useful? React with 👍 / 👎.
…date Follow-up to #141. Snapshot-before-update is the right default — every operator should keep a rollback point most of the time — but there are two cases where the behaviour is friction: 1. The retained set of 5 recent snapshots already covers the operator for the kind of rollback they'd need. 2. Disk is tight (small SD card, couple hundred MB per snapshot adds up). Added a small unobtrusive checkbox under the snapshot hint: *"Skip backup for this update"*. Default unchecked (safety first). When ticked the UI sends \`{"skip_snapshot": true}\` in the POST body and the handler bypasses \`createPreUpdateSnapshot\` entirely, returning \`snapshot_skipped: true\` in the 202 response so client/log can tell the paths apart. Snapshot is still created by default for every Update click. And the deploy-time opt-out (empty SnapshotDir) keeps working exactly the same — the new body flag and the deploy flag are both honoured. Restart flow untouched (never snapshotted anyway) — no body sent there. Test: TestVersionUpdate_SkipSnapshotOptOut exercises the new path — post \`{"skip_snapshot":true}\`, verify the snapshots directory stays empty even as the handler proceeds to trigger (sidecar absence is a red herring for this assertion; we care that the snapshot step was bypassed regardless of downstream outcome). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…date (#149) Follow-up to #141. Snapshot-before-update is the right default — every operator should keep a rollback point most of the time — but there are two cases where the behaviour is friction: 1. The retained set of 5 recent snapshots already covers the operator for the kind of rollback they'd need. 2. Disk is tight (small SD card, couple hundred MB per snapshot adds up). Added a small unobtrusive checkbox under the snapshot hint: *"Skip backup for this update"*. Default unchecked (safety first). When ticked the UI sends \`{"skip_snapshot": true}\` in the POST body and the handler bypasses \`createPreUpdateSnapshot\` entirely, returning \`snapshot_skipped: true\` in the 202 response so client/log can tell the paths apart. Snapshot is still created by default for every Update click. And the deploy-time opt-out (empty SnapshotDir) keeps working exactly the same — the new body flag and the deploy flag are both honoured. Restart flow untouched (never snapshotted anyway) — no body sent there. Test: TestVersionUpdate_SkipSnapshotOptOut exercises the new path — post \`{"skip_snapshot":true}\`, verify the snapshots directory stays empty even as the handler proceeds to trigger (sidecar absence is a red herring for this assertion; we care that the snapshot step was bypassed regardless of downstream outcome). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to #141, driven by a field-tester request: *"en rollback funktion som listar alla snapshots, så man inte måste ssh'a in, och möjlighet att radera gamla snapshots från samma lista"* — Erik @Xorath, 2026-04-20. This PR covers the first half (list + delete). Rollback-to-snapshot is tracked as a separate PR so the review stays focused per logical change. Backend - New endpoint DELETE /api/version/snapshots/{id}. Validates the ID is a simple directory name (no traversal, no dot-segments), checks the target exists + is inside SnapshotDir, then os.RemoveAll. - GET /api/version/snapshots already returned the list — UI just wasn't showing it. Frontend - Update modal grows a collapsible "Backup snapshots (N)" section below the 🛟 hint. Shows created-at, from→to version, size in MB, and a Delete button per row. - Empty state tells the operator where snapshots come from. - Delete uses window.confirm + an optimistic "deleting…" placeholder so rapid double-clicks can't fire duplicates. - Refreshes on modal open and after every delete. Tests - TestVersionSnapshots_DeleteByID — happy path, dir gone after DELETE. - TestVersionSnapshots_DeleteRejectsInvalidID — missing id returns 404; traversal attempts are rejected by Go's ServeMux before reaching the handler (accept any non-200 outcome as sufficient). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to #141, driven by a field-tester request: *"en rollback funktion som listar alla snapshots, så man inte måste ssh'a in, och möjlighet att radera gamla snapshots från samma lista"* — Erik @Xorath, 2026-04-20. This PR covers the first half (list + delete). Rollback-to-snapshot is tracked as a separate PR so the review stays focused per logical change. Backend - New endpoint DELETE /api/version/snapshots/{id}. Validates the ID is a simple directory name (no traversal, no dot-segments), checks the target exists + is inside SnapshotDir, then os.RemoveAll. - GET /api/version/snapshots already returned the list — UI just wasn't showing it. Frontend - Update modal grows a collapsible "Backup snapshots (N)" section below the 🛟 hint. Shows created-at, from→to version, size in MB, and a Delete button per row. - Empty state tells the operator where snapshots come from. - Delete uses window.confirm + an optimistic "deleting…" placeholder so rapid double-clicks can't fire duplicates. - Refreshes on modal open and after every delete. Tests - TestVersionSnapshots_DeleteByID — happy path, dir gone after DELETE. - TestVersionSnapshots_DeleteRejectsInvalidID — missing id returns 404; traversal attempts are rejected by Go's ServeMux before reaching the handler (accept any non-200 outcome as sufficient). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First slice of #140 — the safety net every future rollback relies on.
Summary
Why this first?
It's the keystone. Every later PR in the safe-update UX (#140) — rollback-by-tag, post-update health check, update history — uses this as the precondition. Without snapshots, "rollback" really means "hope the new image is forward-compatible with the current state," which doesn't build trust.
`VACUUM INTO` is point-in-time consistent even under live writers, so no downtime / no lock juggling.
Trust-building failure mode
Snapshot failures abort the update with 500. The whole promise of the Update button is "you can back out." If we can't produce the safety net, we'd rather the operator see an error than quietly ship the update without one.
Operators who explicitly opt out at deploy time (empty `SnapshotDir`) get the old no-snapshot flow unchanged.
Test plan
Follows up
All tracked in #140.
🤖 Generated with Claude Code