Skip to content

feat(selfupdate): snapshot state.db + config before each update - #141

Merged
frahlg merged 1 commit into
masterfrom
140-snapshot-before-update
Apr 20, 2026
Merged

frahlg merged 1 commit into
masterfrom
140-snapshot-before-update

Conversation

@frahlg

@frahlg frahlg commented Apr 20, 2026

Copy link
Copy Markdown
Member

First slice of #140 — the safety net every future rollback relies on.

Summary

  • Before `/api/version/update` hands off to the `ftw-updater` sidecar, capture a point-in-time snapshot of `state.db` (via SQLite's `VACUUM INTO`) plus `config.yaml` into `/snapshots/_to/`.
  • Retention: last 5 snapshots. Older dirs pruned after each new one.
  • New endpoint `GET /api/version/snapshots` lists what's on disk (newest-first), which PR 2 will drive a picker off of.
  • One-line modal hint so the operator sees the safety-net promise before clicking Update.

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

  • `state.Store.SnapshotTo` — point-in-time, live-safe, refuses to overwrite.
  • Handler creates snapshot before triggering (passes even when sidecar is missing — the safety net lands first).
  • Config copy when `ConfigPath` wired.
  • Snapshot failure ⇒ 500, trigger skipped.
  • Retention cap honored.
  • Listing endpoint returns newest-first.
  • `make test` + `make e2e` green.
  • Smoke on Pi: trigger an update, verify `./data/snapshots/_.../` contains `state.db` + `config.yaml` + `meta.json`; run `sqlite3 snapshots/.../state.db '.tables'` to confirm it's readable; verify older snapshots get pruned after the 6th update.

Follows up

  • PR 2 — rollback-by-tag + warning UI (consumes `/api/version/snapshots` and the sidecar's existing `target` field).
  • PR 3 — post-update health check banner.
  • PR 4 — update history view.
  • PR 5 — breaking-change highlight.

All tracked in #140.

🤖 Generated with Claude Code

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>
@frahlg
frahlg merged commit 8a384e8 into master Apr 20, 2026
1 check passed
@frahlg
frahlg deleted the 140-snapshot-before-update branch April 20, 2026 07:47

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread web/update-badge.js
Comment on lines +248 to +249
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>`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

frahlg added a commit that referenced this pull request Apr 20, 2026
…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>
frahlg added a commit that referenced this pull request Apr 20, 2026
…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>
frahlg added a commit that referenced this pull request Apr 20, 2026
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>
frahlg added a commit that referenced this pull request Apr 20, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant