secubox-deb/docs/CACHE-PATTERN.md
CyberMind 3435cfa069
feat(scripts): add check-dashboard-cache.py lint + CI (closes #147) (#148)
Anti-regression for the double-cache pattern documented in CLAUDE.md
(« Performance Patterns — Double Caching »). Catches the bug class that
caused PR #146 (secubox-waf 17 % sustained CPU under SOC polling)
before it lands.

What's added
============
- scripts/check-dashboard-cache.py — AST-based audit. Walks every
  packages/secubox-*/api/main.py, flags hot dashboard routes
  (/stats, /alerts, /summary, /dashboard, /metrics, /overview,
  /events, /history, /recent, /bans, /sessions, /connections)
  whose handler body does per-request I/O (open, read_text,
  read_bytes, subprocess.run, Popen) AND doesn't go through a cache
  (module-level _cache instance + .get() call, OR
  asyncio.create_task(refresh_*) at startup).
    * --check : exit 1 on unjustified violations (CI mode)
    * --report : human-readable table (default)
    * --json : machine-readable

- .cache-lint-allowlist.toml — TOML allowlist for justified
  exceptions. Each entry needs a daemon + route + justification
  (issue link expected). Seeded with the 4 current legacy cases:
  secubox-waf (covered by PR #146 — will drop the entries when
  merged), secubox-config-advisor /history (admin drill-in, not
  polled), secubox-netdiag /connections (real-time TCP state),
  secubox-tor /summary (small file read, follow-up cleanup).

- .github/workflows/dashboard-cache-check.yml — mirror of
  license-check.yml. Runs the lint + the self-tests on PRs that
  touch packages/**/api/main.py, the lint script, the allowlist,
  or the workflow itself.

- docs/CACHE-PATTERN.md — short remediation guide. Three accepted
  shapes (read-through, background-refresh, pure in-memory), code
  example, allowlist syntax, references to the canonical
  implementations (secubox-crowdsec for read-through, secubox-system
  for background-refresh).

Tests (14/14 pass)
==================
- StatsCache pattern detection (compliant cases)
- Non-compliant detection: open, subprocess.run, multiple I/O ops
- Pure in-memory handler = compliant
- Background-refresh task = compliant
- Non-hot route (/health) not audited
- @router.get treated like @app.get
- Allowlist suppresses by exact (daemon, route) key
- Allowlist doesn't cross-pollinate across routes
- Missing allowlist file = no error
- CLI --check exits 1 on unjustified
- CLI --check exits 0 when clean
- CLI --json output well-formed
- **Real repo audit with seeded allowlist passes --check**
  (sanity gate: catches regression if anyone removes an allowlist
  entry without fixing the daemon)

Baseline against the real packages/ tree
========================================
122 daemons audited, 118 compliant, 4 with findings (all
allowlisted). When PR #146 merges, the secubox-waf entries can be
removed from the allowlist — the next lint run will then prove the
fix is wired correctly.

Follow-up
=========
- Drop secubox-waf allowlist entries when #146 merges.
- Per-daemon follow-up issues for the remaining 3 (config-advisor,
  netdiag, tor) — not blocking, allowlist documents the rationale.
- Consider extending HOT_ROUTES to /health / /status once the small
  daemons that call `systemctl is-active` adopt a 10 s cache.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:21:20 +02:00

4.3 KiB

Double-Cache Pattern — SecuBox dashboards

Status: enforced by scripts/check-dashboard-cache.py via the Dashboard Cache Lint GitHub Actions workflow.

TL;DR

If your FastAPI handler in packages/secubox-*/api/main.py matches any of the hot routes below and reads logs / runs subprocesses / opens files, it must go through a cache. The lint will block the PR otherwise.

Hot routes :

/stats   /alerts   /summary   /dashboard   /metrics   /overview
/events  /history  /recent    /bans        /sessions  /connections

Why

These endpoints are typically polled by dashboards (SOC, Hub, c3box…) every 5 s. Without a cache, each call does the full work (log scan, GeoIP lookup, subprocess) → CPU saturation under polling. See live-bench on admin.gk2.secubox.in (2026-05-15) where secubox-waf sustained 17 % CPU and 1035 syscalls/sec from a single open SOC tab.

CLAUDE.md says it directly:

Toujours pour les dashboards stats (WAF, CrowdSec, bandwidth, DPI…) Toujours quand l'endpoint lit des logs ou calcule des agrégats Toujours quand la donnée peut être périmée de 60s sans impact utilisateur

How to comply

Three accepted shapes — pick one.

1. Read-through cache (preferred — simple, robust)

Mirror packages/secubox-crowdsec/api/main.py:106-138 (or packages/secubox-waf/api/main.py after PR #146 merges):

import threading, time
from typing import Any, Dict, Optional

class StatsCache:
    def __init__(self, ttl_seconds: int = 30):
        self.ttl = ttl_seconds
        self._cache: Dict[str, Any] = {}
        self._timestamps: Dict[str, float] = {}
        self._lock = threading.Lock()

    def get(self, key: str) -> Optional[Any]:
        with self._lock:
            if key in self._cache and time.time() - self._timestamps[key] < self.ttl:
                return self._cache[key]
        return None

    def set(self, key: str, value: Any):
        with self._lock:
            self._cache[key] = value
            self._timestamps[key] = time.time()

    def invalidate(self, key: Optional[str] = None):
        with self._lock:
            if key:
                self._cache.pop(key, None); self._timestamps.pop(key, None)
            else:
                self._cache.clear(); self._timestamps.clear()


stats_cache = StatsCache(ttl_seconds=30)


@app.get("/stats")
async def get_stats():
    cached = stats_cache.get("stats")
    if cached is not None:
        return cached
    stats = compute_stats()        # the expensive bit
    stats_cache.set("stats", stats)
    return stats

Invalidate explicitly on mutating endpoints :

@app.post("/ban")
async def ban(...):
    do_the_ban()
    stats_cache.invalidate()
    return {...}

2. Background-refresh task

Spawn a periodic task at startup that pre-warms the cache.

async def refresh_cache():
    while True:
        try:
            _cache.update(await compute_stats())
        except Exception as e:
            log.error("refresh failed: %s", e)
        await asyncio.sleep(60)

@app.on_event("startup")
async def startup():
    asyncio.create_task(refresh_cache())

This is more code but lets you keep handler bodies trivial. Used by secubox-system, secubox-publish, others.

3. Pure in-memory handler

If the handler doesn't open files, run subprocesses, or read logs, the lint won't flag it. Trivial in-memory aggregations from runtime state are fine without a cache.

How to add an exception

Only if you genuinely can't comply right now :

# .cache-lint-allowlist.toml
[[entries]]
daemon = "secubox-myservice"
route = "/stats"
justification = "Reason + issue link. e.g. real-time data, caching would mislead — see #999"

Every entry is implicitly a TODO. Drop the entry when you patch the daemon.

Running locally

python3 scripts/check-dashboard-cache.py --report   # full table
python3 scripts/check-dashboard-cache.py --check    # CI mode, rc=1 if violations
python3 scripts/check-dashboard-cache.py --json     # machine-readable

Tests :

python3 -m pytest scripts/tests/test_check_dashboard_cache.py -v

Reference implementation

  • Read-through : packages/secubox-crowdsec/api/main.py:106-138
  • Read-through (after PR #146) : packages/secubox-waf/api/main.py
  • Background-refresh : packages/secubox-system/api/main.py (search for create_task)