#!/usr/bin/env python3 """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 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 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) 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 target_snapshots = [] if dry_run else request_json(target_url, "/sbom/snapshots/") return { "ok": results["missing_repo"] == 0, "dry_run": dry_run, "source_snapshot_count": len(snapshots), "source_entry_count": source_entries, "target_snapshot_count": len(target_snapshots) if not dry_run else None, "results": dict(results), "snapshots_by_repo": dict(sorted(by_repo.items())), } 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()