feat: add reversible SBOM Nexus read facade
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
parent
9e7e91300f
commit
5fc4c56215
7 changed files with 368 additions and 3 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
56
api/services/sbom_nexus.py
Normal file
56
api/services/sbom_nexus.py
Normal 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
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
167
tests/test_sbom_nexus_compat.py
Normal file
167
tests/test_sbom_nexus_compat.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue