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
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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 }}
|
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 }}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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