feat: gate infrastructure changes on provider inventory
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user