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
|
||||
Loading…
Add table
Add a link
Reference in a new issue