feat: nightly off-box backup of the live DB, and phone alerts from the daily report
The live (Tradier) stack has no backup service. Only the paper stack backs up its own DB, so the 80-trade test's whole record lived only on the TrueNAS pool. - deploy/backup_live_db.py, run by Task Scheduler "Momentum\LiveDbBackup" at 20:40 ET nightly: pg_dump -Fc (PostgreSQL 18.6 client) of the live DB to C:\Users\Admin\momentum-backups. It is verified with pg_restore --list (the events and config_overrides data must be present), keeps the newest 7, and writes status.json. A broken dump is deleted rather than kept. - research/daily_report.py alerts when the last backup failed or is more than 36 h old. When there are alerts it sends them to Telegram, using the stack's own sender and the credentials in .env. --no-telegram suppresses that for dry runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
This commit is contained in:
1 parent
7e6f881b17
commit
b3f023f6e2
2 files changed
+97
-1
No files matched your search
@@ -0,0 +1,71 @@
|
||||
"""Nightly off-box backup of the LIVE (Tradier) Postgres, from the owner's workstation.
|
||||
|
||||
The live stack on TrueNAS has no db-backup service; the paper stack backs up only its own database. This keeps the
|
||||
live test's full record (events, trades, settings) somewhere other than the TrueNAS pool.
|
||||
|
||||
- pg_dump -Fc (compressed, restorable with pg_restore) of the live database (DATABASE_URL host, port 5434)
|
||||
- verified: pg_restore --list must read it and it must contain the events and config_overrides tables
|
||||
- keeps the newest KEEP dumps (default 7) in LIVE_BACKUP_DIR (default C:\\Users\\Admin\\momentum-backups)
|
||||
- writes <dir>/status.json for research.daily_report to alert on a missed or failed backup
|
||||
|
||||
usage: python deploy/backup_live_db.py (Task Scheduler "Momentum\\LiveDbBackup", nightly)
|
||||
restore: pg_restore -d <empty database> --no-owner <dump>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PG_BIN = Path(os.environ.get("PG_BIN", r"C:\Users\Admin\pgsql18\bin"))
|
||||
OUT = Path(os.environ.get("LIVE_BACKUP_DIR", r"C:\Users\Admin\momentum-backups"))
|
||||
KEEP = int(os.environ.get("LIVE_BACKUP_KEEP", "7"))
|
||||
PORT = int(os.environ.get("LIVE_DB_PORT", "5434"))
|
||||
ET = ZoneInfo("America/New_York")
|
||||
MUST_HAVE = ("TABLE DATA public events", "TABLE DATA public config_overrides")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from dotenv import dotenv_values
|
||||
u = urlparse(dotenv_values(ROOT / ".env")["DATABASE_URL"])
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(ET).strftime("%Y-%m-%d_%H%M")
|
||||
dump = OUT / f"live_{stamp}.dump"
|
||||
env = dict(os.environ, PGPASSWORD=u.password or "")
|
||||
status = dict(at=datetime.now(ET).isoformat(timespec="seconds"), file=str(dump), ok=False)
|
||||
t0 = time.time()
|
||||
try:
|
||||
subprocess.run([str(PG_BIN / "pg_dump.exe"), "-h", u.hostname, "-p", str(PORT), "-U", u.username,
|
||||
"-d", u.path[1:], "-Fc", "-Z", "6", "-f", str(dump)],
|
||||
env=env, check=True, capture_output=True, text=True, timeout=3600)
|
||||
listing = subprocess.run([str(PG_BIN / "pg_restore.exe"), "--list", str(dump)],
|
||||
check=True, capture_output=True, text=True, timeout=600).stdout
|
||||
missing = [m for m in MUST_HAVE if m not in listing]
|
||||
if missing:
|
||||
raise RuntimeError(f"dump is missing {missing}")
|
||||
status.update(ok=True, size_mb=round(dump.stat().st_size / 1e6, 1), seconds=round(time.time() - t0),
|
||||
entries=listing.count("\n"))
|
||||
except subprocess.CalledProcessError as e:
|
||||
status["error"] = (e.stderr or str(e))[-400:]
|
||||
except Exception as e: # noqa: BLE001
|
||||
status["error"] = str(e)[-400:]
|
||||
if not status["ok"] and dump.exists():
|
||||
dump.unlink() # never keep a broken dump around as if it were good
|
||||
dumps = sorted(OUT.glob("live_*.dump"))
|
||||
for old in dumps[:-KEEP]:
|
||||
old.unlink()
|
||||
status["kept"] = [p.name for p in sorted(OUT.glob("live_*.dump"))]
|
||||
(OUT / "status.json").write_text(json.dumps(status, indent=1), encoding="utf-8")
|
||||
print(json.dumps(status, indent=1))
|
||||
return 0 if status["ok"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user