feat(T6/T7): supervizare worker (healthcheck+autoheal) + backup online + cheie partajata
T6 — worker supravegheat:
- app/worker/healthcheck.py: probe pe heartbeat-ul din DB (beat invechit -> exit 1).
Prinde worker-ul agatat (proces viu, beat inghetat) pe care restart:always nu-l
vede. Cablat ca healthcheck pe serviciul worker in compose.
- sidecar autoheal: restarteaza efectiv containerul unhealthy (compose simplu doar
marcheaza, nu restarteaza la unhealthy).
T7 — deploy:
- tools/backup.py: backup ONLINE via Connection.backup (WAL nu se copiaza sigur cu
cp); --keep N roteste snapshot-urile.
- .env.example documenteaza env-urile; volum persistent numit deja in compose.
Fix critic (split api/worker in 2 containere): AUTOPASS_CREDS_KEY trebuie PARTAJATA
api<->worker, altfel worker nu decripteaza creds-urile criptate de API -> submission
blocate. Acum impusa in compose (${...:?} -> fail explicit daca lipseste).
.gitignore: exceptie !.env.example.
5 teste noi (tests/test_deploy.py). 100 pass total.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
.env.example
Normal file
21
.env.example
Normal file
@@ -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 <test> din settings.xml cand submission-ul nu are creds criptate.
|
||||||
|
AUTOPASS_WORKER_USE_TEST_CREDS=false
|
||||||
|
|
||||||
|
# --- RAR ---
|
||||||
|
# test | prod
|
||||||
|
AUTOPASS_RAR_ENV=test
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ settings.xml
|
|||||||
*.key
|
*.key
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# --- VFP: programe compilate (se regenerează din .prg) ---
|
# --- VFP: programe compilate (se regenerează din .prg) ---
|
||||||
*.fxp
|
*.fxp
|
||||||
|
|||||||
48
app/worker/healthcheck.py
Normal file
48
app/worker/healthcheck.py
Normal file
@@ -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())
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Gateway RAR AUTOPASS — un container API + un container worker, acelasi image,
|
# Gateway RAR AUTOPASS — un container API + un container worker, acelasi image,
|
||||||
# acelasi volum SQLite persistent (plan.md sect. 4 + 9). restart: always pe ambele.
|
# 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:
|
services:
|
||||||
api:
|
api:
|
||||||
build: .
|
build: .
|
||||||
@@ -11,6 +16,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
AUTOPASS_RAR_ENV: test
|
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
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"]
|
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:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
AUTOPASS_RAR_ENV: test
|
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.
|
# Send dezactivat by default; activeaza pentru proba end-to-end.
|
||||||
AUTOPASS_WORKER_SEND_ENABLED: "false"
|
AUTOPASS_WORKER_SEND_ENABLED: "false"
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- 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:
|
volumes:
|
||||||
autopass-data:
|
autopass-data:
|
||||||
|
|||||||
@@ -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).
|
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ă
|
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).
|
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.
|
- [x] **T6 (P2) — worker supravegheat** ✅ 2026-06-15. `app/worker/healthcheck.py` (probe pe heartbeat-ul din DB: beat
|
||||||
- [ ] **T7 (P2) — deploy:** SQLite pe volum persistent numit + backup (singura copie durabilă, re-push scos).
|
mai vechi de `worker_heartbeat_stale_s` → exit 1) cablat în compose ca healthcheck pe serviciul worker. Prinde worker-ul
|
||||||
Verify: recreare container → coada supraviețuiește.
|
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.
|
- [ ] **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)
|
### De decis ulterior (urmărit, nu blocant)
|
||||||
|
|||||||
117
tests/test_deploy.py
Normal file
117
tests/test_deploy.py
Normal file
@@ -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
|
||||||
79
tools/backup.py
Normal file
79
tools/backup.py
Normal file
@@ -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 # -> <db_dir>/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: <db_dir>/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())
|
||||||
Reference in New Issue
Block a user