feat(sbom): add authoritative Nexus client
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
135b1647d7
commit
b068e9da42
5 changed files with 446 additions and 4 deletions
|
|
@ -6,8 +6,12 @@ import json
|
|||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
SNAPSHOT_SCHEMA = "sbom-nexus.snapshot.v1"
|
||||
SNAPSHOT_REQUIRED_FIELDS = frozenset(
|
||||
|
|
@ -32,12 +36,234 @@ PREVIEW_CONTEXT = {
|
|||
"advances_last_success_at": False,
|
||||
"creates_snapshot_history": False,
|
||||
}
|
||||
SERVICE_CONTEXT = {
|
||||
"mode": "authoritative-service",
|
||||
"authoritative": True,
|
||||
"product_owner": "sbom-nexus",
|
||||
}
|
||||
|
||||
|
||||
class SBOMContractError(ValueError):
|
||||
"""Raised when a Nexus response is outside the pinned consumer contract."""
|
||||
|
||||
|
||||
class SBOMServiceError(RuntimeError):
|
||||
"""A sanitized, deterministic failure from the authoritative service client."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
mutation_may_have_committed: bool = False,
|
||||
) -> None:
|
||||
self.code = code
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.status_code = status_code
|
||||
self.mutation_may_have_committed = mutation_may_have_committed
|
||||
status = f" HTTP {status_code}" if status_code is not None else ""
|
||||
super().__init__(f"SBOM Nexus {method} {path} failed ({code}{status})")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"method": self.method,
|
||||
"path": self.path,
|
||||
"status_code": self.status_code,
|
||||
"mutation_may_have_committed": self.mutation_may_have_committed,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SBOMNexusConfig:
|
||||
"""Explicit service configuration; credentials are deliberately repr-hidden."""
|
||||
|
||||
base_url: str
|
||||
timeout_seconds: float = 30.0
|
||||
bearer_token: str | None = field(default=None, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
url = httpx.URL(self.base_url)
|
||||
if url.scheme not in {"http", "https"} or not url.host:
|
||||
raise ValueError("SBOM Nexus base_url must be an absolute HTTP(S) URL")
|
||||
if url.username or url.password or url.query or url.fragment:
|
||||
raise ValueError("SBOM Nexus base_url must not contain credentials, query, or fragment")
|
||||
if not 0.1 <= self.timeout_seconds <= 300.0:
|
||||
raise ValueError("SBOM Nexus timeout_seconds must be between 0.1 and 300")
|
||||
object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> SBOMNexusConfig:
|
||||
base_url = os.getenv("SBOM_NEXUS_URL")
|
||||
if not base_url:
|
||||
raise ValueError("SBOM_NEXUS_URL is required for authoritative service mode")
|
||||
try:
|
||||
timeout = float(os.getenv("SBOM_NEXUS_TIMEOUT_SECONDS", "30"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("SBOM_NEXUS_TIMEOUT_SECONDS must be numeric") from exc
|
||||
return cls(
|
||||
base_url=base_url,
|
||||
timeout_seconds=timeout,
|
||||
bearer_token=os.getenv("SBOM_NEXUS_TOKEN") or None,
|
||||
)
|
||||
|
||||
|
||||
class SBOMNexusClient:
|
||||
"""Bounded client for Nexus-owned authoritative repository SBOM state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: SBOMNexusConfig,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self._transport = transport
|
||||
|
||||
def upsert_repository(
|
||||
self,
|
||||
repo_slug: str,
|
||||
*,
|
||||
nexus_checkout_path: str | None,
|
||||
active: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Project identity/path; the path must resolve inside Nexus's controlled source plane."""
|
||||
return self._request_object(
|
||||
"PUT",
|
||||
f"/repositories/{quote(repo_slug, safe='')}",
|
||||
body={"checkout_path": nexus_checkout_path, "active": active},
|
||||
operation="repository-projection",
|
||||
writes_state=True,
|
||||
required_fields=frozenset({"slug", "active"}),
|
||||
)
|
||||
|
||||
def ingest_repository(
|
||||
self,
|
||||
repo_slug: str,
|
||||
*,
|
||||
expected_source_revision: str | None = None,
|
||||
operation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist one Nexus scan outcome without automatic mutation retries."""
|
||||
headers = {"Idempotency-Key": operation_id} if operation_id else None
|
||||
result = self._request_object(
|
||||
"POST",
|
||||
f"/sbom/{quote(repo_slug, safe='')}/ingest",
|
||||
headers=headers,
|
||||
operation="ingest",
|
||||
writes_state=True,
|
||||
required_fields=frozenset(
|
||||
{"repo_slug", "snapshot_id", "status", "entry_count", "snapshot_at"}
|
||||
),
|
||||
)
|
||||
if expected_source_revision and result.get("status") == "ingested":
|
||||
actual = result.get("source_revision")
|
||||
if actual != expected_source_revision:
|
||||
raise SBOMServiceError(
|
||||
"source_revision_mismatch",
|
||||
"POST",
|
||||
f"/sbom/{quote(repo_slug, safe='')}/ingest",
|
||||
mutation_may_have_committed=True,
|
||||
)
|
||||
return result
|
||||
|
||||
def latest_snapshot(self, repo_slug: str) -> dict[str, Any]:
|
||||
return self._request_object(
|
||||
"GET",
|
||||
f"/sbom/{quote(repo_slug, safe='')}",
|
||||
operation="latest-snapshot",
|
||||
writes_state=False,
|
||||
required_fields=frozenset(
|
||||
{
|
||||
"repo_slug",
|
||||
"last_attempt_at",
|
||||
"last_success_at",
|
||||
"last_status",
|
||||
"entry_count",
|
||||
"snapshot_id",
|
||||
"entries",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def licence_report(self) -> dict[str, Any]:
|
||||
return self._request_object(
|
||||
"GET",
|
||||
"/sbom/report/licences/",
|
||||
operation="licence-report",
|
||||
writes_state=False,
|
||||
required_fields=frozenset({"groups", "copyleft_direct_prod", "copyleft_direct_count"}),
|
||||
)
|
||||
|
||||
def _request_object(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
operation: str,
|
||||
writes_state: bool,
|
||||
required_fields: frozenset[str],
|
||||
) -> dict[str, Any]:
|
||||
request_headers = dict(headers or {})
|
||||
if self.config.bearer_token:
|
||||
request_headers["Authorization"] = f"Bearer {self.config.bearer_token}"
|
||||
try:
|
||||
with httpx.Client(
|
||||
base_url=self.config.base_url,
|
||||
timeout=self.config.timeout_seconds,
|
||||
headers=request_headers,
|
||||
transport=self._transport,
|
||||
) as client:
|
||||
response = client.request(method, path, json=body)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise SBOMServiceError("timeout", method, path) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise SBOMServiceError("transport_error", method, path) from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise SBOMServiceError(
|
||||
"http_error",
|
||||
method,
|
||||
path,
|
||||
status_code=response.status_code,
|
||||
mutation_may_have_committed=writes_state and response.status_code >= 500,
|
||||
)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise SBOMServiceError(
|
||||
"invalid_json",
|
||||
method,
|
||||
path,
|
||||
mutation_may_have_committed=writes_state,
|
||||
) from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise SBOMServiceError(
|
||||
"invalid_response_type",
|
||||
method,
|
||||
path,
|
||||
mutation_may_have_committed=writes_state,
|
||||
)
|
||||
if required_fields - payload.keys():
|
||||
raise SBOMServiceError(
|
||||
"contract_error",
|
||||
method,
|
||||
path,
|
||||
mutation_may_have_committed=writes_state,
|
||||
)
|
||||
payload["repo_manager_context"] = {
|
||||
**SERVICE_CONTEXT,
|
||||
"operation": operation,
|
||||
"writes_state": writes_state,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def validate_snapshot_contract(payload: dict[str, Any]) -> None:
|
||||
"""Accept additive fields but reject unknown schemas or missing required fields."""
|
||||
if payload.get("schema") != SNAPSHOT_SCHEMA:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue