feat(reports): store and browse report artifacts
This commit is contained in:
1 parent
5967a43eb5
commit
6f2e479958
13 files changed
+875
-93
No files matched your search
+166
-1
@@ -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 ==")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user