ops(dev): add audit retention pruning script
Made-with: Cursor
This commit is contained in:
65
docs/runbook/audit-retention.md
Normal file
65
docs/runbook/audit-retention.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Runbook: Audit Retention (Sofiia Console)
|
||||||
|
|
||||||
|
Ціль: контролювати ріст SQLite (`sofiia.db`) через регулярний prune старих `audit_events`.
|
||||||
|
|
||||||
|
## Default policy
|
||||||
|
|
||||||
|
- `SOFIIA_AUDIT_RETENTION_DAYS=90` (safe default)
|
||||||
|
- Працює для `audit_events.ts < now - retention_days`.
|
||||||
|
|
||||||
|
## Dry-run (рекомендовано перед виконанням)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ops/prune_audit_db.py --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Override шляху/періоду:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ops/prune_audit_db.py --data-dir "/app/data" --retention-days 90 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Виконання prune
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ops/prune_audit_db.py --batch-size 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
З VACUUM після видалення:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ops/prune_audit_db.py --batch-size 5000 --vacuum
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI параметри
|
||||||
|
|
||||||
|
- `--data-dir` — шлях до `SOFIIA_DATA_DIR` (якщо не заданий, бере env або `/app/data`).
|
||||||
|
- `--retention-days` — override retention (`SOFIIA_AUDIT_RETENTION_DAYS` або 90).
|
||||||
|
- `--batch-size` — розмір батчу видалення (default `5000`).
|
||||||
|
- `--dry-run` — лише звіт, без видалення.
|
||||||
|
- `--vacuum` — VACUUM після видалення.
|
||||||
|
- `--yes` — reserved no-op (для майбутнього interactive safeguard).
|
||||||
|
|
||||||
|
## Exit codes
|
||||||
|
|
||||||
|
- `0` — OK
|
||||||
|
- `1` — error (DB path/schema/permissions/SQL)
|
||||||
|
|
||||||
|
## Перевірка розміру DB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
du -h "${SOFIIA_DATA_DIR:-/app/data}/sofiia.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Приклад cron (щонеділі вночі, 03:30 UTC)
|
||||||
|
|
||||||
|
```cron
|
||||||
|
30 3 * * 0 cd /opt/microdao-daarion && SOFIIA_DATA_DIR=/app/data SOFIIA_AUDIT_RETENTION_DAYS=90 /usr/bin/python3 ops/prune_audit_db.py --batch-size 5000 >> /var/log/sofiia-audit-prune.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Порядок безпечного запуску
|
||||||
|
|
||||||
|
1. `--dry-run`
|
||||||
|
2. перевірка candidates/min/max ts
|
||||||
|
3. реальний prune
|
||||||
|
4. опційно `--vacuum` у low-traffic вікні
|
||||||
163
ops/prune_audit_db.py
Executable file
163
ops/prune_audit_db.py
Executable file
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ts(dt: datetime) -> str:
|
||||||
|
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_data_dir(cli_data_dir: str | None) -> Path:
|
||||||
|
raw = (cli_data_dir or os.getenv("SOFIIA_DATA_DIR") or "/app/data").strip()
|
||||||
|
return Path(raw).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Prune old audit_events records from sofiia SQLite DB."
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--data-dir",
|
||||||
|
default=None,
|
||||||
|
help="Path to SOFIIA_DATA_DIR. Defaults to env SOFIIA_DATA_DIR or /app/data.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--retention-days",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Retention period in days. Defaults to SOFIIA_AUDIT_RETENTION_DAYS or 90.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--batch-size",
|
||||||
|
type=int,
|
||||||
|
default=5000,
|
||||||
|
help="Delete batch size (default: 5000).",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Report candidates only, do not delete.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--vacuum",
|
||||||
|
action="store_true",
|
||||||
|
help="Run VACUUM after deletion.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--yes",
|
||||||
|
action="store_true",
|
||||||
|
help="Reserved for non-interactive confirmation (no-op in current script).",
|
||||||
|
)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
(table_name,),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_stats(conn: sqlite3.Connection, cutoff_ts: str) -> Tuple[int, str | None, str | None]:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*), MIN(ts), MAX(ts) FROM audit_events WHERE ts < ?",
|
||||||
|
(cutoff_ts,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0, None, None
|
||||||
|
return int(row[0] or 0), row[1], row[2]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = _parse_args()
|
||||||
|
|
||||||
|
data_dir = _resolve_data_dir(args.data_dir)
|
||||||
|
db_path = (data_dir / "sofiia.db").resolve()
|
||||||
|
retention_days = args.retention_days
|
||||||
|
if retention_days is None:
|
||||||
|
retention_days = int(os.getenv("SOFIIA_AUDIT_RETENTION_DAYS", "90"))
|
||||||
|
retention_days = max(1, int(retention_days))
|
||||||
|
batch_size = max(1, int(args.batch_size))
|
||||||
|
|
||||||
|
cutoff_dt = _utc_now() - timedelta(days=retention_days)
|
||||||
|
cutoff_ts = _fmt_ts(cutoff_dt)
|
||||||
|
|
||||||
|
print("Audit retention pruning")
|
||||||
|
print(f" db_path: {db_path}")
|
||||||
|
print(f" retention_days: {retention_days}")
|
||||||
|
print(f" cutoff_ts: {cutoff_ts}")
|
||||||
|
print(f" batch_size: {batch_size}")
|
||||||
|
print(f" dry_run: {bool(args.dry_run)}")
|
||||||
|
print(f" vacuum: {bool(args.vacuum)}")
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"ERROR: DB file not found: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: cannot open DB: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not _check_table_exists(conn, "audit_events"):
|
||||||
|
print("ERROR: table 'audit_events' not found in DB schema")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
total_candidates, min_ts, max_ts = _candidate_stats(conn, cutoff_ts)
|
||||||
|
print(f" candidates: {total_candidates}")
|
||||||
|
print(f" candidates_min_ts: {min_ts or '-'}")
|
||||||
|
print(f" candidates_max_ts: {max_ts or '-'}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("Dry-run complete. No rows were deleted.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
deleted_total = 0
|
||||||
|
batch_no = 0
|
||||||
|
while True:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM audit_events WHERE id IN ("
|
||||||
|
"SELECT id FROM audit_events WHERE ts < ? ORDER BY ts ASC LIMIT ?"
|
||||||
|
")",
|
||||||
|
(cutoff_ts, batch_size),
|
||||||
|
)
|
||||||
|
deleted = int(cur.rowcount or 0)
|
||||||
|
if deleted <= 0:
|
||||||
|
break
|
||||||
|
conn.commit()
|
||||||
|
deleted_total += deleted
|
||||||
|
batch_no += 1
|
||||||
|
print(f" batch {batch_no}: deleted {deleted} (total={deleted_total})")
|
||||||
|
|
||||||
|
print(f"Deletion complete. Total deleted: {deleted_total}")
|
||||||
|
|
||||||
|
if args.vacuum:
|
||||||
|
print("Running VACUUM...")
|
||||||
|
conn.execute("VACUUM")
|
||||||
|
print("VACUUM complete.")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: prune failed: {exc}")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user