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.
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)}

View File

@ -302,9 +302,10 @@
<section class="card">
<div class="card-head">Local Database Restore (no Dropbox needed)</div>
<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,
so the database can be restored even without a Dropbox token. You can also upload
a <code>.sql</code> dump you downloaded from Dropbox manually.
Before every restore a safety snapshot of the current database is saved locally
on the config volume (named <code>pre-restore-…</code>), so you can roll back or
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 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).

View File

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

View File

@ -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.