Implement bounded SBOM Nexus catch-up
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 23s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
tegwick 2026-08-22 20:45:02 +02:00
parent 192f74f678
commit 8e8c74bd4c
3 changed files with 221 additions and 20 deletions

View file

@ -16,6 +16,7 @@ context_sources:
required: true required: true
params: params:
limit: 3 # catch_up_limit — operator knob, not a nexus constant limit: 3 # catch_up_limit — operator knob, not a nexus constant
apply: true # declared bounded side-effect; manual runs require confirmation
bind_to: context.catchup bind_to: context.catchup
# One ranked call returns only the N repos that have lacked a current SBOM the # One ranked call returns only the N repos that have lacked a current SBOM the
# longest (never-scanned first) plus fleet counts. There is deliberately no # longest (never-scanned first) plus fleet counts. There is deliberately no
@ -24,10 +25,9 @@ context_sources:
# Daily SBOM Catch-up # Daily SBOM Catch-up
> **Disabled until CUST-WP-0062-T03 lands.** The `sbom-nexus` catch-up API does > **Disabled until production enable evidence is ready.** The `sbom-nexus`
> not exist yet; the resolver contract is exercised against a test double in > ranked and terminal ingest/skip APIs are now available. Keep this definition
> `tests/test_sbom_nexus_context_resolver.py`. Do not enable this schedule > off until cluster reachability and a bounded manual fire are proven.
> against the old per-repo `/sbom/{slug}` walk (CUST-WP-0062-T05).
Replaces `weekly-sbom-staleness` / `flag-stale-sbom` (ACTIVITY-WP-0030). Replaces `weekly-sbom-staleness` / `flag-stale-sbom` (ACTIVITY-WP-0030).
The weekly check reported the backlog — 111 / 111 repos stale on 2026-08-18, The weekly check reported the backlog — 111 / 111 repos stale on 2026-08-18,
@ -79,18 +79,20 @@ The progress event names the repos selected, updated, and skipped with a
reason (`no-checkout`, `no-manifest`, `ingest-error`), plus the fleet counters reason (`no-checkout`, `no-manifest`, `ingest-error`), plus the fleet counters
so `never_count` can be watched declining day over day. so `never_count` can be watched declining day over day.
## Not yet implemented (ACTIVITY-WP-0030-T02) ## Bounded side-effect (ACTIVITY-WP-0030-T02)
The bounded ingest side-effect — calling sbom-nexus ingest for each of the N `params.apply: true` calls sbom-nexus for each of the already-truncated N
selected repos using the registered checkout — waits on CUST-WP-0062-T03. Until targets. A target with an explicitly unavailable checkout is recorded through
then this definition resolves and reports only. When T02 lands, a recorded skip `POST /sbom/{slug}/skip` as `no-checkout`; other targets use the terminal ingest
must advance the queue position so an impossible repo does not permanently head route, which returns `ingested`, `no-manifest`, or `ingest-error`. Transport and
it. contract failures are recorded as `ingest-error`. The resulting `updated` and
`skipped` arrays are part of the run context and progress report. There is no
task or issue emission.
## Enable checklist ## Enable checklist
1. CUST-WP-0062-T02/T03 done: `sbom-nexus` stood up, `GET /sbom/catch-up` 1. CUST-WP-0062-T02/T03 done: `sbom-nexus` stood up, `GET /sbom/catch-up`
returns oldest-N in one call. returns oldest-N in one call. **Done 2026-08-22.**
2. `SBOM_NEXUS_URL` reachable from the railiance01 worker. 2. `SBOM_NEXUS_URL` reachable from the railiance01 worker.
3. ACTIVITY-WP-0030-T02 ingest side-effect implemented and dry-run proven. 3. ACTIVITY-WP-0030-T02 ingest side-effect implemented and dry-run proven.
4. `weekly-sbom-staleness` confirmed off in source **and** production 4. `weekly-sbom-staleness` confirmed off in source **and** production

View file

