| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764 |
- #!/usr/bin/env python3
- """Evidence-first product engineering controls for P2-WP11.
- The commands in this module validate or render evidence. They do not promote
- objects, mutate customer source data, switch traffic, or downgrade databases.
- """
- from __future__ import annotations
- import argparse
- import ast
- import hashlib
- import json
- import re
- import subprocess
- import sys
- from pathlib import Path
- from typing import Any
- READINESS_CATEGORIES = (
- "service",
- "task",
- "database",
- "graph",
- "object_storage",
- "external_dependency",
- )
- PROMOTABLE_KINDS = {"asset", "standard", "ontology", "rule", "workflow"}
- ENVIRONMENT_ORDER = {"development": 10, "test": 20, "preproduction": 30}
- HEALTHY_STATES = {"healthy", "ready", "succeeded", "completed", "available"}
- SECRET_KEY_PATTERN = re.compile(
- r"(?:secret|password|token|credential|authorization|api[_-]?key)", re.I
- )
- SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
- COMPOSE_SERVICE_CATEGORIES = {
- "backend": "service",
- "frontend": "service",
- "runner": "task",
- "n8n": "task",
- "kestra": "task",
- "kestra-db-init": "task",
- "postgres": "database",
- "neo4j": "graph",
- "minio": "object_storage",
- "minio-init": "object_storage",
- "source-postgres": "external_dependency",
- "source-mysql": "external_dependency",
- }
- def _canonical_bytes(value: Any) -> bytes:
- return json.dumps(
- value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
- ).encode("utf-8")
- def _sha256_bytes(value: bytes) -> str:
- return hashlib.sha256(value).hexdigest()
- def _sha256_file(path: Path) -> str:
- return _sha256_bytes(path.read_bytes())
- def _tree_manifest(root: Path) -> dict[str, Any]:
- files: list[dict[str, str]] = []
- if not root.exists():
- return {"sha256": None, "file_count": 0}
- for path in sorted(root.rglob("*")):
- if not path.is_file():
- continue
- relative = path.relative_to(root)
- if "__pycache__" in relative.parts or path.suffix in {".pyc", ".pyo"}:
- continue
- files.append(
- {"path": relative.as_posix(), "sha256": _sha256_file(path)}
- )
- return {
- "sha256": _sha256_bytes(_canonical_bytes(files)),
- "file_count": len(files),
- }
- def _redact(value: Any) -> Any:
- if isinstance(value, dict):
- return {
- str(key): (
- "[REDACTED]"
- if SECRET_KEY_PATTERN.search(str(key))
- else _redact(item)
- )
- for key, item in value.items()
- }
- if isinstance(value, list):
- return [_redact(item) for item in value]
- return value
- def _read_json(path: Path) -> dict[str, Any]:
- value = json.loads(path.read_text(encoding="utf-8"))
- if not isinstance(value, dict):
- raise ValueError(f"JSON object required: {path}")
- return value
- def _write_json(value: Any, output: str | None = None) -> None:
- rendered = json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
- if not output or output == "-":
- sys.stdout.write(rendered)
- return
- path = Path(output)
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(rendered, encoding="utf-8")
- def validate_environment_profiles(profiles_dir: Path) -> dict[str, Any]:
- profiles: list[dict[str, Any]] = []
- seen: dict[tuple[str, str], str] = {}
- collisions: list[dict[str, str]] = []
- expected = set(ENVIRONMENT_ORDER)
- for path in sorted(profiles_dir.glob("*.json")):
- profile = _read_json(path)
- environment = str(profile.get("environment", ""))
- if environment not in ENVIRONMENT_ORDER:
- raise ValueError(f"unknown environment in {path.name}: {environment}")
- if int(profile.get("promotion_order", -1)) != ENVIRONMENT_ORDER[environment]:
- raise ValueError(f"invalid promotion order for {environment}")
- required_text = ("project_name", "network_name", "data_namespace")
- for field in required_text:
- if not str(profile.get(field, "")).strip():
- raise ValueError(f"{field} is required for {environment}")
- key = (field, str(profile[field]))
- if key in seen:
- collisions.append(
- {
- "field": field,
- "value": str(profile[field]),
- "environments": f"{seen[key]},{environment}",
- }
- )
- seen[key] = environment
- ports = profile.get("ports")
- if not isinstance(ports, dict) or not ports:
- raise ValueError(f"ports are required for {environment}")
- for name, raw_port in sorted(ports.items()):
- port = int(raw_port)
- if not 1 <= port <= 65535:
- raise ValueError(f"invalid port {name}={port} for {environment}")
- key = ("published_port", str(port))
- if key in seen:
- collisions.append(
- {
- "field": "published_port",
- "value": str(port),
- "environments": f"{seen[key]},{environment}",
- }
- )
- seen[key] = environment
- controls = profile.get("controls")
- if not isinstance(controls, dict) or "auth_required" not in controls:
- raise ValueError(f"controls.auth_required is required for {environment}")
- profiles.append(
- {
- "environment": environment,
- "promotion_order": ENVIRONMENT_ORDER[environment],
- "project_name": profile["project_name"],
- "network_name": profile["network_name"],
- "data_namespace": profile["data_namespace"],
- "ports": dict(sorted((str(k), int(v)) for k, v in ports.items())),
- "controls": controls,
- "profile_sha256": _sha256_file(path),
- "path": path.name,
- }
- )
- found = {item["environment"] for item in profiles}
- if collisions:
- raise ValueError(f"environment collision detected: {collisions}")
- if found != expected:
- raise ValueError(
- "environment profile set must be development,test,preproduction; "
- f"found={','.join(sorted(found))}"
- )
- profiles.sort(key=lambda item: int(item["promotion_order"]))
- differences: list[dict[str, Any]] = []
- baseline = profiles[0]
- for profile in profiles[1:]:
- changed = [
- field
- for field in ("project_name", "network_name", "data_namespace", "ports", "controls")
- if profile[field] != baseline[field]
- ]
- differences.append(
- {
- "from": baseline["environment"],
- "to": profile["environment"],
- "changed_fields": changed,
- }
- )
- return {
- "schema_version": 1,
- "status": "ready",
- "promotion_path": [item["environment"] for item in profiles],
- "profiles": profiles,
- "collisions": [],
- "differences": differences,
- }
- def build_readiness_report(request: dict[str, Any]) -> dict[str, Any]:
- components = request.get("components")
- if not isinstance(components, list):
- raise ValueError("components must be a list")
- category_reports: dict[str, dict[str, Any]] = {}
- blocking: list[str] = []
- redacted_components: list[dict[str, Any]] = []
- for component in components:
- if not isinstance(component, dict):
- raise ValueError("each component must be an object")
- category = str(component.get("category", ""))
- if category not in READINESS_CATEGORIES:
- raise ValueError(f"unsupported readiness category: {category}")
- name = str(component.get("name", "")).strip()
- if not name:
- raise ValueError("component name is required")
- status = str(component.get("status", "unknown")).lower()
- required = bool(component.get("required", True))
- ready = status in HEALTHY_STATES
- if required and not ready:
- blocking.append(name)
- redacted_components.append(_redact(component))
- for category in READINESS_CATEGORIES:
- selected = [
- item for item in redacted_components if item["category"] == category
- ]
- required = [item for item in selected if item.get("required", True)]
- blocked = [
- item["name"]
- for item in required
- if str(item.get("status", "unknown")).lower() not in HEALTHY_STATES
- ]
- category_reports[category] = {
- "status": "ready" if required and not blocked else (
- "blocked" if blocked else "unconfigured"
- ),
- "component_count": len(selected),
- "required_count": len(required),
- "blocked_components": blocked,
- }
- unconfigured = sorted(
- category
- for category, report in category_reports.items()
- if report["status"] == "unconfigured"
- )
- return {
- "schema_version": 1,
- "environment": str(request.get("environment", "unknown")),
- "status": "blocked" if blocking or unconfigured else "ready",
- "blocking_components": sorted(blocking),
- "unconfigured_categories": unconfigured,
- "categories": category_reports,
- "components": redacted_components,
- }
- def build_compose_readiness_report(
- rows: list[dict[str, Any]], *, environment: str
- ) -> dict[str, Any]:
- components: list[dict[str, Any]] = []
- for row in rows:
- service = str(row.get("Service", ""))
- category = COMPOSE_SERVICE_CATEGORIES.get(service)
- if not category:
- continue
- state = str(row.get("State", "unknown")).lower()
- health = str(row.get("Health", "")).lower()
- exit_code = int(row.get("ExitCode", -1))
- completed = state == "exited" and exit_code == 0 and service.endswith("-init")
- ready = completed or (state == "running" and health in {"", "healthy"})
- components.append(
- {
- "name": service,
- "category": category,
- "status": "completed" if completed else ("healthy" if ready else "unhealthy"),
- "required": True,
- "details": {
- "state": state,
- "health": health or "not_reported",
- "exit_code": exit_code,
- "container_id": str(row.get("ID", ""))[:12],
- },
- }
- )
- report = build_readiness_report(
- {"environment": environment, "components": components}
- )
- report["collector"] = "docker_compose_ps"
- return report
- def collect_compose_readiness(
- compose_file: Path,
- *,
- project_name: str,
- environment: str,
- env_file: Path | None = None,
- ) -> dict[str, Any]:
- command = ["docker", "compose"]
- if env_file:
- command.extend(["--env-file", str(env_file)])
- command.extend(
- ["-p", project_name, "-f", str(compose_file), "ps", "-a", "--format", "json"]
- )
- completed = subprocess.run(command, check=True, capture_output=True, text=True)
- rendered = completed.stdout.strip()
- rows: list[dict[str, Any]] = []
- if rendered:
- try:
- parsed = json.loads(rendered)
- rows = parsed if isinstance(parsed, list) else [parsed]
- except json.JSONDecodeError:
- rows = [json.loads(line) for line in rendered.splitlines() if line.strip()]
- return build_compose_readiness_report(rows, environment=environment)
- def _promotion_digest_payload(bundle: dict[str, Any]) -> dict[str, Any]:
- return {
- "schema_version": bundle["schema_version"],
- "source_environment": bundle["source_environment"],
- "target_environment": bundle["target_environment"],
- "approval_ref": bundle["approval_ref"],
- "objects": bundle["objects"],
- }
- def create_promotion_bundle(request: dict[str, Any]) -> dict[str, Any]:
- source = str(request.get("source_environment", ""))
- target = str(request.get("target_environment", ""))
- if source not in ENVIRONMENT_ORDER or target not in ENVIRONMENT_ORDER:
- raise ValueError("known source and target environments are required")
- if ENVIRONMENT_ORDER[target] - ENVIRONMENT_ORDER[source] != 10:
- raise ValueError("promotion must target the adjacent environment")
- approval_ref = str(request.get("approval_ref", "")).strip()
- if not approval_ref:
- raise ValueError("approval_ref is required")
- objects = request.get("objects")
- if not isinstance(objects, list) or not objects:
- raise ValueError("at least one promotion object is required")
- normalized: list[dict[str, Any]] = []
- identities: set[tuple[str, str]] = set()
- for item in objects:
- if not isinstance(item, dict):
- raise ValueError("promotion object must be an object")
- kind = str(item.get("kind", ""))
- uid = str(item.get("uid", "")).strip()
- version = str(item.get("version", "")).strip()
- digest = str(item.get("payload_sha256", "")).lower()
- rollback_ref = str(item.get("rollback_ref", "")).strip()
- evidence_refs = item.get("evidence_refs")
- if kind not in PROMOTABLE_KINDS:
- raise ValueError(f"unsupported promotion kind: {kind}")
- if not uid or not version or not rollback_ref:
- raise ValueError("uid, version and rollback_ref are required")
- if not SHA256_PATTERN.fullmatch(digest):
- raise ValueError(f"invalid payload sha256 for {kind}:{uid}")
- if not isinstance(evidence_refs, list) or not evidence_refs:
- raise ValueError(f"evidence_refs are required for {kind}:{uid}")
- identity = (kind, uid)
- if identity in identities:
- raise ValueError(f"duplicate promotion object: {kind}:{uid}")
- identities.add(identity)
- normalized.append(
- {
- "kind": kind,
- "uid": uid,
- "version": version,
- "payload_sha256": digest,
- "rollback_ref": rollback_ref,
- "evidence_refs": sorted(str(value) for value in evidence_refs),
- }
- )
- normalized.sort(key=lambda item: (item["kind"], item["uid"]))
- bundle: dict[str, Any] = {
- "schema_version": 1,
- "status": "approved",
- "source_environment": source,
- "target_environment": target,
- "approval_ref": approval_ref,
- "objects": normalized,
- }
- bundle["bundle_sha256"] = _sha256_bytes(
- _canonical_bytes(_promotion_digest_payload(bundle))
- )
- return bundle
- def verify_promotion_bundle(bundle: dict[str, Any]) -> dict[str, Any]:
- expected = _sha256_bytes(_canonical_bytes(_promotion_digest_payload(bundle)))
- actual = str(bundle.get("bundle_sha256", ""))
- if actual != expected:
- raise ValueError(f"promotion bundle digest mismatch: expected={expected}, actual={actual}")
- return {
- "schema_version": 1,
- "status": "verified",
- "bundle_sha256": actual,
- "object_count": len(bundle.get("objects", [])),
- }
- def evaluate_customer_migration(request: dict[str, Any]) -> dict[str, Any]:
- reasons: list[str] = []
- mappings = request.get("mappings")
- if not isinstance(mappings, list):
- raise ValueError("mappings must be a list")
- incomplete = [
- str(item.get("source", "unknown"))
- for item in mappings
- if isinstance(item, dict)
- and item.get("required", True)
- and (not str(item.get("source", "")).strip() or not str(item.get("target", "")).strip())
- ]
- targets = [
- str(item.get("target", ""))
- for item in mappings
- if isinstance(item, dict) and str(item.get("target", "")).strip()
- ]
- duplicates = sorted({item for item in targets if targets.count(item) > 1})
- if incomplete or duplicates:
- reasons.append("mapping_incomplete")
- incremental = request.get("incremental") or {}
- latest = int(incremental.get("latest_sequence", 0))
- caught_up = int(incremental.get("caught_up_sequence", -1))
- lag = max(0, latest - caught_up)
- if lag:
- reasons.append("incremental_not_caught_up")
- reconciliation = request.get("reconciliation") or {}
- source_count = int(reconciliation.get("source_count", -1))
- target_count = int(reconciliation.get("target_count", -2))
- source_digest = str(reconciliation.get("source_digest", ""))
- target_digest = str(reconciliation.get("target_digest", ""))
- reconciled = (
- source_count >= 0
- and source_count == target_count
- and bool(source_digest)
- and source_digest == target_digest
- )
- if not reconciled:
- reasons.append("reconciliation_mismatch")
- rollback = request.get("rollback") or {}
- rollback_ready = bool(rollback.get("backup_ref") and rollback.get("release_ref"))
- if not rollback_ready:
- reasons.append("rollback_not_prepared")
- snapshot = request.get("source_snapshot") or {}
- if not snapshot.get("snapshot_ref"):
- reasons.append("source_snapshot_missing")
- return {
- "schema_version": 1,
- "migration_id": str(request.get("migration_id", "unknown")),
- "mode": "dry_run",
- "source_mutated": False,
- "status": "blocked" if reasons else "ready",
- "blocking_reasons": sorted(set(reasons)),
- "source_snapshot": _redact(snapshot),
- "mapping": {
- "status": "blocked" if incomplete or duplicates else "ready",
- "mapping_count": len(mappings),
- "incomplete_sources": incomplete,
- "duplicate_targets": duplicates,
- },
- "incremental_catchup": {
- "status": "caught_up" if lag == 0 else "lagging",
- "latest_sequence": latest,
- "caught_up_sequence": caught_up,
- "lag": lag,
- },
- "reconciliation": {
- "status": "matched" if reconciled else "mismatch",
- "source_count": source_count,
- "target_count": target_count,
- "source_digest": source_digest,
- "target_digest": target_digest,
- },
- "checkpoints": sorted(str(value) for value in request.get("checkpoints", [])),
- "rollback": {
- "status": "prepared" if rollback_ready else "blocked",
- "backup_ref": rollback.get("backup_ref"),
- "release_ref": rollback.get("release_ref"),
- "strategy": "restore_prior_release_and_pre_migration_backup",
- "database_downgrade": False,
- },
- }
- def _git_value(repo_root: Path, *arguments: str, default: str = "unknown") -> str:
- try:
- completed = subprocess.run(
- ["git", "-C", str(repo_root), *arguments],
- check=True,
- capture_output=True,
- text=True,
- )
- return completed.stdout.strip() or default
- except (OSError, subprocess.CalledProcessError):
- return default
- def _migration_head(repo_root: Path) -> str:
- revisions: dict[str, set[str]] = {}
- referenced: set[str] = set()
- for path in sorted((repo_root / "migrations/versions").glob("*.py")):
- tree = ast.parse(path.read_text(encoding="utf-8"))
- revision = ""
- parents: set[str] = set()
- for node in tree.body:
- if not isinstance(node, (ast.Assign, ast.AnnAssign)):
- continue
- target = node.targets[0] if isinstance(node, ast.Assign) else node.target
- if not isinstance(target, ast.Name):
- continue
- if target.id not in {"revision", "down_revision"}:
- continue
- value_node = node.value
- try:
- value = ast.literal_eval(value_node)
- except (ValueError, TypeError):
- continue
- if target.id == "revision":
- revision = str(value)
- elif value:
- if isinstance(value, (list, tuple)):
- parents.update(str(item) for item in value)
- else:
- parents.add(str(value))
- if revision:
- revisions[revision] = parents
- referenced.update(parents)
- heads = sorted(set(revisions) - referenced)
- if len(heads) != 1:
- raise ValueError(f"exactly one migration head required; found={heads}")
- return heads[0]
- def build_release_manifest(
- repo_root: Path, *, version: str, source_date_epoch: int
- ) -> dict[str, Any]:
- repo_root = repo_root.resolve()
- openapi = repo_root / "docs/architecture/OPENAPI.yaml"
- route_match = re.search(
- r"^x-route-count:\s*(\d+)\s*$",
- openapi.read_text(encoding="utf-8"),
- re.MULTILINE,
- )
- if not route_match:
- raise ValueError("OpenAPI x-route-count is missing")
- source_trees = {
- name: _tree_manifest(repo_root / name)
- for name in ("app", "database", "migrations")
- }
- release_copies: dict[str, dict[str, Any]] = {}
- for name, source in source_trees.items():
- release = _tree_manifest(repo_root / "deployment" / name)
- release_copies[name] = {
- **release,
- "matches_source": release["sha256"] == source["sha256"],
- }
- manifest = {
- "schema_version": 1,
- "version": version,
- "source_date_epoch": int(source_date_epoch),
- "source": {
- "commit": _git_value(repo_root, "rev-parse", "HEAD"),
- "tree": _git_value(repo_root, "rev-parse", "HEAD^{tree}"),
- },
- "source_trees": source_trees,
- "release_copies": release_copies,
- "migration": {
- "head": _migration_head(repo_root),
- "tree_sha256": source_trees["migrations"]["sha256"],
- },
- "openapi": {
- "route_count": int(route_match.group(1)),
- "sha256": _sha256_file(openapi),
- },
- "compose": {
- "sha256": _sha256_file(repo_root / "deploy/docker/docker-compose.yml")
- },
- "environment_profiles": _tree_manifest(repo_root / "deployment/environments"),
- "dependency_inputs": {
- "backend_sha256": _sha256_file(repo_root / "requirements.txt"),
- "frontend_sha256": _sha256_file(repo_root / "frontend/package-lock.json"),
- },
- }
- if not all(item["matches_source"] for item in release_copies.values()):
- raise ValueError("release copies do not match source trees")
- manifest["manifest_sha256"] = _sha256_bytes(_canonical_bytes(manifest))
- return manifest
- def _component(name: str, version: str, ecosystem: str) -> dict[str, str]:
- return {
- "type": "library",
- "name": name,
- "version": version,
- "purl": f"pkg:{ecosystem}/{name}@{version}",
- }
- def _sbom(name: str, components: list[dict[str, str]], input_digest: str) -> dict[str, Any]:
- components.sort(key=lambda item: (item["name"].lower(), item["version"]))
- return {
- "bomFormat": "CycloneDX",
- "specVersion": "1.5",
- "version": 1,
- "metadata": {
- "component": {"type": "application", "name": name},
- "properties": [
- {"name": "dataops:source-input-sha256", "value": input_digest}
- ],
- },
- "components": components,
- }
- def generate_backend_sbom(requirements: Path) -> dict[str, Any]:
- components: list[dict[str, str]] = []
- pattern = re.compile(r"^([A-Za-z0-9_.-]+)\s*(===|==|>=|~=|<=|>|<)\s*([^;\s]+)")
- for raw_line in requirements.read_text(encoding="utf-8").splitlines():
- line = raw_line.strip()
- if not line or line.startswith("#"):
- continue
- match = pattern.match(line)
- if not match:
- raise ValueError(f"unsupported requirement line: {line}")
- name, operator, version = match.groups()
- rendered_version = version if operator in {"==", "==="} else operator + version
- components.append(_component(name, rendered_version, "pypi"))
- return _sbom("dataops-platform-backend", components, _sha256_file(requirements))
- def generate_frontend_sbom(package_lock: Path) -> dict[str, Any]:
- lock = _read_json(package_lock)
- components: list[dict[str, str]] = []
- packages = lock.get("packages") or {}
- if not isinstance(packages, dict):
- raise ValueError("package-lock packages object is required")
- for package_path, value in packages.items():
- if not package_path or not isinstance(value, dict):
- continue
- name = str(value.get("name", ""))
- if not name and "node_modules/" in package_path:
- name = package_path.rsplit("node_modules/", 1)[-1]
- version = str(value.get("version", ""))
- if name and version:
- components.append(_component(name, version, "npm"))
- return _sbom("dataops-platform-frontend", components, _sha256_file(package_lock))
- def generate_sboms(repo_root: Path) -> dict[str, Any]:
- return {
- "backend": generate_backend_sbom(repo_root / "requirements.txt"),
- "frontend": generate_frontend_sbom(repo_root / "frontend/package-lock.json"),
- }
- def _parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description=__doc__)
- subparsers = parser.add_subparsers(dest="command", required=True)
- validate = subparsers.add_parser("validate-environments")
- validate.add_argument("--profiles-dir", type=Path, required=True)
- validate.add_argument("--output")
- readiness = subparsers.add_parser("readiness")
- readiness.add_argument("--input", type=Path, required=True)
- readiness.add_argument("--output")
- compose_readiness = subparsers.add_parser("compose-readiness")
- compose_readiness.add_argument("--compose-file", type=Path, required=True)
- compose_readiness.add_argument("--project-name", required=True)
- compose_readiness.add_argument("--environment", required=True)
- compose_readiness.add_argument("--env-file", type=Path)
- compose_readiness.add_argument("--output")
- promotion = subparsers.add_parser("promotion")
- promotion.add_argument("--input", type=Path, required=True)
- promotion.add_argument("--output")
- promotion.add_argument("--verify", action="store_true")
- migration = subparsers.add_parser("migration-dry-run")
- migration.add_argument("--input", type=Path, required=True)
- migration.add_argument("--output")
- manifest = subparsers.add_parser("manifest")
- manifest.add_argument("--repo-root", type=Path, required=True)
- manifest.add_argument("--version", required=True)
- manifest.add_argument("--source-date-epoch", type=int, required=True)
- manifest.add_argument("--output")
- sbom = subparsers.add_parser("sbom")
- sbom.add_argument("--repo-root", type=Path, required=True)
- sbom.add_argument("--output-dir", required=True)
- return parser
- def main(argv: list[str] | None = None) -> int:
- args = _parser().parse_args(argv)
- if args.command == "validate-environments":
- _write_json(validate_environment_profiles(args.profiles_dir), args.output)
- elif args.command == "readiness":
- _write_json(build_readiness_report(_read_json(args.input)), args.output)
- elif args.command == "compose-readiness":
- _write_json(
- collect_compose_readiness(
- args.compose_file,
- project_name=args.project_name,
- environment=args.environment,
- env_file=args.env_file,
- ),
- args.output,
- )
- elif args.command == "promotion":
- value = _read_json(args.input)
- report = verify_promotion_bundle(value) if args.verify else create_promotion_bundle(value)
- _write_json(report, args.output)
- elif args.command == "migration-dry-run":
- _write_json(evaluate_customer_migration(_read_json(args.input)), args.output)
- elif args.command == "manifest":
- _write_json(
- build_release_manifest(
- args.repo_root,
- version=args.version,
- source_date_epoch=args.source_date_epoch,
- ),
- args.output,
- )
- elif args.command == "sbom":
- sboms = generate_sboms(args.repo_root)
- if args.output_dir == "-":
- _write_json(sboms)
- else:
- output_dir = Path(args.output_dir)
- output_dir.mkdir(parents=True, exist_ok=True)
- _write_json(sboms["backend"], str(output_dir / "backend.cdx.json"))
- _write_json(sboms["frontend"], str(output_dir / "frontend.cdx.json"))
- return 0
- if __name__ == "__main__":
- try:
- raise SystemExit(main())
- except (OSError, ValueError, json.JSONDecodeError) as exc:
- print(f"ERROR: {exc}", file=sys.stderr)
- raise SystemExit(2)
|