diff --git a/containers/novela/routers/backup.py b/containers/novela/routers/backup.py index cc6244e..41ad799 100644 --- a/containers/novela/routers/backup.py +++ b/containers/novela/routers/backup.py @@ -826,11 +826,24 @@ def _run_pg_restore(dump_bytes: bytes) -> None: Before the destructive load, a safety dump of the CURRENT database is taken. If applying the requested dump fails, the database is rolled back to that safety snapshot so a failed restore never leaves an empty/broken database. + + The safety dump is also written to the local dump store, so every restore + leaves behind a token-free local snapshot of the pre-restore state. Regular + backups stay Dropbox-only (they can run hourly and would otherwise fill the + disk). """ try: - safety_bytes, _ = _run_pg_dump() + safety_bytes, safety_name = _run_pg_dump() except Exception: - safety_bytes = None + safety_bytes, safety_name = None, "" + + if safety_bytes is not None: + try: + _save_local_dump(f"pre-restore-{safety_name}", safety_bytes) + _enforce_local_dump_retention(_load_dropbox_retention_count()) + except Exception: + # A local-copy failure must not block the restore. + pass try: _apply_pg_dump(dump_bytes) @@ -1136,16 +1149,6 @@ def _run_backup_internal(*, dry_run: bool, progress_key: int | None = None) -> t uploaded_size += len(dump_data) uploaded_count += 1 - # Keep a local copy of the dump so the database can be restored without a - # Dropbox token. Only for real runs, not dry runs. - if not dry_run: - try: - _save_local_dump(dump_name, dump_data) - _enforce_local_dump_retention(retention_count) - except Exception: - # A local-copy failure must not fail the backup itself. - pass - if not dry_run: _save_manifest(new_manifest) return total_files, uploaded_count, uploaded_size @@ -1868,8 +1871,8 @@ async def restore_local_dump(request: Request): async def upload_restore_dump(file: UploadFile = File(...)): """Restore the database from an uploaded .sql dump. No Dropbox token required. - The uploaded dump is also saved to the local dump store so it is available - for future restores. + A local pre-restore safety snapshot is taken automatically (by _run_pg_restore) + before the load; the uploaded file itself is not persisted. """ if not shutil.which("psql"): return {"ok": False, "error": "psql is not available in this container"} @@ -1890,10 +1893,4 @@ async def upload_restore_dump(file: UploadFile = File(...)): except Exception as e: return {"ok": False, "error": str(e)} - # Persist a local copy so it shows up under Local Database Restore afterwards. - try: - await asyncio.to_thread(_save_local_dump, filename, data) - except Exception: - pass - return {"ok": True, "restored": filename, "size_bytes": len(data)} diff --git a/containers/novela/templates/backup.html b/containers/novela/templates/backup.html index 369c3d3..dcd4869 100644 --- a/containers/novela/templates/backup.html +++ b/containers/novela/templates/backup.html @@ -302,9 +302,10 @@
Local Database Restore (no Dropbox needed)

- Every backup also keeps a local copy of the database dump on the config volume, - so the database can be restored even without a Dropbox token. You can also upload - a .sql dump you downloaded from Dropbox manually. + Before every restore a safety snapshot of the current database is saved locally + on the config volume (named pre-restore-…), so you can roll back or + restore without a Dropbox token. You can also upload a .sql dump you + downloaded from Dropbox manually. Regular backups remain Dropbox-only.

⚠ Destructive: replaces the entire current database (with automatic rollback if the dump fails to load). diff --git a/containers/novela/version.py b/containers/novela/version.py index abee128..4f56198 100644 --- a/containers/novela/version.py +++ b/containers/novela/version.py @@ -10,7 +10,7 @@ from __future__ import annotations from changelog import CHANGELOG -BUILD = 5 +BUILD = 6 def _release_version() -> str: diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md index 392fb2f..3a02aca 100644 --- a/docs/changelog-develop.md +++ b/docs/changelog-develop.md @@ -1,13 +1,13 @@ # Develop Changelog -## 2026-06-01 — Local database backups + token-free / upload restore +## 2026-06-01 — Local pre-restore snapshots + token-free / upload restore ### Added -- Backups now keep a **local copy** of each PostgreSQL dump on the config volume (`CONFIG_DIR/postgres_dumps/`, same retention as snapshots), so the database can be restored **without a Dropbox token**. Previously dumps lived only in Dropbox, so losing the stored token meant being locked out of every backup. - - `routers/backup.py`: `LOCAL_DUMP_DIR`, helpers `_save_local_dump`, `_list_local_dumps`, `_enforce_local_dump_retention`, `_resolve_local_dump`; `_run_backup_internal` writes the dump locally (real runs only) after uploading to Dropbox (a local-copy failure never fails the backup). +- Every restore now writes its **pre-restore safety dump to a local store** on the config volume (`CONFIG_DIR/postgres_dumps/`, named `pre-restore-…`, with snapshot-equal retention), so the database can be restored or rolled back **without a Dropbox token**. Regular backups are intentionally left **Dropbox-only** — production backs up hourly, so writing every backup to disk would fill the volume. + - `routers/backup.py`: `LOCAL_DUMP_DIR`, helpers `_save_local_dump`, `_list_local_dumps`, `_enforce_local_dump_retention`, `_resolve_local_dump`; `_run_pg_restore` persists the safety dump locally (and prunes by retention) before the destructive load. `_run_backup_internal` is unchanged (no local copy). - New **Local Database Restore** card on the Backup page with two token-free paths: - Restore from a locally stored dump — `GET /api/backup/local/dumps`, `POST /api/backup/local/restore`. - - Upload a `.sql` dump (e.g. downloaded manually from dropbox.com) and restore it — `POST /api/backup/upload-restore`; the upload is also saved to the local dump store for future use. + - Upload a `.sql` dump (e.g. downloaded manually from dropbox.com) and restore it — `POST /api/backup/upload-restore`. The upload itself is not persisted; the automatic pre-restore safety snapshot covers the local copy. - Both reuse the safe restore path (pre-restore safety dump + automatic rollback) and require `psql`. - `templates/backup.html`: new card with a local-dump selector, an upload field, double-guarded restore buttons, and `fmtBytes` sizes.