#!/usr/bin/env python3 """Generate a CycloneDX SBOM per owned image using trivy, plus a digest index.""" from __future__ import annotations import argparse import json import subprocess from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] OWNED_SERVICES = ( "control-api", "worker", "dataplane-router", "web", "connector-knowledge", "connector-gitea", "connector-unraid", "connector-mobilityops", "connector-projectbrain", "connector-appops", "connector-stitch", "docker-read-broker", "observability-collector", ) def _image_id(tag: str) -> str: command = ["docker", "inspect", "--format", "{{.Id}}", tag] return subprocess.run( # noqa: S603 command, cwd=ROOT, capture_output=True, text=True, check=True, ).stdout.strip() def generate_for_image(tag: str, service: str, output_dir: Path) -> dict[str, Any]: sbom_path = output_dir / f"{service}.cdx.json" command = ["trivy", "image", "--format", "cyclonedx", "--output", str(sbom_path), tag] subprocess.run( # noqa: S603 command, cwd=ROOT, check=True, ) sbom = json.loads(sbom_path.read_text(encoding="utf-8")) return { "service": service, "tag": tag, "imageId": _image_id(tag), "sbomPath": str(sbom_path.relative_to(ROOT)), "componentCount": len(sbom.get("components", [])), } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--tag-prefix", default="local/itworx-mcp-hub-", help="image tag prefix; service name and ':dev' are appended", ) parser.add_argument("--output-dir", type=Path, required=True) args = parser.parse_args() args.output_dir = args.output_dir.resolve() args.output_dir.mkdir(parents=True, exist_ok=True) index = [] # docs/09-release/PRODUCTION_PROMOTION_RUNBOOK.md is the authoritative # naming source (WP-190 corrected an earlier "control" repository-name # mismatch -- the real pushed/deployed image is "control-api", matching # its SERVICE_NAME build arg, not a shortened alias). Only # dataplane-router and observability-collector use a distinct, # deliberately shorter tag name. tag_service_overrides = { "dataplane-router": "dataplane", "observability-collector": "observability", } for service in OWNED_SERVICES: tag_service = tag_service_overrides.get(service, service) tag = f"{args.tag_prefix}{tag_service}:dev" index.append(generate_for_image(tag, service, args.output_dir)) (args.output_dir / "INDEX.json").write_text( json.dumps({"schemaVersion": "1.0", "images": index}, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) print(f"wrote {len(index)} image SBOMs to {args.output_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())