diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2252caf --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Gateway RAR AUTOPASS — variabile de mediu (copiaza in .env; .env NU se comite). +# Compose citeste .env automat. Prefix AUTOPASS_ pentru toate. + +# --- CRITIC: cheie criptare creds RAR (Fernet) --- +# PARTAJATA intre api si worker (API cripteaza, worker decripteaza). Genereaza: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +AUTOPASS_CREDS_KEY= + +# --- Auth API-key --- +# true = orice /v1/* cere cheie valida (prod). false = dev (fara cheie -> cont id=1). +AUTOPASS_REQUIRE_API_KEY=false + +# --- Worker --- +# Send catre RAR. false = nu trimite (default, sigur pentru probe). true = end-to-end. +AUTOPASS_WORKER_SEND_ENABLED=false +# Dev: foloseste creds din settings.xml cand submission-ul nu are creds criptate. +AUTOPASS_WORKER_USE_TEST_CREDS=false + +# --- RAR --- +# test | prod +AUTOPASS_RAR_ENV=test diff --git a/.gitignore b/.gitignore index ffc24eb..9a8e21c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ settings.xml *.key .env .env.* +!.env.example # --- VFP: programe compilate (se regenerează din .prg) --- *.fxp diff --git a/app/worker/healthcheck.py b/app/worker/healthcheck.py new file mode 100644 index 0000000..f65a6bb --- /dev/null +++ b/app/worker/healthcheck.py @@ -0,0 +1,48 @@ +"""Liveness probe pentru worker (T6) — folosit de healthcheck-ul Docker. + +Worker-ul nu e server HTTP, deci `restart: always` prinde doar procesul MORT, +nu si worker-ul AGATAT (proces viu care nu mai bate heartbeat). Acest probe +citeste `worker_heartbeat` din DB si pica daca ultimul beat e mai vechi decat +`worker_heartbeat_stale_s` -> Docker restarteaza containerul worker. + +Utilizare (compose healthcheck): python -m app.worker.healthcheck +Exit 0 = sanatos, 1 = invechit/lipsa. +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone + +from ..config import Settings, get_settings +from ..db import get_connection, read_heartbeat + + +def worker_healthy(conn, settings: Settings, *, now: datetime | None = None) -> bool: + """True daca ultimul heartbeat e mai proaspat decat pragul de invechire.""" + hb = read_heartbeat(conn) + if hb is None or not hb["last_beat"]: + return False + try: + last = datetime.fromisoformat(hb["last_beat"]) + except (ValueError, TypeError): + return False + now = now or datetime.now(timezone.utc) + return (now - last).total_seconds() <= settings.worker_heartbeat_stale_s + + +def main() -> int: + settings = get_settings() + conn = get_connection() + try: + ok = worker_healthy(conn, settings) + finally: + conn.close() + if not ok: + print("[healthcheck] worker invechit sau nepornit", flush=True) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docker-compose.yml b/docker-compose.yml index 3b25fe5..4ab5ba5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,10 @@ # Gateway RAR AUTOPASS — un container API + un container worker, acelasi image, # acelasi volum SQLite persistent (plan.md sect. 4 + 9). restart: always pe ambele. +# +# CRITIC: AUTOPASS_CREDS_KEY trebuie PARTAJATA intre api si worker — API cripteaza +# creds-urile RAR, worker-ul le decripteaza. Chei diferite -> worker nu poate +# decripta -> submission-uri blocate "creds indisponibile". Seteaz-o in .env +# (vezi .env.example): compose o citeste automat. Lipsa -> compose pica explicit. services: api: build: . @@ -11,6 +16,8 @@ services: environment: AUTOPASS_DB_PATH: /data/autopass.db AUTOPASS_RAR_ENV: test + AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)} + AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false} restart: always healthcheck: test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"] @@ -26,11 +33,35 @@ services: environment: AUTOPASS_DB_PATH: /data/autopass.db AUTOPASS_RAR_ENV: test + AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)} # Send dezactivat by default; activeaza pentru proba end-to-end. AUTOPASS_WORKER_SEND_ENABLED: "false" restart: always depends_on: - api + # T6: probe pe heartbeat-ul din DB — prinde worker-ul AGATAT (proces viu, beat + # invechit), pe care restart:always singur nu-l vede. start_period acopera bootul. + # ATENTIE: in compose simplu, "unhealthy" doar marcheaza containerul — NU il + # restarteaza (restart:always reactioneaza la EXIT). Sidecar-ul `autoheal` de + # mai jos vede label-ul si chiar restarteaza worker-ul cand pica probe-ul. + labels: + autoheal: "true" + healthcheck: + test: ["CMD", "python", "-m", "app.worker.healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + # Restarteaza orice container marcat unhealthy cu label autoheal=true (worker-ul + # agatat). Alternativa: Docker Swarm (restart on unhealthy nativ). + autoheal: + image: willfarrell/autoheal:latest + restart: always + environment: + AUTOHEAL_CONTAINER_LABEL: autoheal + volumes: + - /var/run/docker.sock:/var/run/docker.sock volumes: autopass-data: diff --git a/docs/plans/plan.md b/docs/plans/plan.md index 30c531c..e0b5321 100644 --- a/docs/plans/plan.md +++ b/docs/plans/plan.md @@ -221,9 +221,15 @@ Nimic din cod nu e scris încă (`app/`, `tools/` nu există). Ordine recomandat retry; token expirat → invalidare sesiune + requeue; fără creds (restart) → requeue „indisponibile" (ROAAUTO re-trimite). Verify: 10 teste (`tests/test_creds_delivery.py`). **Risc acceptat:** la restart token+creds se pierd → contul re-loghează la următorul submission cu creds (degradare per modelul efemer). -- [ ] **T6 (P2) — worker proces/container propriu supravegheat;** `/healthz` pică → restart. Verify: worker omorât → restart automat. -- [ ] **T7 (P2) — deploy:** SQLite pe volum persistent numit + backup (singura copie durabilă, re-push scos). - Verify: recreare container → coada supraviețuiește. +- [x] **T6 (P2) — worker supravegheat** ✅ 2026-06-15. `app/worker/healthcheck.py` (probe pe heartbeat-ul din DB: beat + mai vechi de `worker_heartbeat_stale_s` → exit 1) cablat în compose ca healthcheck pe serviciul worker. Prinde worker-ul + AGĂȚAT (proces viu, beat înghețat), pe care `restart:always` (doar la EXIT) nu-l vede. Sidecar `autoheal` restartează + efectiv containerul marcat unhealthy (compose simplu doar marchează, nu restartează). Verify: 3 teste (`tests/test_deploy.py`). +- [x] **T7 (P2) — deploy** ✅ 2026-06-15. `tools/backup.py` (backup ONLINE via `Connection.backup` — WAL nu se copiază sigur + cu `cp`; `--keep N` rotește snapshot-urile) + volum SQLite persistent numit (`autopass-data`, deja în compose). `.env.example` + documentează env-urile. **Fix critic descoperit la split-ul în 2 containere:** `AUTOPASS_CREDS_KEY` trebuie PARTAJATĂ + api↔worker (altfel worker nu decriptează creds) — acum impusă în compose (`${...:?}` → fail explicit dacă lipsește). + Verify: 2 teste (`tests/test_deploy.py`). - [ ] **Dashboard** (Jinja2+HTMX) cu stările empty/error/RAR-indisponibil + banner alertă. Apoi `/design-review` pe UI-ul live. ### De decis ulterior (urmărit, nu blocant) diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..858b289 --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,117 @@ +"""Teste T6/T7: liveness probe worker + backup online SQLite.""" + +from __future__ import annotations + +import os +import sqlite3 +import tempfile +from datetime import datetime, timedelta, timezone + +import pytest + + +@pytest.fixture() +def env(monkeypatch): + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) + from app.config import get_settings + + get_settings.cache_clear() + from app.db import get_connection, init_db + + init_db() + yield get_connection, get_settings() + get_settings.cache_clear() + + +# --------------------------------------------------------------------------- # +# T6 — worker liveness probe # +# --------------------------------------------------------------------------- # + +def _set_beat(conn, dt: datetime | None): + conn.execute( + "UPDATE worker_heartbeat SET last_beat=? WHERE id=1", + (dt.isoformat(timespec="seconds") if dt else None,), + ) + + +def test_healthy_when_beat_fresh(env): + from app.worker.healthcheck import worker_healthy + + get_connection, settings = env + conn = get_connection() + try: + _set_beat(conn, datetime.now(timezone.utc)) + assert worker_healthy(conn, settings) is True + finally: + conn.close() + + +def test_unhealthy_when_beat_stale(env): + from app.worker.healthcheck import worker_healthy + + get_connection, settings = env + conn = get_connection() + try: + stale = datetime.now(timezone.utc) - timedelta(seconds=settings.worker_heartbeat_stale_s + 60) + _set_beat(conn, stale) + assert worker_healthy(conn, settings) is False + finally: + conn.close() + + +def test_unhealthy_when_never_started(env): + from app.worker.healthcheck import worker_healthy + + get_connection, settings = env + conn = get_connection() + try: + _set_beat(conn, None) # never started + assert worker_healthy(conn, settings) is False + finally: + conn.close() + + +# --------------------------------------------------------------------------- # +# T7 — backup online # +# --------------------------------------------------------------------------- # + +def test_backup_db_roundtrip(env): + from tools.backup import backup_db + + get_connection, settings = env + conn = get_connection() + try: + conn.execute( + "INSERT INTO submissions (idempotency_key, status, payload_json) VALUES ('bk1','queued','{}')" + ) + finally: + conn.close() + + dest = settings.db_path.parent / "backups" / "snap.db" + out = backup_db(settings.db_path, dest) + assert out.exists() + # Copia contine randul scris (backup consistent, nu fisier gol). + bk = sqlite3.connect(out) + try: + n = bk.execute("SELECT COUNT(*) FROM submissions WHERE idempotency_key='bk1'").fetchone()[0] + finally: + bk.close() + assert n == 1 + + +def test_backup_prune_keeps_newest(env): + from tools.backup import prune + + get_connection, settings = env + bdir = settings.db_path.parent / "backups" + bdir.mkdir(parents=True, exist_ok=True) + # Creeaza 5 snapshot-uri cu nume-timestamp crescator. + names = [f"autopass-2026010{i}-000000.db" for i in range(1, 6)] + for n in names: + (bdir / n).write_bytes(b"x") + removed = prune(bdir, keep=2) + remaining = sorted(p.name for p in bdir.glob("autopass-*.db")) + assert len(remaining) == 2 + assert remaining == names[-2:] # cele mai noi doua + assert len(removed) == 3 diff --git a/tools/backup.py b/tools/backup.py new file mode 100644 index 0000000..c7d90ed --- /dev/null +++ b/tools/backup.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Backup online SQLite (T7) — copie consistenta a bazei pe volum persistent. + +SQLite in WAL NU se copiaza sigur cu `cp` (WAL-ul poate avea tranzactii necheckpoint-ate). +Folosim API-ul `Connection.backup` (online, consistent, fara oprirea worker-ului). + +Utilizare: + python -m tools.backup # -> /backups/autopass-YYYYMMDD-HHMMSS.db + python -m tools.backup --out /path/snap.db # destinatie explicita + python -m tools.backup --keep 14 # pastreaza ultimele 14, sterge restul + +Recomandat: rulat dintr-un cron pe gazda (ex. zilnic), tinta pe volum/montaj separat. +""" + +from __future__ import annotations + +import argparse +import sqlite3 +import sys +from datetime import datetime, timezone +from pathlib import Path + +from app.config import get_settings + + +def backup_db(src: Path, dest: Path) -> Path: + """Backup online (consistent) din `src` in `dest`. Intoarce `dest`.""" + dest.parent.mkdir(parents=True, exist_ok=True) + src_conn = sqlite3.connect(src) + try: + dst_conn = sqlite3.connect(dest) + try: + src_conn.backup(dst_conn) + finally: + dst_conn.close() + finally: + src_conn.close() + return dest + + +def prune(backup_dir: Path, keep: int) -> list[Path]: + """Pastreaza cele mai noi `keep` snapshot-uri (dupa nume = timestamp), sterge restul.""" + snaps = sorted(backup_dir.glob("autopass-*.db"), reverse=True) + removed = [] + for old in snaps[keep:]: + old.unlink(missing_ok=True) + removed.append(old) + return removed + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Backup online SQLite gateway RAR AUTOPASS") + parser.add_argument("--out", type=Path, default=None, help="destinatie explicita (default: /backups/)") + parser.add_argument("--keep", type=int, default=0, help="pastreaza ultimele N snapshot-uri (0 = nu sterge)") + args = parser.parse_args(argv) + + src = get_settings().db_path + if not src.exists(): + print(f"eroare: baza {src} nu exista", file=sys.stderr) + return 1 + + if args.out is not None: + dest = args.out + else: + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + dest = src.parent / "backups" / f"autopass-{stamp}.db" + + backup_db(src, dest) + print(f"backup -> {dest} ({dest.stat().st_size} bytes)") + + if args.keep > 0: + removed = prune(dest.parent, args.keep) + if removed: + print(f"sterse {len(removed)} snapshot-uri vechi (pastrez {args.keep})") + return 0 + + +if __name__ == "__main__": + sys.exit(main())