113 lines
3.6 KiB
Python
113 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate a CycloneDX SBOM from the resolved GovOPlaN release environment."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
|
|
|
|
META_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
|
|
|
from govoplan_release.sbom import ( # noqa: E402
|
|
build_release_sbom,
|
|
parse_sbom_timestamp,
|
|
render_sbom,
|
|
sbom_sha256,
|
|
timestamp_from_source_date_epoch,
|
|
validate_sbom,
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--python", default=sys.executable, help="Python executable for pip inspect.")
|
|
parser.add_argument("--core-root", type=Path, default=META_ROOT.parent / "govoplan-core")
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
default=META_ROOT / "runtime" / "release-artifacts" / "govoplan-sbom.cdx.json",
|
|
)
|
|
parser.add_argument("--pip-inspect", type=Path, help="Use an existing pip inspect report instead of executing pip.")
|
|
parser.add_argument(
|
|
"--timestamp",
|
|
help="ISO-8601 build timestamp. Defaults to SOURCE_DATE_EPOCH, then the current time.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
core_root = args.core_root.expanduser().resolve()
|
|
product_version = _project_version(core_root / "pyproject.toml")
|
|
npm_lock_path = core_root / "webui" / "package-lock.release.json"
|
|
if not npm_lock_path.exists():
|
|
parser.error(f"release npm lockfile does not exist: {npm_lock_path}")
|
|
|
|
pip_inspect = _read_json(args.pip_inspect) if args.pip_inspect else _run_pip_inspect(args.python)
|
|
npm_lock = _read_json(npm_lock_path)
|
|
try:
|
|
timestamp = _resolved_timestamp(args.timestamp)
|
|
payload = build_release_sbom(
|
|
product_version=product_version,
|
|
pip_inspect=pip_inspect,
|
|
npm_lock=npm_lock,
|
|
timestamp=timestamp,
|
|
)
|
|
except ValueError as exc:
|
|
parser.error(str(exc))
|
|
validate_sbom(payload)
|
|
|
|
output = args.output.expanduser().resolve()
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(render_sbom(payload), encoding="utf-8")
|
|
print(f"Wrote {len(payload['components'])} components to {output}")
|
|
print(f"SHA-256: {sbom_sha256(payload)}")
|
|
return 0
|
|
|
|
|
|
def _run_pip_inspect(python: str) -> dict:
|
|
result = subprocess.run(
|
|
[python, "-m", "pip", "inspect", "--local"],
|
|
check=False,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
if result.returncode != 0:
|
|
raise SystemExit(f"pip inspect failed: {result.stderr.strip()}")
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def _read_json(path: Path) -> dict:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(payload, dict):
|
|
raise ValueError(f"expected a JSON object in {path}")
|
|
return payload
|
|
|
|
|
|
def _project_version(path: Path) -> str:
|
|
with path.open("rb") as handle:
|
|
payload = tomllib.load(handle)
|
|
project = payload.get("project")
|
|
if not isinstance(project, dict) or not isinstance(project.get("version"), str):
|
|
raise ValueError(f"project.version is missing from {path}")
|
|
return project["version"]
|
|
|
|
|
|
def _resolved_timestamp(explicit: str | None):
|
|
if explicit is not None:
|
|
return parse_sbom_timestamp(explicit)
|
|
source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
|
|
if source_date_epoch is not None:
|
|
return timestamp_from_source_date_epoch(source_date_epoch)
|
|
return None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|