From 7322fa8858055dd3bdd2b44f82d8cded8ec86a1d Mon Sep 17 00:00:00 2001 From: Tero Date: Sun, 30 Aug 2026 10:17:30 +0300 Subject: [PATCH] Add database backup and restore --- .gitignore | 5 +++ README.md | 26 ++++++++++++++++ TODO.md | 2 +- scripts/backup-database.py | 63 ++++++++++++++++++++++++++++++++++++++ scripts/verify-backup.py | 42 +++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 scripts/backup-database.py create mode 100644 scripts/verify-backup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e2d446 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.venv/ +__pycache__/ +*.pyc +backups/ \ No newline at end of file diff --git a/README.md b/README.md index 17a2de2..14c59f0 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,32 @@ The command works on Windows, Linux, and macOS. It prints `SMTP_TEST_SENT_TO=> backups/backup.log 2>&1 +``` + +On Windows, create a daily Task Scheduler task with **Start in** set to the project directory and this program/argument pair: + +```text +Program: python.exe +Arguments: .\scripts\backup-database.py +``` + +Run `python ./scripts/verify-backup.py ` after the first scheduled backup and at least quarterly. Keep an encrypted copy of `backups/` outside the Docker host. + ## Production notes Put this service behind a TLS reverse proxy for `eternityproject.fi` (for example Caddy or Nginx). Set a strong unique `SECRET_KEY`; the Compose file intentionally refuses to start without it. Back up the `eternity_data` Docker volume, which contains accounts and posts. diff --git a/TODO.md b/TODO.md index 69144b2..c4235b1 100644 --- a/TODO.md +++ b/TODO.md @@ -7,5 +7,5 @@ - [x] Configure production SMTP credentials and verify outgoing email delivery. - [x] Rotate the initial `admin` password and enroll its authenticator. - [x] Add CSRF protection to all state-changing forms. -- [ ] Add automated database backups and test restoration. +- [x] Add automated database backups and test restoration. - [ ] Configure TLS reverse proxy and production domain for `eternityproject.fi`. \ No newline at end of file diff --git a/scripts/backup-database.py b/scripts/backup-database.py new file mode 100644 index 0000000..a6f82e6 --- /dev/null +++ b/scripts/backup-database.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Create a consistent SQLite backup from the running Docker service.""" + +import argparse +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import uuid4 + +CONTAINER_BACKUP = """import sqlite3 +source = sqlite3.connect('/data/eternity.db') +destination = sqlite3.connect('/tmp/eternity-backup.db') +with destination: + source.backup(destination) +destination.close() +source.close() +""" + + +def run(command, **kwargs): + return subprocess.run(command, check=True, text=True, **kwargs) + + +def main(): + parser = argparse.ArgumentParser(description="Back up the live Eternity Project SQLite database.") + parser.add_argument("--output-dir", default="backups", help="Directory for timestamped backup files") + parser.add_argument("--keep-days", type=int, default=30, help="Delete backups older than this many days; use 0 to keep all") + args = parser.parse_args() + + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + destination = output_dir / f"eternity-{timestamp}.db" + temporary_path = f"/tmp/eternity-backup-{uuid4().hex}.db" + + try: + container_id = run(["docker", "compose", "ps", "-q", "eternityproject"], capture_output=True).stdout.strip() + if not container_id: + raise RuntimeError("The eternityproject Docker service must be running.") + run(["docker", "exec", container_id, "python", "-c", CONTAINER_BACKUP.replace("/tmp/eternity-backup.db", temporary_path)]) + run(["docker", "cp", f"{container_id}:{temporary_path}", str(destination)]) + except (OSError, subprocess.CalledProcessError, RuntimeError) as error: + print(f"DATABASE_BACKUP_FAILED: {error}", file=sys.stderr) + return 1 + finally: + if "container_id" in locals() and container_id: + subprocess.run(["docker", "exec", container_id, "rm", "-f", temporary_path], check=False) + + print(f"DATABASE_BACKUP_CREATED={destination}") + if args.keep_days > 0: + cutoff = datetime.now(timezone.utc) - timedelta(days=args.keep_days) + removed = 0 + for backup in output_dir.glob("eternity-*.db"): + if backup != destination and datetime.fromtimestamp(backup.stat().st_mtime, timezone.utc) < cutoff: + backup.unlink() + removed += 1 + print(f"DATABASE_BACKUP_RETENTION: removed={removed} keep_days={args.keep_days}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-backup.py b/scripts/verify-backup.py new file mode 100644 index 0000000..ccaf543 --- /dev/null +++ b/scripts/verify-backup.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Restore a backup to an isolated temporary database and verify its integrity.""" + +import argparse +import shutil +import sqlite3 +import sys +import tempfile +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description="Test an Eternity Project database backup by restoring it safely.") + parser.add_argument("backup", type=Path, help="Path to a .db backup created by backup-database.py") + args = parser.parse_args() + source = args.backup.resolve() + if not source.is_file(): + print(f"BACKUP_VERIFY_FAILED: Backup does not exist: {source}", file=sys.stderr) + return 2 + + with tempfile.TemporaryDirectory(prefix="eternity-restore-") as directory: + restored = Path(directory) / "eternity-restored.db" + shutil.copy2(source, restored) + try: + database = sqlite3.connect(f"file:{restored}?mode=ro", uri=True) + integrity = database.execute("PRAGMA integrity_check").fetchone()[0] + users = database.execute("SELECT COUNT(*) FROM users").fetchone()[0] + posts = database.execute("SELECT COUNT(*) FROM posts").fetchone()[0] + database.close() + except sqlite3.DatabaseError as error: + print(f"BACKUP_VERIFY_FAILED: {error}", file=sys.stderr) + return 1 + + if integrity != "ok": + print(f"BACKUP_VERIFY_FAILED: integrity_check returned {integrity}", file=sys.stderr) + return 1 + print(f"BACKUP_RESTORE_VERIFIED: users={users} posts={posts} source={source}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())