Harden SBOM retries and align hub evidence
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
This commit is contained in:
parent
0f573c4378
commit
3b3e1a1ff0
17 changed files with 941 additions and 140 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue