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
@ -17,6 +19,9 @@ class Settings(BaseSettings):
activity_core_worker_token: str | None = None
ops_run_projection_ttl_seconds: float = 15.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()

View file

@ -1,7 +1,8 @@
import uuid
import logging
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.ext.asyncio import AsyncSession
@ -18,8 +19,11 @@ from api.schemas.sbom import (
SBOMSnapshotDetail,
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"])
logger = logging.getLogger(__name__)
_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}
@ -54,6 +58,7 @@ def _latest_snapshot_ids_subquery():
@router.post("/ingest/")
async def ingest_sbom(
body: SBOMIngest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Create a new SBOM snapshot for a repo. Previous snapshots are retained."""
@ -90,6 +95,7 @@ async def ingest_sbom(
repo.sbom_source = "manual"
await session.commit()
await _meter_compat(session, request, "POST", "/sbom/ingest/")
return {
"repo_slug": body.repo_slug,
"snapshot_id": str(snap.id),
@ -100,10 +106,19 @@ async def ingest_sbom(
@router.get("/snapshots/", response_model=list[SBOMSnapshotRead])
async def list_snapshots(
request: Request,
repo_slug: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[SBOMSnapshotRead]:
"""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())
if repo_slug:
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)
async def get_snapshot(
snapshot_id: uuid.UUID,
request: Request,
session: AsyncSession = Depends(get_session),
) -> SBOMSnapshotDetail:
"""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)
if snap is None:
raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found")
@ -140,6 +167,7 @@ async def get_snapshot(
@router.get("/")
async def list_sbom_entries(
request: Request,
repo_slug: str | None = Query(None),
ecosystem: Ecosystem | None = Query(None),
license_spdx: str | None = Query(None),
@ -148,6 +176,26 @@ async def list_sbom_entries(
session: AsyncSession = Depends(get_session),
) -> list[SBOMEntryRead]:
"""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:
repo = await _get_repo_by_slug(repo_slug, session)
latest_snap_id_sq = (
@ -177,9 +225,15 @@ async def list_sbom_entries(
@router.get("/report/licences/", response_model=LicenceReport)
async def licence_report(
request: Request,
session: AsyncSession = Depends(get_session),
) -> LicenceReport:
"""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()
rows = await session.execute(
select(SBOMEntry, ManagedRepo.slug)
@ -212,10 +266,19 @@ async def licence_report(
@router.get("/{repo_slug}", response_model=SBOMRepoView)
async def get_repo_sbom(
repo_slug: str,
request: Request,
session: AsyncSession = Depends(get_session),
) -> SBOMRepoView:
"""Return the latest snapshot entries for a specific repo."""
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 = (
select(SBOMSnapshot.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:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
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 }}
data:
CORS_ORIGINS: {{ .Values.config.corsOrigins | quote }}
{{- end }}
SBOM_NEXUS_URL: {{ .Values.config.sbomNexusUrl | quote }}
SBOM_NEXUS_READ_MODE: {{ .Values.config.sbomNexusReadMode | quote }}
{{- end }}

View file

@ -22,6 +22,8 @@ config:
enabled: true
name: state-hub-config
corsOrigins: "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001"
sbomNexusUrl: ""
sbomNexusReadMode: legacy
secret:
name: state-hub-env
@ -71,4 +73,4 @@ sweep:
enabled: false
hostname: ""
hostPath: /home/tegwick
sshHostPath: /home/tegwick/.ssh
sshHostPath: /home/tegwick/.ssh

View file

@ -11,6 +11,11 @@ image:
ingress:
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:
# RMGR-WP-0005-T11: disabled while railiance01 checkouts still target the
# 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"