@ -36,9 +36,10 @@ Until CUST-WP-0062-T03 lands there is no live endpoint — the query is exercise
against a test double (``tests/test_sbom_nexus_context_resolver.py``) and the against a test double (``tests/test_sbom_nexus_context_resolver.py``) and the
daily definition stays ``enabled: false``. daily definition stays ``enabled: false``.
This adapter is read-only. Ingest of the selected repos is a declared bounded With ``params.apply: true`` the adapter performs the declared T02 side-effect:
side-effect owned by ACTIVITY-WP-0030-T02 and is deliberately not implemented each selected repository receives exactly one terminal ingest or skip outcome.
here. The ranked response is truncated before any write, so the number of processed
repositories can never exceed ``limit``. The default remains read-only.
Config: SBOM_NEXUS_URL env var (default: http://127.0.0.1:8010). Config: SBOM_NEXUS_URL env var (default: http://127.0.0.1:8010).
""" """
@ -47,6 +48,7 @@ from __future__ import annotations
import os import os
from typing import Any from typing import Any
from urllib.parse import quote
import httpx import httpx
@ -84,6 +86,14 @@ def _fetch_json(path: str, params: dict[str, Any] | None = None) -> Any:
return response.json() return response.json()
def _post_json(path: str, payload: dict[str, Any] | None = None) -> Any:
url = f"{_base_url()}{path}"
with httpx.Client(timeout=_TIMEOUT_SECONDS) as client:
response = client.post(url, json=payload)
response.raise_for_status()
return response.json()
def _int_or(value: Any, default: int) -> int: def _int_or(value: Any, default: int) -> int:
try: try:
return int(value) return int(value)
@ -147,7 +157,7 @@ def _catch_up(params: dict[str, Any]) -> dict[str, Any]:
never_count = _int_or(payload.get("never_count"), 0) never_count = _int_or(payload.get("never_count"), 0)
stale_count = _int_or(payload.get("stale_count"), len(repos)) stale_count = _int_or(payload.get("stale_count"), len(repos))
return { result = {
"repos": repos, "repos": repos,
"selected_count": len(repos), "selected_count": len(repos),
"stale_count": stale_count, "stale_count": stale_count,
@ -155,6 +165,68 @@ def _catch_up(params: dict[str, Any]) -> dict[str, Any]:
"total_count": total_count, "total_count": total_count,
"limit": limit, "limit": limit,
} }
if params.get("apply") is True:
result.update(_apply_bounded_ingest(repos))
return result
def _skip(repo_slug: str, reason: str, detail: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"reason": reason}
if detail:
payload["detail"] = detail[:300]
raw = _post_json(f"/sbom/{quote(repo_slug, safe='')}/skip", payload)
if not isinstance(raw, dict) or raw.get("status") != "skipped":
raise RuntimeError(f"sbom-nexus skip returned an invalid outcome for {repo_slug}")
return raw
def _ingest(repo_slug: str) -> dict[str, Any]:
raw = _post_json(f"/sbom/{quote(repo_slug, safe='')}/ingest")
if not isinstance(raw, dict) or raw.get("status") not in {"ingested", "skipped"}:
return _skip(repo_slug, "ingest-error", "invalid ingest outcome")
return raw
def _apply_bounded_ingest(repos: list[dict[str, Any]]) -> dict[str, Any]:
updated: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for repo in repos:
repo_slug = str(repo["repo_slug"])
try:
if repo.get("checkout_available") is False:
outcome = _skip(repo_slug, "no-checkout")
else:
outcome = _ingest(repo_slug)
except Exception as exc:
# A transport or contract failure still needs a terminal Nexus
# outcome so the same impossible repository cannot pin the queue.
outcome = _skip(repo_slug, "ingest-error", type(exc).__name__)
compact = {
key: outcome.get(key)
for key in (
"repo_slug",
"status",
"reason",
"snapshot_id",
"entry_count",
"snapshot_at",
"source_revision",
)
if outcome.get(key) is not None
}
compact.setdefault("repo_slug", repo_slug)
if outcome.get("status") == "ingested":
updated.append(compact)
else:
skipped.append(compact)
return {
"attempted_count": len(repos),
"updated": updated,
"skipped": skipped,
}
class SbomNexusContextResolver(ContextResolver): class SbomNexusContextResolver(ContextResolver):

View file

@ -1,8 +1,4 @@
"""sbom-nexus catch_up resolver (ACTIVITY-WP-0030-T01). """sbom-nexus bounded catch-up resolver (ACTIVITY-WP-0030-T01/T02)."""
CUST-WP-0062-T03 has not landed, so the contract is exercised against a test
double rather than a live nexus.
"""
from __future__ import annotations from __future__ import annotations
@ -40,6 +36,8 @@ class DummyClient:
self._payload = payload self._payload = payload
self._status_code = status_code self._status_code = status_code
self.calls: list[tuple[str, dict[str, Any] | None]] = [] self.calls: list[tuple[str, dict[str, Any] | None]] = []
self.posts: list[tuple[str, dict[str, Any] | None]] = []
self.post_results: dict[str, list[DummyResponse]] = {}
def __enter__(self) -> "DummyClient": def __enter__(self) -> "DummyClient":
return self return self
@ -51,6 +49,13 @@ class DummyClient:
self.calls.append((url, params)) self.calls.append((url, params))
return DummyResponse(self._payload, self._status_code) return DummyResponse(self._payload, self._status_code)
def post(self, url: str, json: dict[str, Any] | None = None) -> DummyResponse:
self.posts.append((url, json))
for suffix, results in self.post_results.items():
if url.endswith(suffix) and results:
return results.pop(0)
raise AssertionError(f"unexpected POST {url}")
def _install(monkeypatch, payload: Any, status_code: int = 200) -> DummyClient: def _install(monkeypatch, payload: Any, status_code: int = 200) -> DummyClient:
client = DummyClient(payload, status_code) client = DummyClient(payload, status_code)
@ -155,6 +160,127 @@ def test_catch_up_truncates_an_over_long_response(monkeypatch) -> None:
assert result["selected_count"] == 2 assert result["selected_count"] == 2
def test_apply_processes_at_most_limit_and_records_terminal_outcomes(monkeypatch) -> None:
repos = [
{
"repo_slug": "no-checkout",
"last_sbom_at": None,
"has_sbom": False,
"checkout_available": False,
},
{
"repo_slug": "scan-me",
"last_sbom_at": None,
"has_sbom": False,
"checkout_available": True,
},
{
"repo_slug": "not-selected",
"last_sbom_at": None,
"has_sbom": False,
"checkout_available": True,
},
]
client = _install(monkeypatch, _payload(repos))
client.post_results = {
"/sbom/no-checkout/skip": [
DummyResponse(
{
"repo_slug": "no-checkout",
"status": "skipped",
"reason": "no-checkout",
"snapshot_id": "skip-1",
}
)
],
"/sbom/scan-me/ingest": [
DummyResponse(
{
"repo_slug": "scan-me",
"status": "ingested",
"snapshot_id": "scan-1",
"entry_count": 12,
}
)
],
}
result = SbomNexusContextResolver().resolve(
"catch_up", None, {"limit": 2, "apply": True}
)
assert result["attempted_count"] == 2
assert result["updated"] == [
{
"repo_slug": "scan-me",
"status": "ingested",
"snapshot_id": "scan-1",
"entry_count": 12,
}
]
assert result["skipped"] == [
{
"repo_slug": "no-checkout",
"status": "skipped",
"reason": "no-checkout",
"snapshot_id": "skip-1",
}
]
assert len(client.posts) == 2
assert all("not-selected" not in url for url, _body in client.posts)
def test_apply_records_ingest_error_when_ingest_fails(monkeypatch) -> None:
client = _install(
monkeypatch,
_payload(
[
{
"repo_slug": "broken",
"last_sbom_at": None,
"has_sbom": False,
"checkout_available": True,
}
]
),
)
client.post_results = {
"/sbom/broken/ingest": [DummyResponse({}, status_code=503)],
"/sbom/broken/skip": [
DummyResponse(
{
"repo_slug": "broken",
"status": "skipped",
"reason": "ingest-error",
"snapshot_id": "skip-error-1",
}
)
],
}
result = SbomNexusContextResolver().resolve(
"catch_up", None, {"limit": 3, "apply": True}
)
assert result["attempted_count"] == 1
assert result["updated"] == []
assert result["skipped"][0]["reason"] == "ingest-error"
assert client.posts[1][1] == {
"reason": "ingest-error",
"detail": "HTTPStatusError",
}
def test_read_only_default_never_posts(monkeypatch) -> None:
client = _install(monkeypatch, _payload([{"repo_slug": "selected"}]))
result = SbomNexusContextResolver().resolve("catch_up", None, {"limit": 1})
assert result["selected_count"] == 1
assert "attempted_count" not in result
assert client.posts == []
def test_catch_up_normalises_partial_entries(monkeypatch) -> None: def test_catch_up_normalises_partial_entries(monkeypatch) -> None:
_install( _install(
monkeypatch, monkeypatch,
@ -312,6 +438,7 @@ def test_daily_definition_is_bounded_and_disabled() -> None:
assert source["type"] == "sbom-nexus" assert source["type"] == "sbom-nexus"
assert source["query"] == "catch_up" assert source["query"] == "catch_up"
assert source["params"]["limit"] == 3 assert source["params"]["limit"] == 3
assert source["params"]["apply"] is True
assert source["bind_to"] == "context.catchup" assert source["bind_to"] == "context.catchup"
instruction = definition.instructions[0] instruction = definition.instructions[0]
assert instruction["model"] == "deterministic" assert instruction["model"] == "deterministic"