feat: gate infrastructure changes on provider inventory
Dependency Audit / dependency-audit (push) Successful in 1m43s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Failing after 12m3s
Developer Meta-package Release / publish-package (push) Successful in 9s

This commit is contained in:
2026-08-24 15:18:38 +02:00
parent ed6790c057
commit 0b171fbdd4
10 changed files with 743 additions and 18 deletions
+129 -2
View File
@@ -18,7 +18,8 @@ import sys
import time
from typing import Iterator, Mapping, Sequence
from urllib.error import URLError
from urllib.request import urlopen
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
from .backup_evidence import (
DEFAULT_MAX_BACKUP_AGE_SECONDS,
@@ -28,6 +29,7 @@ from .backup_evidence import (
)
from .bundle import (
BACKUP_RUNTIME_ENV_KEYS,
BundlePaths,
atomic_write,
bundle_paths,
canonical_json,
@@ -45,7 +47,11 @@ from .bundle import (
service_names,
write_env,
)
from .capabilities import infrastructure_capability_document
from .capabilities import (
InfrastructureDependencyInventory,
infrastructure_capability_document,
infrastructure_dependency_inventory_from_mapping,
)
from .cluster_evidence import collect_kubernetes_evidence
from .distribution import (
MAX_KEYRING_BYTES,
@@ -81,6 +87,7 @@ from .kubernetes import (
write_secret_creation_hint,
)
from .planning import (
MAX_DEPENDENCY_INVENTORY_BYTES,
DeploymentPlan,
build_plan,
release_change_requires_backup,
@@ -152,6 +159,39 @@ def build_parser() -> argparse.ArgumentParser:
default=120.0,
help="Maximum time to wait for the public health endpoint.",
)
apply_parser.add_argument(
"--ops-url",
help=(
"Dependency inventory URL; defaults to "
"<public-url>/api/v1/ops/infrastructure/dependencies."
),
)
apply_parser.add_argument(
"--api-key-env",
default="GOVOPLAN_OPS_API_KEY",
help=(
"Environment variable containing an API key authorized to read "
"Ops dependency inventory."
),
)
collect_inventory = subparsers.add_parser(
"collect-infrastructure-inventory",
help="Collect current module-owned capability dependencies from Ops.",
)
_directory_argument(collect_inventory)
collect_inventory.add_argument(
"--ops-url",
help=(
"Dependency inventory URL; defaults to "
"<public-url>/api/v1/ops/infrastructure/dependencies."
),
)
collect_inventory.add_argument(
"--api-key-env",
default="GOVOPLAN_OPS_API_KEY",
help="Environment variable containing an authorized Ops API key.",
)
status = subparsers.add_parser(
"status", help="Show desired state and current Compose process state."
@@ -425,6 +465,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return _render_or_doctor(args)
if args.command == "apply":
return _apply(args)
if args.command == "collect-infrastructure-inventory":
return _collect_infrastructure_inventory(args)
if args.command == "status":
return _status(args)
if args.command == "verify-release":
@@ -586,6 +628,25 @@ def _apply(args: argparse.Namespace) -> int:
)
secrets = reconcile_runtime_environment(spec, read_env(paths.env))
secrets = _write_bundle(spec, paths, secrets)
preliminary_plan = build_plan(spec, paths, include_host_checks=False)
api_key_env = str(
getattr(args, "api_key_env", "GOVOPLAN_OPS_API_KEY")
).strip()
api_key = os.environ.get(api_key_env, "").strip()
if preliminary_plan.capability_impacts and api_key:
try:
_collect_dependency_inventory(
spec,
paths,
ops_url=getattr(args, "ops_url", None),
api_key=api_key,
)
print("Refreshed infrastructure dependency inventory from Ops.")
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(
f"warning: could not refresh dependency inventory: {exc}",
file=sys.stderr,
)
plan = build_plan(spec, paths, include_host_checks=True)
_write_plan(paths.plan, plan)
effective_errors = [
@@ -613,6 +674,8 @@ def _apply(args: argparse.Namespace) -> int:
if effective_errors:
_print_plan(plan)
raise ValueError("deployment plan is blocked; resolve doctor errors first")
if plan.capability_impacts:
_print_plan(plan)
docker = shutil.which("docker")
if docker is None:
raise ValueError("Docker CLI is required for apply")
@@ -761,6 +824,70 @@ def _apply(args: argparse.Namespace) -> int:
return 0
def _collect_infrastructure_inventory(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
spec = load_spec(paths.spec)
api_key_env = str(args.api_key_env).strip()
api_key = os.environ.get(api_key_env, "").strip()
if not api_key:
raise ValueError(f"{api_key_env} must contain an authorized Ops API key")
inventory = _collect_dependency_inventory(
spec,
paths,
ops_url=args.ops_url,
api_key=api_key,
)
state = "complete" if inventory.complete else "incomplete"
print(
f"Collected {state} provider dependency inventory with "
f"{len(inventory.dependencies)} record(s) at {paths.dependency_inventory}."
)
return 0 if inventory.complete else 1
def _collect_dependency_inventory(
spec: InstallationSpec,
paths: BundlePaths,
*,
ops_url: str | None,
api_key: str,
) -> InfrastructureDependencyInventory:
url = str(ops_url or "").strip() or (
spec.public_url.rstrip("/")
+ "/api/v1/ops/infrastructure/dependencies"
)
_validate_ops_inventory_url(url)
request = Request(
url,
headers={"Accept": "application/json", "X-API-Key": api_key},
)
with urlopen(request, timeout=15) as response: # noqa: S310
_validate_ops_inventory_url(response.geturl())
encoded = response.read(MAX_DEPENDENCY_INVENTORY_BYTES + 1)
if len(encoded) > MAX_DEPENDENCY_INVENTORY_BYTES:
raise ValueError("Ops dependency inventory exceeds its size limit")
value = json.loads(encoded)
inventory = infrastructure_dependency_inventory_from_mapping(value)
if inventory.installation_id != spec.installation_id:
raise ValueError(
"Ops dependency inventory belongs to a different installation"
)
ensure_private_directory(paths.root)
atomic_write(paths.dependency_inventory, canonical_json(value), mode=0o600)
return inventory
def _validate_ops_inventory_url(url: str) -> None:
parsed = urlsplit(url)
if not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
raise ValueError("Ops dependency inventory URL is invalid")
loopback = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
raise ValueError(
"Ops dependency inventory URL requires HTTPS except on loopback"
)
def _status(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
spec = load_spec(paths.spec)