Harden SBOM retries and align hub evidence
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 21s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
This commit is contained in:
tegwick 2026-08-22 22:51:13 +02:00
parent 0f573c4378
commit 3b3e1a1ff0
17 changed files with 941 additions and 140 deletions

View file

@ -32,14 +32,10 @@ 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``.
With ``params.apply: true`` the adapter performs the declared T02 side-effect:
each selected repository receives exactly one terminal ingest or skip outcome.
The ranked response is truncated before any write, so the number of processed
repositories can never exceed ``limit``. The default remains read-only.
The ranked query is always read-only, including when a definition declares
``params.apply: true``. The workflow records that result in Temporal history,
then a dedicated activity applies the already-truncated fixed target set. This
prevents a retry from querying and advancing into a second batch.
Config: SBOM_NEXUS_URL env var (default: http://127.0.0.1:8010).
"""
@ -47,8 +43,10 @@ Config: SBOM_NEXUS_URL env var (default: http://127.0.0.1:8010).
from __future__ import annotations
import os
from collections.abc import Callable
from typing import Any
from urllib.parse import quote
from uuid import NAMESPACE_URL, uuid5
import httpx
@ -86,10 +84,22 @@ def _fetch_json(path: str, params: dict[str, Any] | None = None) -> Any:
return response.json()
def _post_json(path: str, payload: dict[str, Any] | None = None) -> Any:
def _post_json(
path: str,
payload: dict[str, Any] | None = None,
*,
idempotency_key: str,
) -> Any:
url = f"{_base_url()}{path}"
with httpx.Client(timeout=_TIMEOUT_SECONDS) as client:
response = client.post(url, json=payload)
response = client.post(
url,
json=payload,
headers={
"Idempotency-Key": idempotency_key,
"X-Activity-Core-Operation-ID": idempotency_key,
},
)
response.raise_for_status()
return response.json()
@ -145,10 +155,12 @@ def _catch_up(params: dict[str, Any]) -> dict[str, Any]:
raise RuntimeError("sbom-nexus catch_up response missing required key: repos")
repos: list[dict[str, Any]] = []
seen_slugs: set[str] = set()
for raw in raw_repos:
entry = _normalise_entry(raw)
if entry is not None:
if entry is not None and entry["repo_slug"] not in seen_slugs:
repos.append(entry)
seen_slugs.add(entry["repo_slug"])
# 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]
@ -157,7 +169,7 @@ def _catch_up(params: dict[str, Any]) -> dict[str, Any]:
never_count = _int_or(payload.get("never_count"), 0)
stale_count = _int_or(payload.get("stale_count"), len(repos))
result = {
return {
"repos": repos,
"selected_count": len(repos),
"stale_count": stale_count,
@ -165,65 +177,135 @@ def _catch_up(params: dict[str, Any]) -> dict[str, Any]:
"total_count": total_count,
"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]:
def _operation_key(operation_id: str, repo_slug: str) -> str:
return str(
uuid5(
NAMESPACE_URL,
f"activity-core:sbom-catchup:{operation_id}:{repo_slug}",
)
)
def _skip(
repo_slug: str,
reason: str,
*,
operation_id: 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":
raw = _post_json(
f"/sbom/{quote(repo_slug, safe='')}/skip",
payload,
idempotency_key=_operation_key(operation_id, repo_slug),
)
if (
not isinstance(raw, dict)
or raw.get("status") != "skipped"
or raw.get("reason") not in {"no-checkout", "no-manifest", "ingest-error"}
):
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")
def _ingest(repo_slug: str, *, operation_id: str) -> dict[str, Any]:
raw = _post_json(
f"/sbom/{quote(repo_slug, safe='')}/ingest",
idempotency_key=_operation_key(operation_id, repo_slug),
)
valid = isinstance(raw, dict) and raw.get("status") in {"ingested", "skipped"}
if not valid:
raise RuntimeError(f"sbom-nexus ingest returned an invalid outcome for {repo_slug}")
if raw.get("status") == "skipped" and raw.get("reason") not in {
"no-checkout",
"no-manifest",
"ingest-error",
}:
raise RuntimeError(f"sbom-nexus ingest returned an invalid skip for {repo_slug}")
return raw
def _apply_bounded_ingest(repos: list[dict[str, Any]]) -> dict[str, Any]:
updated: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
def _compact_outcome(repo_slug: str, outcome: dict[str, Any]) -> dict[str, Any]:
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)
return compact
def apply_bounded_ingest(
repos: list[dict[str, Any]],
*,
operation_id: str,
completed: list[dict[str, Any]] | None = None,
on_progress: Callable[[list[dict[str, Any]]], None] | None = None,
) -> dict[str, Any]:
"""Apply one fixed target set, resuming outcomes acknowledged by heartbeat.
Transport errors and malformed responses are deliberately not converted to
synthetic skips. The remote write may have committed, so only Nexus can
safely resolve that ambiguity through operation-id enforcement.
"""
selected: list[dict[str, Any]] = []
selected_slugs: set[str] = set()
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__)
if repo_slug not in selected_slugs:
selected.append(repo)
selected_slugs.add(repo_slug)
compact = {
key: outcome.get(key)
for key in (
"repo_slug",
"status",
"reason",
"snapshot_id",
"entry_count",
"snapshot_at",
"source_revision",
outcomes_by_slug = {
str(outcome.get("repo_slug")): dict(outcome)
for outcome in completed or []
if isinstance(outcome, dict)
and outcome.get("repo_slug") in selected_slugs
and outcome.get("status") in {"ingested", "skipped"}
}
for repo in selected:
repo_slug = str(repo["repo_slug"])
if repo_slug in outcomes_by_slug:
continue
if on_progress:
on_progress(list(outcomes_by_slug.values()))
if repo.get("checkout_available") is False:
outcome = _skip(
repo_slug,
"no-checkout",
operation_id=operation_id,
)
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)
outcome = _ingest(repo_slug, operation_id=operation_id)
outcomes_by_slug[repo_slug] = _compact_outcome(repo_slug, outcome)
if on_progress:
on_progress(list(outcomes_by_slug.values()))
ordered_outcomes = [
outcomes_by_slug[str(repo["repo_slug"])] for repo in selected
]
updated = [
outcome for outcome in ordered_outcomes if outcome.get("status") == "ingested"
]
skipped = [
outcome for outcome in ordered_outcomes if outcome.get("status") == "skipped"
]
return {
"attempted_count": len(repos),
"attempted_count": len(ordered_outcomes),
"updated": updated,
"skipped": skipped,
}