Dev build 2026-06-01 21:45

This commit is contained in:
Ivo Oskamp 2026-06-01 21:45:12 +02:00
parent 3e2700b1bc
commit ef05da92f8
4 changed files with 26 additions and 28 deletions

View File

@ -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. 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 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. 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: try:
safety_bytes, _ = _run_pg_dump() safety_bytes, safety_name = _run_pg_dump()
except Exception: 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: try:
_apply_pg_dump(dump_bytes) _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_size += len(dump_data)
uploaded_count += 1 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: if not dry_run:
_save_manifest(new_manifest) _save_manifest(new_manifest)
return total_files, uploaded_count, uploaded_size 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(...)): async def upload_restore_dump(file: UploadFile = File(...)):
"""Restore the database from an uploaded .sql dump. No Dropbox token required. """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 A local pre-restore safety snapshot is taken automatically (by _run_pg_restore)
for future restores. before the load; the uploaded file itself is not persisted.
""" """
if not shutil.which("psql"): if not shutil.which("psql"):
return {"ok": False, "error": "psql is not available in this container"} 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: except Exception as e:
return {"ok": False, "error": str(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)} return {"ok": True, "restored": filename, "size_bytes": len(data)}

View File

@ -302,9 +302,10 @@
<section class="card"> <section class="card">
<div class="card-head">Local Database Restore (no Dropbox needed)</div> <div class="card-head">Local Database Restore (no Dropbox needed)</div>
<p class="muted" style="margin-top:0;margin-bottom:0.6rem;"> <p class="muted" style="margin-top:0;margin-bottom:0.6rem;">
Every backup also keeps a local copy of the database dump on the config volume, Before every restore a safety snapshot of the current database is saved locally
so the database can be restored even without a Dropbox token. You can also upload on the config volume (named <code>pre-restore-…</code>), so you can roll back or
a <code>.sql</code> dump you downloaded from Dropbox manually. restore without a Dropbox token. You can also upload a <code>.sql</code> dump you
downloaded from Dropbox manually. Regular backups remain Dropbox-only.
</p> </p>
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;color:var(--err);"> <p class="muted" style="margin-top:0;margin-bottom:0.9rem;color:var(--err);">
⚠ Destructive: replaces the entire current database (with automatic rollback if the dump fails to load). ⚠ Destructive: replaces the entire current database (with automatic rollback if the dump fails to load).

View File

@ -10,7 +10,7 @@ from __future__ import annotations
from changelog import CHANGELOG from changelog import CHANGELOG
BUILD = 5 BUILD = 6
def _release_version() -> str: def _release_version() -> str:

View File

@ -1,13 +1,13 @@
# Develop Changelog # Develop Changelog
## 2026-06-01 — Local database backups + token-free / upload restore ## 2026-06-01 — Local pre-restore snapshots + token-free / upload restore
### Added ### 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. - 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_backup_internal` writes the dump locally (real runs only) after uploading to Dropbox (a local-copy failure never fails the backup). - `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: - 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`. - 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`. - 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. - `templates/backup.html`: new card with a local-dump selector, an upload field, double-guarded restore buttons, and `fmtBytes` sizes.