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
+20
View File
@@ -14,6 +14,26 @@ def ledger_health_check() -> dict:
"""Verify the Jarca Ledger MCP server and SQLite database are reachable."""
return service.health_check()
@mcp.tool()
def get_ledger_db_status(include_hash: bool = True, include_integrity: bool = True) -> dict:
"""Return non-sensitive DB status evidence: path, size, mtime, checksum, table row counts, and aggregate totals."""
return service.get_ledger_db_status(include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool()
def create_ledger_backup(backup_dir: str = "", retention_days: int = 30, retention_count: int = 20) -> dict:
"""Create a timestamped ledger DB backup. Restore is intentionally not exposed as an MCP tool."""
return service.create_ledger_backup(backup_dir=backup_dir or None, retention_days=retention_days, retention_count=retention_count)
@mcp.tool()
def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict:
"""List ledger DB backups with metadata-only evidence."""
return service.list_ledger_backups(backup_dir=backup_dir or None, include_hash=include_hash, limit=limit)
@mcp.tool()
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict:
"""Return metadata-only status and integrity evidence for one backup file."""
return service.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
@mcp.tool()
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 = "") -> dict:
"""Create an account/card/cash/investment/loan ledger account."""
+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")