diff --git a/api/config.py b/api/config.py index a7b13fb..7d3b2e9 100644 --- a/api/config.py +++ b/api/config.py @@ -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() diff --git a/api/routers/sbom.py b/api/routers/sbom.py index ee9bf1e..dc7c3d3 100644 --- a/api/routers/sbom.py +++ b/api/routers/sbom.py @@ -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) diff --git a/api/services/sbom_nexus.py b/api/services/sbom_nexus.py new file mode 100644 index 0000000..7bb2c39 --- /dev/null +++ b/api/services/sbom_nexus.py @@ -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 diff --git a/deploy/railiance/apps/charts/state-hub/templates/configmap.yaml b/deploy/railiance/apps/charts/state-hub/templates/configmap.yaml index 69de021..17f8fcb 100644 --- a/deploy/railiance/apps/charts/state-hub/templates/configmap.yaml +++ b/deploy/railiance/apps/charts/state-hub/templates/configmap.yaml @@ -6,4 +6,6 @@ metadata: labels: {{- include "statehub.labels" . | nindent 4 }} data: CORS_ORIGINS: {{ .Values.config.corsOrigins | quote }} -{{- end }} \ No newline at end of file + SBOM_NEXUS_URL: {{ .Values.config.sbomNexusUrl | quote }} + SBOM_NEXUS_READ_MODE: {{ .Values.config.sbomNexusReadMode | quote }} +{{- end }} diff --git a/deploy/railiance/apps/charts/state-hub/values.yaml b/deploy/railiance/apps/charts/state-hub/values.yaml index 9c2fb59..822d0c9 100644 --- a/deploy/railiance/apps/charts/state-hub/values.yaml +++ b/deploy/railiance/apps/charts/state-hub/values.yaml @@ -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 \ No newline at end of file + sshHostPath: /home/tegwick/.ssh diff --git a/deploy/railiance/apps/helm/state-hub-values.yaml b/deploy/railiance/apps/helm/state-hub-values.yaml index 991619e..f9bed36 100644 --- a/deploy/railiance/apps/helm/state-hub-values.yaml +++ b/deploy/railiance/apps/helm/state-hub-values.yaml @@ -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 diff --git a/tests/test_sbom_nexus_compat.py b/tests/test_sbom_nexus_compat.py new file mode 100644 index 0000000..a78493f --- /dev/null +++ b/tests/test_sbom_nexus_compat.py @@ -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"