feat: add ledger backup safety workflow

confirm dcy-002 and add REQ/BAT evidence

add ledger backup/status metadata workflow

document restore as runbook/script-only guarded operation

add synthetic DB backup/restore smoke tests
This commit is contained in:
Jarca
2026-06-27 10:40:06 +09:00
parent 0f881b245f
commit 67d8d4871b
19 changed files with 1035 additions and 8 deletions
+161 -1
View File
@@ -4,7 +4,8 @@ import csv
import hashlib
import json
import re
from datetime import date, datetime
import sqlite3
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
@@ -31,6 +32,12 @@ END)"""
CROSS_TRANSFER_MATCH_TAG = "cross_transfer_match:"
CROSS_TRANSFER_REVIEW_TAG = "cross_transfer_review:"
DEFAULT_LEDGER_BACKUP_DIR = Path.home() / ".local" / "share" / "jarca" / "backups" / "ledger"
BACKUP_RESTORE_POLICY = {
"mcp_restore_exposed": False,
"restore_requires_explicit_user_approval": True,
"restore_entrypoint": "scripts/ledger_restore_smoke.py 또는 docs/runbooks/ledger-backup-restore.md",
}
def _normalize_counterparty_for_match(value: str) -> str:
@@ -134,6 +141,159 @@ def health_check(db_path: str | None = None) -> dict[str, Any]:
tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active'").fetchone()[0]
return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count)
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _file_metadata(path: Path, include_hash: bool = True) -> dict[str, Any]:
stat = path.stat()
metadata: dict[str, Any] = {
"path": str(path.resolve()),
"size_bytes": stat.st_size,
"mtime": datetime.fromtimestamp(stat.st_mtime).replace(microsecond=0).isoformat(),
}
if include_hash:
metadata["sha256"] = _sha256_file(path)
return metadata
def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
counts: dict[str, int] = {}
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories"):
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
if exists:
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
return counts
def _safe_monthly_aggregates(conn: sqlite3.Connection) -> dict[str, Any]:
current_month = date.today().strftime("%Y-%m")
start, next_month = _month_range(current_month)
rows = conn.execute(
f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total, COUNT(*) AS count
FROM transactions t
LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date<?
GROUP BY {CASHFLOW_KIND_SQL}""",
(start, next_month),
).fetchall()
return {
"month": current_month,
"by_cashflow_kind": {
str(row["cashflow_kind"]): {"amount": int(row["total"] or 0), "count": int(row["count"] or 0)}
for row in rows
},
}
def _ledger_db_status_for_path(path: Path, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
status: dict[str, Any] = {"db_path": str(path.resolve()), "exists": path.exists()}
if not path.exists():
return status
status.update(_file_metadata(path, include_hash=include_hash))
try:
with _readonly_sqlite_connection(path) as conn:
if include_integrity:
status["integrity_check"] = str(conn.execute("PRAGMA integrity_check").fetchone()[0])
status["table_counts"] = _safe_table_counts(conn)
status["aggregate_totals"] = _safe_monthly_aggregates(conn)
except Exception as e:
status["integrity_check"] = "error"
status["error"] = str(e)
return status
def get_ledger_db_status(db_path: str | None = None, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
"""Return non-sensitive metadata evidence for the selected ledger DB."""
path = resolve_db_path(db_path)
if not path.exists():
return fail("not_found", f"ledger DB not found: {path}", db_path=str(path), exists=False)
return ok(**_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity))
def _resolve_backup_dir(backup_dir: str | None = None) -> Path:
return Path(backup_dir).expanduser().resolve() if backup_dir else DEFAULT_LEDGER_BACKUP_DIR.resolve()
def _backup_file_metadata(path: Path, include_hash: bool = False) -> dict[str, Any]:
metadata = _file_metadata(path, include_hash=include_hash)
metadata["name"] = path.name
metadata["restore_policy"] = BACKUP_RESTORE_POLICY
return metadata
def _apply_backup_retention(backup_dir: Path, retention_days: int = 30, retention_count: int = 20) -> list[str]:
backups = sorted(backup_dir.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)
keep_count = max(1, int(retention_count))
cutoff = datetime.now() - timedelta(days=max(0, int(retention_days)))
pruned: list[str] = []
for idx, path in enumerate(backups):
mtime = datetime.fromtimestamp(path.stat().st_mtime)
should_keep = idx < keep_count or mtime >= cutoff
if should_keep:
continue
path.unlink()
pruned.append(str(path.resolve()))
return pruned
def create_ledger_backup(db_path: str | None = None, backup_dir: str | None = None, retention_days: int = 30, retention_count: int = 20) -> dict[str, Any]:
"""Create a timestamped SQLite backup and return metadata-only evidence."""
source_path = resolve_db_path(db_path)
if not source_path.exists():
return fail("not_found", f"ledger DB not found: {source_path}", db_path=str(source_path), exists=False)
backup_root = _resolve_backup_dir(backup_dir)
backup_root.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
backup_path = backup_root / f"ledger_{timestamp}.sqlite3"
try:
# Use SQLite's online backup API so WAL-mode databases are copied consistently.
with sqlite3.connect(f"file:{source_path}?mode=ro", uri=True) as source_conn:
with sqlite3.connect(backup_path) as backup_conn:
source_conn.backup(backup_conn)
except Exception:
if backup_path.exists():
backup_path.unlink()
raise
source_status = _ledger_db_status_for_path(source_path, include_hash=True, include_integrity=True)
backup_status = _ledger_db_status_for_path(backup_path, include_hash=True, include_integrity=True)
pruned_paths = _apply_backup_retention(backup_root, retention_days=retention_days, retention_count=retention_count)
return ok(
source_db=source_status,
backup=backup_status,
backup_dir=str(backup_root),
retention={"retention_days": int(retention_days), "retention_count": int(retention_count), "pruned_paths": pruned_paths},
restore_policy=BACKUP_RESTORE_POLICY,
)
def list_ledger_backups(backup_dir: str | None = None, include_hash: bool = False, limit: int = 50) -> dict[str, Any]:
backup_root = _resolve_backup_dir(backup_dir)
if not backup_root.exists():
return ok(backup_dir=str(backup_root), backups=[], count=0, restore_policy=BACKUP_RESTORE_POLICY)
max_items = max(1, min(int(limit), 500))
files = sorted(backup_root.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)[:max_items]
backups = [_backup_file_metadata(path, include_hash=include_hash) for path in files]
return ok(backup_dir=str(backup_root), backups=backups, count=len(backups), restore_policy=BACKUP_RESTORE_POLICY)
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]:
path = Path(file_path).expanduser().resolve()
if not path.exists():
return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False)
return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY)
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", db_path: str | None = None) -> dict[str, Any]:
if not name.strip():
return fail("validation_error", "account name is required")