feat: prepare postgres sbom cutover
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028f0-a42f-7582-89a8-ebaad7343834
This commit is contained in:
parent
cf7e3acb78
commit
ba535e1f8f
26 changed files with 1573 additions and 411 deletions
|
|
@ -12,7 +12,9 @@ from pydantic import BaseModel, Field
|
|||
from sbom_nexus.scanner import VALID_ECOSYSTEMS, scan_repository
|
||||
from sbom_nexus.storage import Store
|
||||
|
||||
DEFAULT_DATABASE_PATH = os.getenv("SBOM_NEXUS_DATABASE_PATH", "sbom-nexus.db")
|
||||
DEFAULT_DATABASE_TARGET = os.getenv("SBOM_NEXUS_DATABASE_URL") or os.getenv(
|
||||
"SBOM_NEXUS_DATABASE_PATH", "sbom-nexus.db"
|
||||
)
|
||||
DEFAULT_STALE_DAYS = int(os.getenv("SBOM_NEXUS_STALE_DAYS", "30"))
|
||||
|
||||
|
||||
|
|
@ -74,19 +76,28 @@ def _validate_entries(entries: list[EntryCreate]) -> list[dict[str, Any]]:
|
|||
return result
|
||||
|
||||
|
||||
def _auto_create(store: Store) -> bool:
|
||||
configured = os.getenv("SBOM_NEXUS_AUTO_CREATE")
|
||||
if configured is not None:
|
||||
return configured.lower() in {"1", "true", "yes"}
|
||||
return store.dialect == "sqlite"
|
||||
|
||||
|
||||
def create_app(database_path: str | Path | None = None) -> FastAPI:
|
||||
application = FastAPI(
|
||||
title="SBOM Nexus",
|
||||
version="0.1.0",
|
||||
description="SBOM capture, history, evaluation, and bounded catch-up service",
|
||||
)
|
||||
application.state.store = Store(database_path or DEFAULT_DATABASE_PATH)
|
||||
application.state.store.init_schema()
|
||||
application.state.store = Store(database_path or DEFAULT_DATABASE_TARGET)
|
||||
if _auto_create(application.state.store):
|
||||
application.state.store.init_schema()
|
||||
|
||||
@application.get("/state/health")
|
||||
def health(request: Request) -> dict[str, str]:
|
||||
_store(request).list_repositories()
|
||||
return {"status": "ok", "store": "connected"}
|
||||
store = _store(request)
|
||||
store.health()
|
||||
return {"status": "ok", "store": "connected", "dialect": store.dialect}
|
||||
|
||||
@application.put("/repositories/{repo_slug}")
|
||||
def upsert_repository(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ from sbom_nexus.scanner import scan_repository
|
|||
|
||||
|
||||
def _serve(args: argparse.Namespace) -> None:
|
||||
if args.database:
|
||||
if args.database_url:
|
||||
os.environ["SBOM_NEXUS_DATABASE_URL"] = args.database_url
|
||||
elif args.database:
|
||||
os.environ["SBOM_NEXUS_DATABASE_PATH"] = str(Path(args.database).resolve())
|
||||
import uvicorn
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8010)
|
||||
serve.add_argument("--database", help="SQLite database path")
|
||||
serve.add_argument("--database-url", help="SQLAlchemy database URL (PostgreSQL in production)")
|
||||
serve.add_argument("--reload", action="store_true")
|
||||
serve.set_defaults(handler=_serve)
|
||||
|
||||
|
|
|
|||
128
src/sbom_nexus/database.py
Normal file
128
src/sbom_nexus/database.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Portable SQLAlchemy schema shared by the runtime store and Alembic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
MetaData,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
create_engine,
|
||||
event,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "ix_%(table_name)s_%(column_0_name)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
|
||||
repositories = Table(
|
||||
"repositories",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column("slug", String(200), nullable=False, unique=True),
|
||||
Column("checkout_path", Text, nullable=True),
|
||||
Column("active", Boolean, nullable=False),
|
||||
Column("last_attempt_at", DateTime(timezone=True), nullable=True),
|
||||
Column("last_success_at", DateTime(timezone=True), nullable=True),
|
||||
Column("last_status", String(50), nullable=True),
|
||||
Column("last_source", String(200), nullable=True),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
snapshots = Table(
|
||||
"snapshots",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column(
|
||||
"repo_id",
|
||||
String(36),
|
||||
ForeignKey("repositories.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("snapshot_at", DateTime(timezone=True), nullable=False),
|
||||
Column("source", String(200), nullable=False),
|
||||
Column("status", String(50), nullable=False),
|
||||
Column("entry_count", Integer, nullable=False),
|
||||
Column("source_revision", String(200), nullable=True),
|
||||
Column("sources_json", JSON, nullable=False),
|
||||
Column("errors_json", JSON, nullable=False),
|
||||
Column("legacy_id", String(100), nullable=True, unique=True),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
entries = Table(
|
||||
"entries",
|
||||
metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
Column(
|
||||
"repo_id",
|
||||
String(36),
|
||||
ForeignKey("repositories.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
Column(
|
||||
"snapshot_id",
|
||||
String(36),
|
||||
ForeignKey("snapshots.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
Column("package_name", String(300), nullable=False),
|
||||
Column("package_version", String(100), nullable=True),
|
||||
Column("ecosystem", String(50), nullable=False),
|
||||
Column("license_spdx", String(100), nullable=True),
|
||||
Column("is_direct", Boolean, nullable=False),
|
||||
Column("is_dev", Boolean, nullable=False),
|
||||
Column("source_path", Text, nullable=True),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
Index("ix_snapshots_repo_time", snapshots.c.repo_id, snapshots.c.snapshot_at)
|
||||
Index("ix_entries_snapshot", entries.c.snapshot_id)
|
||||
Index("ix_entries_repo", entries.c.repo_id)
|
||||
Index("ix_entries_license", entries.c.license_spdx)
|
||||
|
||||
|
||||
def database_url(value: str | Path) -> str:
|
||||
"""Normalize a filesystem path or supported URL into a SQLAlchemy URL."""
|
||||
text = str(value)
|
||||
if "://" not in text:
|
||||
return f"sqlite:///{Path(text).resolve()}"
|
||||
if text.startswith("postgres://"):
|
||||
return text.replace("postgres://", "postgresql+psycopg://", 1)
|
||||
if text.startswith("postgresql://"):
|
||||
return text.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
return text
|
||||
|
||||
|
||||
def create_database_engine(value: str | Path) -> Engine:
|
||||
url = database_url(value)
|
||||
options: dict[str, object] = {"pool_pre_ping": True}
|
||||
if url.startswith("sqlite:"):
|
||||
options["connect_args"] = {"check_same_thread": False}
|
||||
engine = create_engine(url, **options)
|
||||
if engine.dialect.name == "sqlite":
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys = ON")
|
||||
cursor.close()
|
||||
|
||||
return engine
|
||||
197
src/sbom_nexus/importer.py
Normal file
197
src/sbom_nexus/importer.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Idempotently import historical State Hub SBOM snapshots into SBOM Nexus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
|
||||
def request_json(
|
||||
base_url: str,
|
||||
path: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
payload = json.dumps(body).encode() if body is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}{path}",
|
||||
data=payload,
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
def checkout_path(repo: dict[str, Any]) -> str | None:
|
||||
host_paths = repo.get("host_paths") or {}
|
||||
return host_paths.get(socket.gethostname()) or repo.get("local_path")
|
||||
|
||||
|
||||
def normalise_licence_groups(groups: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Compare the report as a set of groups; API ordering is not contractual."""
|
||||
return {
|
||||
json.dumps(group.get("license_spdx"), sort_keys=True): {
|
||||
"license_spdx": group.get("license_spdx"),
|
||||
"count": group.get("count"),
|
||||
"repos": sorted(group.get("repos") or []),
|
||||
"is_copyleft": bool(group.get("is_copyleft")),
|
||||
}
|
||||
for group in groups
|
||||
}
|
||||
|
||||
|
||||
def import_history(source_url: str, target_url: str, *, dry_run: bool) -> dict[str, Any]:
|
||||
repositories = request_json(source_url, "/repos/")
|
||||
repo_by_id = {str(repo["id"]): repo for repo in repositories}
|
||||
snapshots = request_json(source_url, "/sbom/snapshots/")
|
||||
snapshots.sort(key=lambda item: (item["snapshot_at"], item["id"]))
|
||||
|
||||
results = Counter()
|
||||
by_repo = Counter()
|
||||
source_entries = 0
|
||||
expected: dict[str, dict[str, Any]] = {}
|
||||
for snapshot in snapshots:
|
||||
repo = repo_by_id.get(str(snapshot["repo_id"]))
|
||||
if repo is None:
|
||||
results["missing_repo"] += 1
|
||||
continue
|
||||
repo_slug = repo["slug"]
|
||||
detail = request_json(source_url, f"/sbom/snapshots/{snapshot['id']}")
|
||||
entries = [
|
||||
{
|
||||
"package_name": entry["package_name"],
|
||||
"package_version": entry.get("package_version"),
|
||||
"ecosystem": entry["ecosystem"],
|
||||
"license_spdx": entry.get("license_spdx"),
|
||||
"is_direct": entry.get("is_direct", True),
|
||||
"is_dev": entry.get("is_dev", False),
|
||||
"source_path": entry.get("source_path"),
|
||||
}
|
||||
for entry in detail.get("entries", [])
|
||||
]
|
||||
source_entries += len(entries)
|
||||
expected[str(snapshot["id"])] = {
|
||||
"repo_slug": repo_slug,
|
||||
"snapshot_at": snapshot["snapshot_at"],
|
||||
"entry_count": len(entries),
|
||||
}
|
||||
if dry_run:
|
||||
results["would_import"] += 1
|
||||
by_repo[repo_slug] += 1
|
||||
continue
|
||||
|
||||
request_json(
|
||||
target_url,
|
||||
f"/repositories/{urllib.parse.quote(repo_slug, safe='')}",
|
||||
method="PUT",
|
||||
body={
|
||||
"checkout_path": checkout_path(repo),
|
||||
"active": repo.get("status", "active") == "active",
|
||||
},
|
||||
)
|
||||
imported = request_json(
|
||||
target_url,
|
||||
"/sbom/import/",
|
||||
method="POST",
|
||||
body={
|
||||
"repo_slug": repo_slug,
|
||||
"legacy_id": str(snapshot["id"]),
|
||||
"snapshot_at": snapshot["snapshot_at"],
|
||||
"source": f"state-hub:{snapshot.get('source') or 'manual'}",
|
||||
"entries": entries,
|
||||
},
|
||||
)
|
||||
results["imported" if imported["imported"] else "already_present"] += 1
|
||||
by_repo[repo_slug] += 1
|
||||
|
||||
reconciliation: dict[str, Any] | None = None
|
||||
licence_reconciliation: dict[str, Any] | None = None
|
||||
if not dry_run:
|
||||
target_snapshots = request_json(target_url, "/sbom/snapshots/")
|
||||
imported = {
|
||||
str(snapshot["legacy_id"]): snapshot
|
||||
for snapshot in target_snapshots
|
||||
if snapshot.get("legacy_id")
|
||||
}
|
||||
missing = sorted(set(expected) - set(imported))
|
||||
mismatched = []
|
||||
for legacy_id in sorted(set(expected) & set(imported)):
|
||||
wanted = expected[legacy_id]
|
||||
actual = imported[legacy_id]
|
||||
differences = {
|
||||
field: {"source": wanted[field], "target": actual.get(field)}
|
||||
for field in ("repo_slug", "snapshot_at", "entry_count")
|
||||
if wanted[field] != actual.get(field)
|
||||
}
|
||||
if differences:
|
||||
mismatched.append({"legacy_id": legacy_id, "differences": differences})
|
||||
reconciliation = {
|
||||
"ok": not missing and not mismatched,
|
||||
"expected_snapshot_count": len(expected),
|
||||
"matched_snapshot_count": len(expected) - len(missing) - len(mismatched),
|
||||
"expected_entry_count": source_entries,
|
||||
"target_imported_entry_count": sum(
|
||||
imported[legacy_id]["entry_count"]
|
||||
for legacy_id in expected
|
||||
if legacy_id in imported
|
||||
),
|
||||
"missing_legacy_ids": missing,
|
||||
"mismatched_snapshots": mismatched,
|
||||
}
|
||||
|
||||
source_licences = request_json(source_url, "/sbom/report/licences/")
|
||||
target_licences = request_json(target_url, "/sbom/report/licences/")
|
||||
source_groups = normalise_licence_groups(source_licences.get("groups", []))
|
||||
target_groups = normalise_licence_groups(target_licences.get("groups", []))
|
||||
licence_reconciliation = {
|
||||
"ok": source_groups == target_groups
|
||||
and source_licences.get("copyleft_direct_count")
|
||||
== target_licences.get("copyleft_direct_count"),
|
||||
"groups_match": source_groups == target_groups,
|
||||
"source_copyleft_direct_count": source_licences.get("copyleft_direct_count"),
|
||||
"target_copyleft_direct_count": target_licences.get("copyleft_direct_count"),
|
||||
}
|
||||
|
||||
ok = results["missing_repo"] == 0
|
||||
if reconciliation is not None:
|
||||
ok = ok and reconciliation["ok"]
|
||||
if licence_reconciliation is not None:
|
||||
ok = ok and licence_reconciliation["ok"]
|
||||
return {
|
||||
"ok": ok,
|
||||
"dry_run": dry_run,
|
||||
"source_repo_count": len(by_repo),
|
||||
"source_snapshot_count": len(snapshots),
|
||||
"source_entry_count": source_entries,
|
||||
"results": dict(results),
|
||||
"snapshots_by_repo": dict(sorted(by_repo.items())),
|
||||
"snapshot_reconciliation": reconciliation,
|
||||
"licence_reconciliation": licence_reconciliation,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--target-url", default="http://127.0.0.1:8010")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = import_history(args.source_url, args.target_url, dry_run=args.dry_run)
|
||||
except (urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
raise SystemExit(f"Import failed: {exc}") from exc
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
if not result["ok"]:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,19 +1,23 @@
|
|||
"""SQLite persistence for the extraction slice.
|
||||
|
||||
The store keeps the product behavior independent from FastAPI. PostgreSQL and
|
||||
managed migrations are an explicit production-cutover gate in SBOM-WP-0001-T05.
|
||||
"""
|
||||
"""Transactional persistence for SQLite development and PostgreSQL production."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, insert, select, update
|
||||
from sqlalchemy.engine import RowMapping
|
||||
|
||||
from sbom_nexus.database import (
|
||||
create_database_engine,
|
||||
metadata,
|
||||
repositories,
|
||||
snapshots,
|
||||
)
|
||||
from sbom_nexus.database import entries as entry_table
|
||||
from sbom_nexus.scanner import is_copyleft
|
||||
|
||||
SUCCESS_STATUSES = frozenset({"ingested", "imported"})
|
||||
|
|
@ -23,84 +27,37 @@ def utc_now() -> datetime:
|
|||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def parse_datetime(value: datetime | str) -> datetime:
|
||||
parsed = (
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if isinstance(value, str)
|
||||
else value
|
||||
)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def datetime_text(value: datetime | str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
parsed = parse_datetime(value)
|
||||
else:
|
||||
parsed = value
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_datetime(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return parse_datetime(value).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, database_path: str | Path) -> None:
|
||||
self.database_path = str(database_path)
|
||||
def __init__(self, database_target: str | Path) -> None:
|
||||
self.engine = create_database_engine(database_target)
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.database_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
return connection
|
||||
@property
|
||||
def dialect(self) -> str:
|
||||
return self.engine.dialect.name
|
||||
|
||||
def init_schema(self) -> None:
|
||||
with self.connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS repositories (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
checkout_path TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
last_attempt_at TEXT,
|
||||
last_success_at TEXT,
|
||||
last_status TEXT,
|
||||
last_source TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
metadata.create_all(self.engine)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
repo_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT,
|
||||
snapshot_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
entry_count INTEGER NOT NULL,
|
||||
source_revision TEXT,
|
||||
sources_json TEXT NOT NULL DEFAULT '[]',
|
||||
errors_json TEXT NOT NULL DEFAULT '[]',
|
||||
legacy_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
repo_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT,
|
||||
snapshot_id TEXT NOT NULL REFERENCES snapshots(id) ON DELETE RESTRICT,
|
||||
package_name TEXT NOT NULL,
|
||||
package_version TEXT,
|
||||
ecosystem TEXT NOT NULL,
|
||||
license_spdx TEXT,
|
||||
is_direct INTEGER NOT NULL,
|
||||
is_dev INTEGER NOT NULL,
|
||||
source_path TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_snapshots_repo_time
|
||||
ON snapshots(repo_id, snapshot_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_entries_snapshot ON entries(snapshot_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_entries_repo ON entries(repo_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_entries_license ON entries(license_spdx);
|
||||
"""
|
||||
)
|
||||
def health(self) -> None:
|
||||
with self.engine.connect() as connection:
|
||||
connection.execute(select(func.count()).select_from(repositories))
|
||||
|
||||
def upsert_repository(
|
||||
self,
|
||||
|
|
@ -111,59 +68,57 @@ class Store:
|
|||
last_attempt_at: datetime | str | None = None,
|
||||
last_success_at: datetime | str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
now = datetime_text(utc_now())
|
||||
attempt = datetime_text(last_attempt_at)
|
||||
success = datetime_text(last_success_at)
|
||||
with self.connect() as connection:
|
||||
now = utc_now()
|
||||
attempt = parse_datetime(last_attempt_at) if last_attempt_at else None
|
||||
success = parse_datetime(last_success_at) if last_success_at else None
|
||||
with self.engine.begin() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM repositories WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
select(repositories).where(repositories.c.slug == slug)
|
||||
).mappings().one_or_none()
|
||||
if existing:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE repositories
|
||||
SET checkout_path = ?, active = ?,
|
||||
last_attempt_at = COALESCE(?, last_attempt_at),
|
||||
last_success_at = COALESCE(?, last_success_at),
|
||||
updated_at = ?
|
||||
WHERE slug = ?
|
||||
""",
|
||||
(checkout_path, int(active), attempt, success, now, slug),
|
||||
update(repositories)
|
||||
.where(repositories.c.slug == slug)
|
||||
.values(
|
||||
checkout_path=checkout_path,
|
||||
active=active,
|
||||
last_attempt_at=attempt or existing["last_attempt_at"],
|
||||
last_success_at=success or existing["last_success_at"],
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO repositories (
|
||||
id, slug, checkout_path, active, last_attempt_at,
|
||||
last_success_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
slug,
|
||||
checkout_path,
|
||||
int(active),
|
||||
attempt,
|
||||
success,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
insert(repositories).values(
|
||||
id=str(uuid.uuid4()),
|
||||
slug=slug,
|
||||
checkout_path=checkout_path,
|
||||
active=active,
|
||||
last_attempt_at=attempt,
|
||||
last_success_at=success,
|
||||
last_status=None,
|
||||
last_source=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM repositories WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
select(repositories).where(repositories.c.slug == slug)
|
||||
).mappings().one()
|
||||
return self._repository_dict(row)
|
||||
|
||||
def get_repository(self, slug: str) -> dict[str, Any] | None:
|
||||
with self.connect() as connection:
|
||||
with self.engine.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM repositories WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
select(repositories).where(repositories.c.slug == slug)
|
||||
).mappings().one_or_none()
|
||||
return self._repository_dict(row) if row else None
|
||||
|
||||
def list_repositories(self) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute("SELECT * FROM repositories ORDER BY slug").fetchall()
|
||||
with self.engine.connect() as connection:
|
||||
rows = connection.execute(
|
||||
select(repositories).order_by(repositories.c.slug)
|
||||
).mappings().all()
|
||||
return [self._repository_dict(row) for row in rows]
|
||||
|
||||
def record_snapshot(
|
||||
|
|
@ -180,122 +135,110 @@ class Store:
|
|||
snapshot_id: str | None = None,
|
||||
legacy_id: str | None = None,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
timestamp = datetime_text(snapshot_at or utc_now())
|
||||
created_at = datetime_text(utc_now())
|
||||
with self.connect() as connection:
|
||||
timestamp = parse_datetime(snapshot_at or utc_now())
|
||||
created_at = utc_now()
|
||||
with self.engine.begin() as connection:
|
||||
repo = connection.execute(
|
||||
"SELECT * FROM repositories WHERE slug = ?", (repo_slug,)
|
||||
).fetchone()
|
||||
select(repositories).where(repositories.c.slug == repo_slug)
|
||||
).mappings().one_or_none()
|
||||
if repo is None:
|
||||
raise KeyError(repo_slug)
|
||||
if legacy_id:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM snapshots WHERE legacy_id = ?", (legacy_id,)
|
||||
).fetchone()
|
||||
select(snapshots).where(snapshots.c.legacy_id == legacy_id)
|
||||
).mappings().one_or_none()
|
||||
if existing:
|
||||
return self._snapshot_dict(existing), False
|
||||
|
||||
new_snapshot_id = snapshot_id or str(uuid.uuid4())
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO snapshots (
|
||||
id, repo_id, snapshot_at, source, status, entry_count,
|
||||
source_revision, sources_json, errors_json, legacy_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
new_snapshot_id,
|
||||
repo["id"],
|
||||
timestamp,
|
||||
source,
|
||||
status,
|
||||
len(entries),
|
||||
source_revision,
|
||||
json.dumps(sources or [], sort_keys=True),
|
||||
json.dumps(errors or [], sort_keys=True),
|
||||
legacy_id,
|
||||
created_at,
|
||||
),
|
||||
insert(snapshots).values(
|
||||
id=new_snapshot_id,
|
||||
repo_id=repo["id"],
|
||||
snapshot_at=timestamp,
|
||||
source=source,
|
||||
status=status,
|
||||
entry_count=len(entries),
|
||||
source_revision=source_revision,
|
||||
sources_json=sources or [],
|
||||
errors_json=errors or [],
|
||||
legacy_id=legacy_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
)
|
||||
for entry in entries:
|
||||
if entries:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO entries (
|
||||
id, repo_id, snapshot_id, package_name, package_version,
|
||||
ecosystem, license_spdx, is_direct, is_dev, source_path,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
repo["id"],
|
||||
new_snapshot_id,
|
||||
entry["package_name"],
|
||||
entry.get("package_version"),
|
||||
entry["ecosystem"],
|
||||
entry.get("license_spdx"),
|
||||
int(bool(entry.get("is_direct", True))),
|
||||
int(bool(entry.get("is_dev", False))),
|
||||
entry.get("source_path"),
|
||||
created_at,
|
||||
),
|
||||
insert(entry_table),
|
||||
[
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"repo_id": repo["id"],
|
||||
"snapshot_id": new_snapshot_id,
|
||||
"package_name": entry["package_name"],
|
||||
"package_version": entry.get("package_version"),
|
||||
"ecosystem": entry["ecosystem"],
|
||||
"license_spdx": entry.get("license_spdx"),
|
||||
"is_direct": bool(entry.get("is_direct", True)),
|
||||
"is_dev": bool(entry.get("is_dev", False)),
|
||||
"source_path": entry.get("source_path"),
|
||||
"created_at": created_at,
|
||||
}
|
||||
for entry in entries
|
||||
],
|
||||
)
|
||||
|
||||
previous_attempt = repo["last_attempt_at"]
|
||||
is_latest = previous_attempt is None or parse_datetime(timestamp) >= parse_datetime(
|
||||
previous_attempt
|
||||
)
|
||||
is_latest = previous_attempt is None or timestamp >= parse_datetime(previous_attempt)
|
||||
if is_latest:
|
||||
last_success = repo["last_success_at"]
|
||||
if status in SUCCESS_STATUSES:
|
||||
last_success = timestamp
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE repositories
|
||||
SET last_attempt_at = ?, last_success_at = ?, last_status = ?,
|
||||
last_source = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(timestamp, last_success, status, source, created_at, repo["id"]),
|
||||
update(repositories)
|
||||
.where(repositories.c.id == repo["id"])
|
||||
.values(
|
||||
last_attempt_at=timestamp,
|
||||
last_success_at=last_success,
|
||||
last_status=status,
|
||||
last_source=source,
|
||||
updated_at=created_at,
|
||||
)
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM snapshots WHERE id = ?", (new_snapshot_id,)
|
||||
).fetchone()
|
||||
select(snapshots).where(snapshots.c.id == new_snapshot_id)
|
||||
).mappings().one()
|
||||
return self._snapshot_dict(row), True
|
||||
|
||||
def list_snapshots(self, repo_slug: str | None = None) -> list[dict[str, Any]]:
|
||||
query = """
|
||||
SELECT s.*, r.slug AS repo_slug
|
||||
FROM snapshots s JOIN repositories r ON r.id = s.repo_id
|
||||
"""
|
||||
params: tuple[Any, ...] = ()
|
||||
statement = (
|
||||
select(snapshots, repositories.c.slug.label("repo_slug"))
|
||||
.join(repositories, repositories.c.id == snapshots.c.repo_id)
|
||||
.order_by(snapshots.c.snapshot_at.desc(), snapshots.c.created_at.desc())
|
||||
)
|
||||
if repo_slug:
|
||||
query += " WHERE r.slug = ?"
|
||||
params = (repo_slug,)
|
||||
query += " ORDER BY s.snapshot_at DESC, s.created_at DESC"
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(query, params).fetchall()
|
||||
statement = statement.where(repositories.c.slug == repo_slug)
|
||||
with self.engine.connect() as connection:
|
||||
rows = connection.execute(statement).mappings().all()
|
||||
return [self._snapshot_dict(row) for row in rows]
|
||||
|
||||
def get_snapshot(self, snapshot_id: str) -> dict[str, Any] | None:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT s.*, r.slug AS repo_slug
|
||||
FROM snapshots s JOIN repositories r ON r.id = s.repo_id
|
||||
WHERE s.id = ?
|
||||
""",
|
||||
(snapshot_id,),
|
||||
).fetchone()
|
||||
statement = (
|
||||
select(snapshots, repositories.c.slug.label("repo_slug"))
|
||||
.join(repositories, repositories.c.id == snapshots.c.repo_id)
|
||||
.where(snapshots.c.id == snapshot_id)
|
||||
)
|
||||
with self.engine.connect() as connection:
|
||||
row = connection.execute(statement).mappings().one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
entries = connection.execute(
|
||||
"SELECT * FROM entries WHERE snapshot_id = ? ORDER BY package_name",
|
||||
(snapshot_id,),
|
||||
).fetchall()
|
||||
entry_rows = connection.execute(
|
||||
select(entry_table)
|
||||
.where(entry_table.c.snapshot_id == snapshot_id)
|
||||
.order_by(entry_table.c.package_name)
|
||||
).mappings().all()
|
||||
result = self._snapshot_dict(row)
|
||||
result["entries"] = []
|
||||
for entry in entries:
|
||||
for entry in entry_rows:
|
||||
rendered = self._entry_dict(entry)
|
||||
rendered["snapshot_at"] = result["snapshot_at"]
|
||||
result["entries"].append(rendered)
|
||||
|
|
@ -310,57 +253,57 @@ class Store:
|
|||
is_direct: bool | None = None,
|
||||
is_dev: bool | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
latest = self._latest_snapshot_rows(repo_slug)
|
||||
if not latest:
|
||||
return []
|
||||
snapshot_ids = [row["id"] for row in latest]
|
||||
placeholders = ",".join("?" for _ in snapshot_ids)
|
||||
query = f"""
|
||||
SELECT e.*, r.slug AS repo_slug, s.snapshot_at AS snapshot_at
|
||||
FROM entries e
|
||||
JOIN repositories r ON r.id = e.repo_id
|
||||
JOIN snapshots s ON s.id = e.snapshot_id
|
||||
WHERE e.snapshot_id IN ({placeholders})
|
||||
"""
|
||||
params: list[Any] = list(snapshot_ids)
|
||||
for field, value in (
|
||||
("ecosystem", ecosystem),
|
||||
("license_spdx", license_spdx),
|
||||
("is_direct", None if is_direct is None else int(is_direct)),
|
||||
("is_dev", None if is_dev is None else int(is_dev)),
|
||||
ranked = self._ranked_snapshots()
|
||||
statement = (
|
||||
select(
|
||||
entry_table,
|
||||
repositories.c.slug.label("repo_slug"),
|
||||
snapshots.c.snapshot_at.label("entry_snapshot_at"),
|
||||
)
|
||||
.join(repositories, repositories.c.id == entry_table.c.repo_id)
|
||||
.join(snapshots, snapshots.c.id == entry_table.c.snapshot_id)
|
||||
.join(ranked, ranked.c.id == entry_table.c.snapshot_id)
|
||||
.where(ranked.c.snapshot_rank == 1)
|
||||
)
|
||||
if repo_slug:
|
||||
statement = statement.where(repositories.c.slug == repo_slug)
|
||||
for column, value in (
|
||||
(entry_table.c.ecosystem, ecosystem),
|
||||
(entry_table.c.license_spdx, license_spdx),
|
||||
(entry_table.c.is_direct, is_direct),
|
||||
(entry_table.c.is_dev, is_dev),
|
||||
):
|
||||
if value is not None:
|
||||
query += f" AND e.{field} = ?"
|
||||
params.append(value)
|
||||
query += " ORDER BY e.package_name, r.slug"
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(query, params).fetchall()
|
||||
statement = statement.where(column == value)
|
||||
statement = statement.order_by(entry_table.c.package_name, repositories.c.slug)
|
||||
with self.engine.connect() as connection:
|
||||
rows = connection.execute(statement).mappings().all()
|
||||
return [self._entry_dict(row) for row in rows]
|
||||
|
||||
def repository_view(self, repo_slug: str) -> dict[str, Any] | None:
|
||||
repo = self.get_repository(repo_slug)
|
||||
if repo is None:
|
||||
return None
|
||||
entries = self.latest_entries(repo_slug=repo_slug)
|
||||
snapshots = self.list_snapshots(repo_slug)
|
||||
current_entries = self.latest_entries(repo_slug=repo_slug)
|
||||
history = self.list_snapshots(repo_slug)
|
||||
return {
|
||||
"repo_slug": repo_slug,
|
||||
"last_sbom_at": repo["last_attempt_at"],
|
||||
"last_attempt_at": repo["last_attempt_at"],
|
||||
"last_success_at": repo["last_success_at"],
|
||||
"last_status": repo["last_status"],
|
||||
"entry_count": len(entries),
|
||||
"snapshot_id": snapshots[0]["id"] if snapshots else None,
|
||||
"entries": entries,
|
||||
"entry_count": len(current_entries),
|
||||
"snapshot_id": history[0]["id"] if history else None,
|
||||
"entries": current_entries,
|
||||
}
|
||||
|
||||
def licence_report(self) -> dict[str, Any]:
|
||||
entries = self.latest_entries()
|
||||
current_entries = self.latest_entries()
|
||||
groups: dict[str | None, dict[str, Any]] = defaultdict(
|
||||
lambda: {"count": 0, "repos": set()}
|
||||
)
|
||||
risks: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
for entry in current_entries:
|
||||
license_id = entry.get("license_spdx")
|
||||
groups[license_id]["count"] += 1
|
||||
groups[license_id]["repos"].add(entry["repo_slug"])
|
||||
|
|
@ -374,7 +317,9 @@ class Store:
|
|||
"source_path": entry.get("source_path"),
|
||||
}
|
||||
)
|
||||
sorted_groups = sorted(groups.items(), key=lambda item: (-item[1]["count"], item[0] or ""))
|
||||
sorted_groups = sorted(
|
||||
groups.items(), key=lambda item: (-item[1]["count"], item[0] or "")
|
||||
)
|
||||
return {
|
||||
"groups": [
|
||||
{
|
||||
|
|
@ -398,13 +343,13 @@ class Store:
|
|||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
effective_limit = max(1, min(25, int(limit)))
|
||||
current = now or utc_now()
|
||||
current = parse_datetime(now or utc_now())
|
||||
cutoff = current - timedelta(days=stale_days)
|
||||
repositories = [repo for repo in self.list_repositories() if repo["active"]]
|
||||
never = [repo for repo in repositories if repo["last_attempt_at"] is None]
|
||||
active_repositories = [repo for repo in self.list_repositories() if repo["active"]]
|
||||
never = [repo for repo in active_repositories if repo["last_attempt_at"] is None]
|
||||
stale = [
|
||||
repo
|
||||
for repo in repositories
|
||||
for repo in active_repositories
|
||||
if repo["last_attempt_at"] is None
|
||||
or parse_datetime(repo["last_attempt_at"]) < cutoff
|
||||
]
|
||||
|
|
@ -421,69 +366,60 @@ class Store:
|
|||
"selected_count": len(selected),
|
||||
"stale_count": len(stale),
|
||||
"never_count": len(never),
|
||||
"total_count": len(repositories),
|
||||
"total_count": len(active_repositories),
|
||||
"limit": effective_limit,
|
||||
"stale_after_days": stale_days,
|
||||
"evaluated_at": datetime_text(current),
|
||||
}
|
||||
|
||||
def _latest_snapshot_rows(self, repo_slug: str | None) -> list[sqlite3.Row]:
|
||||
query = """
|
||||
SELECT s.*, r.slug AS repo_slug
|
||||
FROM snapshots s
|
||||
JOIN repositories r ON r.id = s.repo_id
|
||||
WHERE s.id = (
|
||||
SELECT inner_s.id FROM snapshots inner_s
|
||||
WHERE inner_s.repo_id = s.repo_id
|
||||
ORDER BY inner_s.snapshot_at DESC, inner_s.created_at DESC
|
||||
LIMIT 1
|
||||
@staticmethod
|
||||
def _ranked_snapshots():
|
||||
return select(
|
||||
snapshots.c.id,
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=snapshots.c.repo_id,
|
||||
order_by=(snapshots.c.snapshot_at.desc(), snapshots.c.created_at.desc()),
|
||||
)
|
||||
"""
|
||||
params: tuple[Any, ...] = ()
|
||||
if repo_slug:
|
||||
query += " AND r.slug = ?"
|
||||
params = (repo_slug,)
|
||||
with self.connect() as connection:
|
||||
return connection.execute(query, params).fetchall()
|
||||
.label("snapshot_rank"),
|
||||
).subquery("ranked_snapshots")
|
||||
|
||||
@staticmethod
|
||||
def _repository_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||
def _repository_dict(row: RowMapping) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"slug": row["slug"],
|
||||
"checkout_path": row["checkout_path"],
|
||||
"active": bool(row["active"]),
|
||||
"last_attempt_at": row["last_attempt_at"],
|
||||
"last_success_at": row["last_success_at"],
|
||||
"last_attempt_at": datetime_text(row["last_attempt_at"]),
|
||||
"last_success_at": datetime_text(row["last_success_at"]),
|
||||
"last_status": row["last_status"],
|
||||
"last_source": row["last_source"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"created_at": datetime_text(row["created_at"]),
|
||||
"updated_at": datetime_text(row["updated_at"]),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||
def _snapshot_dict(row: RowMapping) -> dict[str, Any]:
|
||||
result = {
|
||||
"id": row["id"],
|
||||
"repo_id": row["repo_id"],
|
||||
"snapshot_at": row["snapshot_at"],
|
||||
"snapshot_at": datetime_text(row["snapshot_at"]),
|
||||
"source": row["source"],
|
||||
"status": row["status"],
|
||||
"entry_count": row["entry_count"],
|
||||
"source_revision": row["source_revision"],
|
||||
"sources": json.loads(row["sources_json"]),
|
||||
"errors": json.loads(row["errors_json"]),
|
||||
"sources": row["sources_json"] or [],
|
||||
"errors": row["errors_json"] or [],
|
||||
"legacy_id": row["legacy_id"],
|
||||
"created_at": row["created_at"],
|
||||
"created_at": datetime_text(row["created_at"]),
|
||||
}
|
||||
if "repo_slug" in row.keys():
|
||||
if "repo_slug" in row:
|
||||
result["repo_slug"] = row["repo_slug"]
|
||||
if "snapshot_at" in row.keys():
|
||||
result["snapshot_at"] = row["snapshot_at"]
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _entry_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||
def _entry_dict(row: RowMapping) -> dict[str, Any]:
|
||||
result = {
|
||||
"id": row["id"],
|
||||
"repo_id": row["repo_id"],
|
||||
|
|
@ -495,10 +431,12 @@ class Store:
|
|||
"is_direct": bool(row["is_direct"]),
|
||||
"is_dev": bool(row["is_dev"]),
|
||||
"source_path": row["source_path"],
|
||||
"created_at": row["created_at"],
|
||||
"created_at": datetime_text(row["created_at"]),
|
||||
}
|
||||
if "repo_slug" in row.keys():
|
||||
if "repo_slug" in row:
|
||||
result["repo_slug"] = row["repo_slug"]
|
||||
if "entry_snapshot_at" in row:
|
||||
result["snapshot_at"] = datetime_text(row["entry_snapshot_at"])
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue