Add database backup and restore
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
backups/
|
||||||
@@ -70,6 +70,32 @@ The command works on Windows, Linux, and macOS. It prints `SMTP_TEST_SENT_TO=<ad
|
|||||||
|
|
||||||
SMTP connection and delivery have been verified for the current deployment environment.
|
SMTP connection and delivery have been verified for the current deployment environment.
|
||||||
|
|
||||||
|
## Automated database backups
|
||||||
|
|
||||||
|
The portable backup tool creates a consistent snapshot from the running Docker service using SQLite's backup API. It retains the most recent 30 days by default; snapshots are stored in the ignored `backups/` directory.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python ./scripts/backup-database.py
|
||||||
|
python ./scripts/verify-backup.py ./backups/eternity-YYYYMMDDTHHMMSSZ.db
|
||||||
|
```
|
||||||
|
|
||||||
|
The restore verification copies the selected snapshot to a temporary location, checks SQLite integrity, and reports the recovered user and post counts. It never changes the live database.
|
||||||
|
|
||||||
|
Schedule a daily backup from the project directory. Ensure the scheduler environment provides `SECRET_KEY` for Docker Compose interpolation, or use the same `.env` file used to start the service.
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 2 * * * cd /srv/docker-ep-blog-web-server && SECRET_KEY="$(grep '^SECRET_KEY=' .env | cut -d= -f2-)" python3 ./scripts/backup-database.py >> 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 <backup-file>` after the first scheduled backup and at least quarterly. Keep an encrypted copy of `backups/` outside the Docker host.
|
||||||
|
|
||||||
## Production notes
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
- [x] Configure production SMTP credentials and verify outgoing email delivery.
|
- [x] Configure production SMTP credentials and verify outgoing email delivery.
|
||||||
- [x] Rotate the initial `admin` password and enroll its authenticator.
|
- [x] Rotate the initial `admin` password and enroll its authenticator.
|
||||||
- [x] Add CSRF protection to all state-changing forms.
|
- [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`.
|
- [ ] Configure TLS reverse proxy and production domain for `eternityproject.fi`.
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user