feat(sbom): draft bounded daily SBOM catch-up (ACTIVITY-WP-0030-T01)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 23s

Add the sbom-nexus catch_up resolver contract and the daily replacement for
weekly-sbom-staleness, both disabled until CUST-WP-0062-T03 lands.

The weekly check used `for_each: context.repos.repos` and emitted one task per
stale repo — 75 tasks on 2026-08-17 against 111/111 stale repos. The
replacement asks sbom-nexus for only the N oldest-stale repos in one ranked
call and carries no rule block at all, so tasks_spawned is 0 by construction.

- context_resolvers/sbom_nexus.py: source type `sbom-nexus`, query `catch_up`,
  GET /sbom/catch-up?limit=N. Read-only; ingest is T02. Limit bounded 1..25 and
  the response truncated to it so an over-long reply cannot widen T02's
  side-effect.
- activity-definitions/daily-sbom-catchup.md: weekdays 09:15 Berlin, enabled:
  false, deterministic sbom_catchup progress sink.
- rules/executor.py: the deterministic report builder only special-cased
  context.repos, which would have emitted a contentless progress event for this
  definition. _sbom_catchup_report names the selected repos and reads
  updated/skipped from context when T02 populates them.
- 17 tests against a test double; no live nexus exists yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-21 08:45:40 +02:00
parent a446de1c45
commit e64af4102d
6 changed files with 667 additions and 2 deletions

View file

@ -4,6 +4,7 @@ from activity_core.context_resolvers import ( # noqa: F401
kaizen,
ops_inventory,
repo_scoping,
sbom_nexus,
state_hub,
reuse_surface,
)

View file

@ -0,0 +1,169 @@
"""sbom-nexus context adapter (ACTIVITY-WP-0030-T01).
Registered as source type ``sbom-nexus``.
Supported queries:
- catch_up: GET {SBOM_NEXUS_URL}/sbom/catch-up?limit=N
Contract (CUST-WP-0062-T03). One ranked call replaces the fleet-wide
``for_each`` walk that produced the 2026-08-17 task flood: the nexus returns
only the N repos that have lacked a current SBOM the longest (never-scanned
first), plus fleet counts for the evidence report.
input : {"limit": 3}
output: {
"repos": [
{
"repo_slug": str,
"last_sbom_at": str | None,
"sbom_age_days": int,
"has_sbom": bool,
"checkout_available": bool | None,
},
...
],
"stale_count": int,
"never_count": int,
"total_count": int,
"limit": int,
}
Ordering is the nexus's responsibility (never-scanned first, then oldest
``last_sbom_at``); this adapter validates the shape and normalises the entries
so the deterministic report can render them without comprehensions.
Until CUST-WP-0062-T03 lands there is no live endpoint — the query is exercised
against a test double (``tests/test_sbom_nexus_context_resolver.py``) and the
daily definition stays ``enabled: false``.
This adapter is read-only. Ingest of the selected repos is a declared bounded
side-effect owned by ACTIVITY-WP-0030-T02 and is deliberately not implemented
here.
Config: SBOM_NEXUS_URL env var (default: http://127.0.0.1:8010).
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from activity_core.context_resolvers.base import CONTEXT_RESOLVER_REGISTRY, ContextResolver
_DEFAULT_SBOM_NEXUS_URL = "http://127.0.0.1:8010"
_TIMEOUT_SECONDS = 15.0
_DEFAULT_CATCH_UP_LIMIT = 3
_MIN_CATCH_UP_LIMIT = 1
_MAX_CATCH_UP_LIMIT = 25
# Mirrors state_hub._NEVER_SCANNED_AGE_DAYS: a repo that was never scanned
# sorts ahead of every real age without needing a null-aware comparison.
_NEVER_SCANNED_AGE_DAYS = 9999
def _base_url() -> str:
return os.getenv("SBOM_NEXUS_URL", _DEFAULT_SBOM_NEXUS_URL).rstrip("/")
def _bounded_limit(value: Any) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return _DEFAULT_CATCH_UP_LIMIT
return max(_MIN_CATCH_UP_LIMIT, min(_MAX_CATCH_UP_LIMIT, parsed))
def _fetch_json(path: str, params: dict[str, Any] | None = None) -> Any:
url = f"{_base_url()}{path}"
with httpx.Client(timeout=_TIMEOUT_SECONDS) as client:
response = client.get(url, params=params)
response.raise_for_status()
return response.json()
def _int_or(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _normalise_entry(raw: Any) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
repo_slug = raw.get("repo_slug")
if not isinstance(repo_slug, str) or not repo_slug:
return None
last_sbom_at = raw.get("last_sbom_at")
if not isinstance(last_sbom_at, str) or not last_sbom_at:
last_sbom_at = None
has_sbom = raw.get("has_sbom")
if not isinstance(has_sbom, bool):
has_sbom = last_sbom_at is not None
age_days = _int_or(
raw.get("sbom_age_days"),
0 if has_sbom else _NEVER_SCANNED_AGE_DAYS,
)
checkout_available = raw.get("checkout_available")
if not isinstance(checkout_available, bool):
checkout_available = None
return {
"repo_slug": repo_slug,
"last_sbom_at": last_sbom_at,
"sbom_age_days": max(0, age_days),
"has_sbom": has_sbom,
"checkout_available": checkout_available,
}
def _catch_up(params: dict[str, Any]) -> dict[str, Any]:
limit = _bounded_limit(params.get("limit", _DEFAULT_CATCH_UP_LIMIT))
payload = _fetch_json("/sbom/catch-up", {"limit": limit})
if not isinstance(payload, dict):
raise RuntimeError("sbom-nexus catch_up returned a non-object response")
raw_repos = payload.get("repos")
if not isinstance(raw_repos, list):
raise RuntimeError("sbom-nexus catch_up response missing required key: repos")
repos: list[dict[str, Any]] = []
for raw in raw_repos:
entry = _normalise_entry(raw)
if entry is not None:
repos.append(entry)
# The nexus owns ranking, but the definition promises "at most N": never let
# an over-long response widen the bounded side-effect in T02.
repos = repos[:limit]
total_count = _int_or(payload.get("total_count"), len(repos))
never_count = _int_or(payload.get("never_count"), 0)
stale_count = _int_or(payload.get("stale_count"), len(repos))
return {
"repos": repos,
"selected_count": len(repos),
"stale_count": stale_count,
"never_count": never_count,
"total_count": total_count,
"limit": limit,
}
class SbomNexusContextResolver(ContextResolver):
"""Fetches the ranked SBOM catch-up queue from sbom-nexus."""
def resolve(self, query: str, event: Any, params: dict[str, Any]) -> Any:
if query == "catch_up":
return _catch_up(params)
return {}
CONTEXT_RESOLVER_REGISTRY["sbom-nexus"] = SbomNexusContextResolver