feat(reports): store and browse report artifacts

This commit is contained in:
zwitschi committed 2026-11-06 12:27:00 +01:00
1 parent 5967a43eb5
commit 6f2e479958
13 files changed
+875 -93

No files matched your search

+166 -1
View File
@@ -19,19 +19,30 @@ from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import re
import sys
from datetime import datetime, timezone
from ..utils.constants import (
APP_REPORT_DIR,
APP_REPORT_DATA_DIR,
DATA_DIR,
MIKROTIK_DATA_DIR,
MIKROTIK_REPORT_DIR,
NPM_REPORT_DIR,
COPYPARTY_REPORT_DIR,
)
MAX_REPORT_ARTIFACT_BYTES = 10 * 1024 * 1024
CANONICAL_REPORT_EXPORTS = {
"requests.csv", "ips.json", "ips_ipv4.csv", "ips_ipv6.csv",
"blocked_ipv4.txt", "blocked_ipv6.txt", "address_list_ipv4.json",
"address_list_ipv6.json", "firewall_ipv4.json", "firewall_ipv6.json",
}
def utcnow() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -92,7 +103,7 @@ def backfill_firewall(conn, dry_run: bool = False) -> dict:
"SELECT COUNT(*) FROM firewall_snapshots").fetchone()[0]
if n:
return {"firewall": {"dry_run": dry_run, "snapshot": "exists",
"skipped": True}}
"skipped": True}}
if dry_run:
return {"firewall": {"dry_run": True, "snapshot": None}}
sid = repo.save_firewall_snapshot(
@@ -161,6 +172,152 @@ def backfill_requests(conn, dry_run: bool = False) -> dict:
return {"requests": {"imported": total, "dry_run": dry_run}}
def backfill_report_artifacts(conn, dry_run: bool = False,
roots: dict[str, str] | None = None) -> dict:
"""Import supported report files as versioned, hash-addressed artifacts.
Canonical event exports and current firewall/blocklist data are skipped;
those belong in their normalized tables. Original files are never changed
or removed. Oversized or unreadable files are reported for manual review.
"""
from . import repositories as repo
roots = roots or {
"copyparty": COPYPARTY_REPORT_DIR,
"npm": NPM_REPORT_DIR,
"app": APP_REPORT_DIR,
"mikrotik": MIKROTIK_REPORT_DIR,
}
result = {
"discovered": 0, "imported": 0, "would_import": 0,
"already_imported": 0, "skipped_canonical": [], "unmapped": [],
"by_source": {}, "by_format": {}, "dry_run": dry_run,
}
def count(bucket: str, key: str, field: str) -> None:
stats = result[bucket].setdefault(key, {})
stats[field] = stats.get(field, 0) + 1
for source, root in roots.items():
if not os.path.isdir(root):
continue
for directory, _, filenames in os.walk(root):
for filename in sorted(filenames):
path = os.path.join(directory, filename)
suffix = os.path.splitext(filename)[1].lower()
if suffix not in (".txt", ".json", ".csv", ".rsc"):
continue
relative = os.path.relpath(path, root).replace(os.sep, "/")
result["discovered"] += 1
report_format = suffix.lstrip(".")
count("by_source", source, "discovered")
count("by_format", report_format, "discovered")
if filename.lower() in CANONICAL_REPORT_EXPORTS:
result["skipped_canonical"].append(
{"source": source, "path": relative})
count("by_source", source, "skipped_canonical")
count("by_format", report_format, "skipped_canonical")
continue
try:
size = os.path.getsize(path)
if size > MAX_REPORT_ARTIFACT_BYTES:
raise ValueError(
f"file exceeds {MAX_REPORT_ARTIFACT_BYTES} bytes")
with open(path, "rb") as file:
raw = file.read()
text = raw.decode("utf-8", errors="replace")
payload = _report_payload(suffix, text)
digest = hashlib.sha256(raw).hexdigest()
modified = datetime.fromtimestamp(
os.path.getmtime(path), timezone.utc)
generated_at = _report_generated_at(filename, modified)
except (OSError, ValueError) as exc:
result["unmapped"].append(
{"source": source, "path": relative,
"reason": str(exc)})
count("by_source", source, "unmapped")
count("by_format", report_format, "unmapped")
continue
original_path = f"{source}/{relative}"
exists = conn.execute(
"SELECT 1 FROM report_artifacts "
"WHERE original_path=? AND content_sha256=?",
(original_path, digest),
).fetchone()
if exists:
result["already_imported"] += 1
count("by_source", source, "already_imported")
count("by_format", report_format, "already_imported")
continue
if dry_run:
result["would_import"] += 1
count("by_source", source, "would_import")
count("by_format", report_format, "would_import")
continue
try:
repo.insert_report_artifact(
conn, source=source,
report_type=_report_type(suffix),
generated_at=generated_at, payload=payload,
original_path=original_path,
content_sha256=digest,
)
result["imported"] += 1
count("by_source", source, "imported")
count("by_format", report_format, "imported")
except (OSError, ValueError, TypeError) as exc:
result["unmapped"].append(
{"source": source, "path": relative,
"reason": str(exc)})
count("by_source", source, "unmapped")
count("by_format", report_format, "unmapped")
return {"reports": result}
def _report_payload(suffix: str, text: str) -> dict:
if suffix == ".json":
try:
return {"format": "json", "data": json.loads(text)}
except ValueError as exc:
return {"format": "json", "content": text,
"parse_error": str(exc)}
if suffix == ".csv":
try:
reader = csv.DictReader(text.splitlines())
return {"format": "csv", "columns": reader.fieldnames or [],
"rows": list(reader)}
except csv.Error as exc:
return {"format": "csv", "content": text,
"parse_error": str(exc)}
return {"format": "text", "content": text}
def _report_type(suffix: str) -> str:
return {".json": "json_report", ".csv": "csv_report",
".rsc": "routeros_script"}.get(suffix, "text_report")
def _report_generated_at(filename: str, modified: datetime) -> str:
match = re.search(
r"(?P<date>\d{4}-\d{2}-\d{2})(?:[-_T](?P<hour>\d{2})[-_:]"
r"(?P<minute>\d{2})[-_:](?P<second>\d{2}))?", filename)
if not match:
return modified.isoformat(timespec="seconds")
values = match.groupdict()
hour = int(values["hour"] or 0)
minute = int(values["minute"] or 0)
second = int(values["second"] or 0)
try:
parsed = datetime.strptime(values["date"], "%Y-%m-%d").replace(
hour=hour, minute=minute, second=second, tzinfo=timezone.utc)
except ValueError:
return modified.isoformat(timespec="seconds")
return parsed.isoformat(timespec="seconds")
def _int_or_none(v):
try:
if v in (None, "", "-"):
@@ -181,14 +338,22 @@ def main(argv: list[str] | None = None) -> None:
description="Backfill legacy runtime data into SQLite.")
p.add_argument("--dry-run", action="store_true",
help="report what would be imported without writing")
p.add_argument("--reports-only", action="store_true",
help="import legacy report artifacts only")
opts = p.parse_args(argv)
from . import get_connection
conn = get_connection()
print(f"== backfill (dry_run={opts.dry_run}) ==")
if opts.reports_only:
print("reports :", backfill_report_artifacts(
conn, dry_run=opts.dry_run))
print("== done ==")
return
print("blocklist:", backfill_blocklist(conn, dry_run=opts.dry_run))
print("firewall :", backfill_firewall(conn, dry_run=opts.dry_run))
print("requests :", backfill_requests(conn, dry_run=opts.dry_run))
print("reports :", backfill_report_artifacts(conn, dry_run=opts.dry_run))
print("== done ==")