feat: add reversible SBOM Nexus read facade
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
tegwick 2026-08-22 18:39:21 +02:00
parent 9e7e91300f
commit 5fc4c56215
7 changed files with 368 additions and 3 deletions

View file

@ -1,3 +1,5 @@
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@ -17,6 +19,9 @@ class Settings(BaseSettings):
activity_core_worker_token: str | None = None activity_core_worker_token: str | None = None
ops_run_projection_ttl_seconds: float = 15.0 ops_run_projection_ttl_seconds: float = 15.0
ops_run_sla_hours: float = 1.0 ops_run_sla_hours: float = 1.0
sbom_nexus_url: str | None = None
sbom_nexus_read_mode: Literal["legacy", "nexus"] = "legacy"
sbom_nexus_timeout_seconds: float = 5.0
settings = Settings() settings = Settings()

View file

@ -1,7 +1,8 @@
import uuid import uuid
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -18,8 +19,11 @@ from api.schemas.sbom import (
SBOMSnapshotDetail, SBOMSnapshotDetail,
SBOMSnapshotRead, SBOMSnapshotRead,
) )
from api.services.legacy_meter import identity_from_request, record_legacy_usage
from api.services.sbom_nexus import SBOMNexusError, get_json, reads_from_nexus
router = APIRouter(prefix="/sbom", tags=["sbom"]) router = APIRouter(prefix="/sbom", tags=["sbom"])
logger = logging.getLogger(__name__)
_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"} _COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}
@ -54,6 +58,7 @@ def _latest_snapshot_ids_subquery():
@router.post("/ingest/") @router.post("/ingest/")
async def ingest_sbom( async def ingest_sbom(
body: SBOMIngest, body: SBOMIngest,
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> dict: ) -> dict:
"""Create a new SBOM snapshot for a repo. Previous snapshots are retained.""" """Create a new SBOM snapshot for a repo. Previous snapshots are retained."""
@ -90,6 +95,7 @@ async def ingest_sbom(
repo.sbom_source = "manual" repo.sbom_source = "manual"
await session.commit() await session.commit()
await _meter_compat(session, request, "POST", "/sbom/ingest/")
return { return {
"repo_slug": body.repo_slug, "repo_slug": body.repo_slug,
"snapshot_id": str(snap.id), "snapshot_id": str(snap.id),
@ -100,10 +106,19 @@ async def ingest_sbom(
@router.get("/snapshots/", response_model=list[SBOMSnapshotRead]) @router.get("/snapshots/", response_model=list[SBOMSnapshotRead])
async def list_snapshots( async def list_snapshots(
request: Request,
repo_slug: str | None = Query(None), repo_slug: str | None = Query(None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[SBOMSnapshotRead]: ) -> list[SBOMSnapshotRead]:
"""List SBOM snapshots, newest first. Optionally filter by repo.""" """List SBOM snapshots, newest first. Optionally filter by repo."""
await _meter_compat(session, request, "GET", "/sbom/snapshots/")
if reads_from_nexus():
payload = await _nexus_get(
"/sbom/snapshots/",
params={"repo_slug": repo_slug} if repo_slug else None,
)
return await _translate_snapshots(payload, session)
q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc()) q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc())
if repo_slug: if repo_slug:
repo = await _get_repo_by_slug(repo_slug, session) repo = await _get_repo_by_slug(repo_slug, session)
@ -115,9 +130,21 @@ async def list_snapshots(
@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail) @router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail)
async def get_snapshot( async def get_snapshot(
snapshot_id: uuid.UUID, snapshot_id: uuid.UUID,
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SBOMSnapshotDetail: ) -> SBOMSnapshotDetail:
"""Get a snapshot with its full entry list.""" """Get a snapshot with its full entry list."""
await _meter_compat(session, request, "GET", "/sbom/snapshots/{snapshot_id}")
if reads_from_nexus():
payload = await _nexus_get(f"/sbom/snapshots/{snapshot_id}")
repo_ids = await _local_repo_ids([payload], session)
repo_id = repo_ids[payload["repo_slug"]]
translated = _translate_snapshot(payload, repo_id)
translated["entries"] = [
_translate_entry(entry, repo_id) for entry in payload.get("entries", [])
]
return SBOMSnapshotDetail.model_validate(translated)
snap = await session.get(SBOMSnapshot, snapshot_id) snap = await session.get(SBOMSnapshot, snapshot_id)
if snap is None: if snap is None:
raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found") raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found")
@ -140,6 +167,7 @@ async def get_snapshot(
@router.get("/") @router.get("/")
async def list_sbom_entries( async def list_sbom_entries(
request: Request,
repo_slug: str | None = Query(None), repo_slug: str | None = Query(None),
ecosystem: Ecosystem | None = Query(None), ecosystem: Ecosystem | None = Query(None),
license_spdx: str | None = Query(None), license_spdx: str | None = Query(None),
@ -148,6 +176,26 @@ async def list_sbom_entries(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[SBOMEntryRead]: ) -> list[SBOMEntryRead]:
"""Return entries from the latest snapshot per repo (default) or filter by repo.""" """Return entries from the latest snapshot per repo (default) or filter by repo."""
await _meter_compat(session, request, "GET", "/sbom/")
if reads_from_nexus():
params = {
key: value
for key, value in {
"repo_slug": repo_slug,
"ecosystem": ecosystem.value if ecosystem is not None else None,
"license_spdx": license_spdx,
"is_direct": is_direct,
"is_dev": is_dev,
}.items()
if value is not None
}
payload = await _nexus_get("/sbom/", params=params)
repo_ids = await _local_repo_ids(payload, session)
return [
SBOMEntryRead.model_validate(_translate_entry(entry, repo_ids[entry["repo_slug"]]))
for entry in payload
]
if repo_slug: if repo_slug:
repo = await _get_repo_by_slug(repo_slug, session) repo = await _get_repo_by_slug(repo_slug, session)
latest_snap_id_sq = ( latest_snap_id_sq = (
@ -177,9 +225,15 @@ async def list_sbom_entries(
@router.get("/report/licences/", response_model=LicenceReport) @router.get("/report/licences/", response_model=LicenceReport)
async def licence_report( async def licence_report(
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> LicenceReport: ) -> LicenceReport:
"""Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft.""" """Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft."""
await _meter_compat(session, request, "GET", "/sbom/report/licences/")
if reads_from_nexus():
payload = await _nexus_get("/sbom/report/licences/")
return LicenceReport.model_validate(payload)
latest_ids_sq = _latest_snapshot_ids_subquery() latest_ids_sq = _latest_snapshot_ids_subquery()
rows = await session.execute( rows = await session.execute(
select(SBOMEntry, ManagedRepo.slug) select(SBOMEntry, ManagedRepo.slug)
@ -212,10 +266,19 @@ async def licence_report(
@router.get("/{repo_slug}", response_model=SBOMRepoView) @router.get("/{repo_slug}", response_model=SBOMRepoView)
async def get_repo_sbom( async def get_repo_sbom(
repo_slug: str, repo_slug: str,
request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SBOMRepoView: ) -> SBOMRepoView:
"""Return the latest snapshot entries for a specific repo.""" """Return the latest snapshot entries for a specific repo."""
repo = await _get_repo_by_slug(repo_slug, session) repo = await _get_repo_by_slug(repo_slug, session)
await _meter_compat(session, request, "GET", "/sbom/{repo_slug}")
if reads_from_nexus():
payload = await _nexus_get(f"/sbom/{repo_slug}")
payload["entries"] = [
_translate_entry(entry, repo.id) for entry in payload.get("entries", [])
]
return SBOMRepoView.model_validate(payload)
latest_snap_id_sq = ( latest_snap_id_sq = (
select(SBOMSnapshot.id) select(SBOMSnapshot.id)
.where(SBOMSnapshot.repo_id == repo.id) .where(SBOMSnapshot.repo_id == repo.id)
@ -243,3 +306,68 @@ async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
if repo is None: if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found") raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return repo return repo
async def _nexus_get(path: str, *, params: dict | None = None):
try:
return await get_json(path, params=params)
except SBOMNexusError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
async def _local_repo_ids(items: list[dict], session: AsyncSession) -> dict[str, uuid.UUID]:
slugs = {item.get("repo_slug") for item in items}
if None in slugs:
raise HTTPException(status_code=502, detail="SBOM Nexus response omitted repo_slug")
result = await session.execute(
select(ManagedRepo.slug, ManagedRepo.id).where(ManagedRepo.slug.in_(slugs))
)
repo_ids = dict(result.all())
missing = sorted(slugs - repo_ids.keys())
if missing:
raise HTTPException(
status_code=502,
detail=f"SBOM Nexus returned repositories absent from State Hub: {', '.join(missing)}",
)
return repo_ids
async def _translate_snapshots(
items: list[dict], session: AsyncSession
) -> list[SBOMSnapshotRead]:
repo_ids = await _local_repo_ids(items, session)
return [
SBOMSnapshotRead.model_validate(_translate_snapshot(item, repo_ids[item["repo_slug"]]))
for item in items
]
def _translate_snapshot(item: dict, repo_id: uuid.UUID) -> dict:
return {**item, "repo_id": repo_id}
def _translate_entry(item: dict, repo_id: uuid.UUID) -> dict:
return {**item, "repo_id": repo_id}
async def _meter_compat(
session: AsyncSession,
request: Request,
method: str,
route: str,
) -> None:
"""Meter the compatibility surface without making route success depend on it."""
interface_key = f"rest_api:{method} {route}"
try:
await record_legacy_usage(
session,
interface_key=interface_key,
interface_kind="rest_api",
replacement_ref="sbom-nexus:/sbom/",
owner_component="state-hub.sbom-compat",
replacement_verified=reads_from_nexus(),
identity=identity_from_request(request),
)
except Exception:
await session.rollback()
logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)

View file

@ -0,0 +1,56 @@
"""Read-only client for the reversible SBOM Nexus compatibility facade."""
from __future__ import annotations
from typing import Any
import httpx
from api.config import settings
class SBOMNexusError(RuntimeError):
"""An SBOM Nexus request failed without a usable compatibility response."""
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def reads_from_nexus() -> bool:
return settings.sbom_nexus_read_mode == "nexus"
async def get_json(
path: str,
*,
params: dict[str, Any] | None = None,
) -> Any:
"""Fetch one Nexus resource; never fall back silently to legacy storage."""
if not settings.sbom_nexus_url:
raise SBOMNexusError(503, "SBOM Nexus read mode is enabled without SBOM_NEXUS_URL")
try:
async with httpx.AsyncClient(
base_url=settings.sbom_nexus_url.rstrip("/"),
timeout=settings.sbom_nexus_timeout_seconds,
) as client:
response = await client.get(path, params=params)
except httpx.RequestError as exc:
raise SBOMNexusError(502, f"SBOM Nexus is unavailable: {exc.__class__.__name__}") from exc
if response.status_code >= 400:
detail = f"SBOM Nexus returned HTTP {response.status_code}"
try:
payload = response.json()
if isinstance(payload, dict) and isinstance(payload.get("detail"), str):
detail = payload["detail"]
except ValueError:
pass
status_code = 404 if response.status_code == 404 else 502
raise SBOMNexusError(status_code, detail)
try:
return response.json()
except ValueError as exc:
raise SBOMNexusError(502, "SBOM Nexus returned invalid JSON") from exc

View file

@ -6,4 +6,6 @@ metadata:
labels: {{- include "statehub.labels" . | nindent 4 }} labels: {{- include "statehub.labels" . | nindent 4 }}
data: data:
CORS_ORIGINS: {{ .Values.config.corsOrigins | quote }} CORS_ORIGINS: {{ .Values.config.corsOrigins | quote }}
SBOM_NEXUS_URL: {{ .Values.config.sbomNexusUrl | quote }}
SBOM_NEXUS_READ_MODE: {{ .Values.config.sbomNexusReadMode | quote }}
{{- end }} {{- end }}

View file

@ -22,6 +22,8 @@ config:
enabled: true enabled: true
name: state-hub-config name: state-hub-config
corsOrigins: "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001" corsOrigins: "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001"
sbomNexusUrl: ""
sbomNexusReadMode: legacy
secret: secret:
name: state-hub-env name: state-hub-env

View file

@ -11,6 +11,11 @@ image:
ingress: ingress:
enabled: false enabled: false
config:
sbomNexusUrl: "http://sbom-nexus.sbom-nexus.svc.cluster.local:8010"
# T04 starts fail-safe. Change only this value to `nexus` after parity checks.
sbomNexusReadMode: legacy
sweep: sweep:
# RMGR-WP-0005-T11: disabled while railiance01 checkouts still target the # RMGR-WP-0005-T11: disabled while railiance01 checkouts still target the
# stale gitea-remote lineage. Re-enable only after the governed remote # stale gitea-remote lineage. Re-enable only after the governed remote

View file

@ -0,0 +1,167 @@
"""Compatibility coverage for the reversible SBOM Nexus read facade."""
from __future__ import annotations
import uuid
from api.config import settings
from api.services.sbom_nexus import SBOMNexusError
async def _create_repo(client) -> dict:
domain = await client.post(
"/domains/", json={"slug": "sbom-test", "name": "SBOM Test"}
)
assert domain.status_code == 201
repo = await client.post(
"/repos/",
json={
"slug": "testrepo",
"name": "Test Repo",
"domain_slug": "sbom-test",
"local_path": "/tmp/testrepo",
},
)
assert repo.status_code == 201
return repo.json()
def _entry(snapshot_id: str, nexus_repo_id: str) -> dict:
return {
"id": str(uuid.uuid4()),
"repo_id": nexus_repo_id,
"repo_slug": "testrepo",
"snapshot_id": snapshot_id,
"package_name": "fastapi",
"package_version": "0.115.0",
"ecosystem": "python",
"license_spdx": "MIT",
"is_direct": True,
"is_dev": False,
"snapshot_at": "2026-08-22T12:00:00+00:00",
"created_at": "2026-08-22T12:00:01+00:00",
"source_path": "uv.lock",
}
async def test_nexus_read_mode_preserves_state_hub_response_contract(client, monkeypatch):
repo = await _create_repo(client)
snapshot_id = str(uuid.uuid4())
nexus_repo_id = str(uuid.uuid4())
entry = _entry(snapshot_id, nexus_repo_id)
snapshot = {
"id": snapshot_id,
"repo_id": nexus_repo_id,
"repo_slug": "testrepo",
"snapshot_at": "2026-08-22T12:00:00+00:00",
"source": "state-hub-import",
"entry_count": 1,
"created_at": "2026-08-22T12:00:01+00:00",
"status": "imported",
}
async def fake_get(path: str, *, params=None):
if path == "/sbom/snapshots/":
assert params == {"repo_slug": "testrepo"}
return [snapshot]
if path == f"/sbom/snapshots/{snapshot_id}":
return {**snapshot, "entries": [entry]}
if path == "/sbom/":
assert params == {
"repo_slug": "testrepo",
"ecosystem": "python",
"is_direct": True,
}
return [entry]
if path == "/sbom/report/licences/":
return {
"groups": [
{
"license_spdx": "MIT",
"count": 1,
"repos": ["testrepo"],
"is_copyleft": False,
}
],
"copyleft_direct_count": 0,
"signal_qualification": "extra Nexus field",
}
if path == "/sbom/testrepo":
return {
"repo_slug": "testrepo",
"last_sbom_at": "2026-08-22T12:00:00+00:00",
"last_attempt_at": "2026-08-22T12:00:00+00:00",
"entry_count": 1,
"entries": [entry],
}
raise AssertionError(f"unexpected Nexus path {path}")
monkeypatch.setattr(settings, "sbom_nexus_read_mode", "nexus")
monkeypatch.setattr("api.routers.sbom.get_json", fake_get)
snapshots = await client.get("/sbom/snapshots/?repo_slug=testrepo")
detail = await client.get(f"/sbom/snapshots/{snapshot_id}")
entries = await client.get(
"/sbom/?repo_slug=testrepo&ecosystem=python&is_direct=true"
)
licences = await client.get("/sbom/report/licences/")
repo_view = await client.get("/sbom/testrepo")
assert snapshots.status_code == 200
assert detail.status_code == 200
assert entries.status_code == 200
assert licences.status_code == 200
assert repo_view.status_code == 200
assert snapshots.json()[0]["id"] == snapshot_id
assert snapshots.json()[0]["repo_id"] == repo["id"]
assert detail.json()["entries"][0]["repo_id"] == repo["id"]
assert entries.json()[0]["repo_id"] == repo["id"]
assert "source_path" not in entries.json()[0]
assert set(licences.json()) == {"groups", "copyleft_direct_count"}
assert repo_view.json()["entries"][0]["repo_id"] == repo["id"]
assert set(repo_view.json()) == {
"repo_slug",
"last_sbom_at",
"entry_count",
"entries",
}
async def test_nexus_read_mode_does_not_move_ingest_write_authority(client, monkeypatch):
await _create_repo(client)
async def unexpected_get(*args, **kwargs):
raise AssertionError("POST /sbom/ingest/ must not call SBOM Nexus")
monkeypatch.setattr(settings, "sbom_nexus_read_mode", "nexus")
monkeypatch.setattr("api.routers.sbom.get_json", unexpected_get)
response = await client.post(
"/sbom/ingest/",
json={
"repo_slug": "testrepo",
"entries": [
{
"package_name": "fastapi",
"package_version": "0.115.0",
"ecosystem": "python",
"license_spdx": "MIT",
}
],
},
)
assert response.status_code == 200
assert response.json()["ingested"] == 1
async def test_nexus_failure_is_visible_and_does_not_fall_back(client, monkeypatch):
await _create_repo(client)
async def unavailable(*args, **kwargs):
raise SBOMNexusError(502, "SBOM Nexus is unavailable: ConnectError")
monkeypatch.setattr(settings, "sbom_nexus_read_mode", "nexus")
monkeypatch.setattr("api.routers.sbom.get_json", unavailable)
response = await client.get("/sbom/snapshots/")
assert response.status_code == 502
assert response.json()["detail"] == "SBOM Nexus is unavailable: ConnectError"