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
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import hashlib
import json
import os
@@ -37,8 +38,10 @@ from .bundle import (
)
from .capabilities import (
CapabilityChangeImpact,
InfrastructureDependencyInventory,
capability_change_impacts,
infrastructure_capability_document,
infrastructure_dependency_inventory_from_mapping,
)
from .distribution import (
MAX_KEYRING_BYTES,
@@ -110,6 +113,9 @@ class DeploymentPlan:
CommandRunner = Callable[[Sequence[str], Path], subprocess.CompletedProcess[str]]
MAX_DEPENDENCY_INVENTORY_BYTES = 2 * 1024 * 1024
DEPENDENCY_INVENTORY_MAX_AGE_SECONDS = 300
DEPENDENCY_INVENTORY_MAX_FUTURE_SECONDS = 60
def build_plan(
@@ -135,9 +141,13 @@ def build_plan(
spec,
read_env(paths.env),
)
dependency_inventory, dependency_inventory_error = (
_read_dependency_inventory(paths.dependency_inventory)
)
capability_impacts = capability_change_impacts(
previous.get("infrastructure_capabilities"),
infrastructure_capabilities,
dependency_inventory=dependency_inventory,
)
actions: list[PlanAction] = []
@@ -215,6 +225,14 @@ def build_plan(
)
for impact in capability_impacts
)
checks.extend(
_dependency_inventory_checks(
spec,
capability_impacts,
dependency_inventory,
dependency_inventory_error,
)
)
if include_host_checks:
checks.extend(host_checks(spec, paths, command_runner=command_runner))
return DeploymentPlan(
@@ -1187,6 +1205,106 @@ def _read_receipt(path: Path) -> Mapping[str, object]:
return value if isinstance(value, dict) else {}
def _read_dependency_inventory(
path: Path,
) -> tuple[InfrastructureDependencyInventory | None, str]:
if not path.exists():
return None, "missing"
try:
value = load_bounded_json(
path,
maximum_bytes=MAX_DEPENDENCY_INVENTORY_BYTES,
)
return infrastructure_dependency_inventory_from_mapping(value), ""
except (DistributionError, ValueError) as exc:
return None, str(exc)
def _dependency_inventory_checks(
spec: InstallationSpec,
impacts: tuple[CapabilityChangeImpact, ...],
inventory: InfrastructureDependencyInventory | None,
inventory_error: str,
) -> tuple[Check, ...]:
if not impacts:
return ()
collect_action = (
"Run govoplan-deploy collect-infrastructure-inventory with an Ops API "
"key, then review the capability impacts before apply."
)
if inventory is None:
if inventory_error == "missing":
message = "Current provider dependency inventory is missing."
check_id = "capability.dependency_inventory.missing"
else:
message = f"Provider dependency inventory is invalid: {inventory_error}"
check_id = "capability.dependency_inventory.invalid"
return (Check(check_id, "error", message, collect_action),)
if inventory.installation_id != spec.installation_id:
return (
Check(
"capability.dependency_inventory.installation",
"error",
"Provider dependency inventory belongs to a different installation.",
collect_action,
),
)
if not inventory.complete:
return (
Check(
"capability.dependency_inventory.incomplete",
"error",
"Provider dependency inventory is incomplete because at least one provider failed.",
"Resolve the provider failure and collect the inventory again.",
),
)
age_seconds = (datetime.now(UTC) - inventory.generated_at).total_seconds()
if age_seconds < -DEPENDENCY_INVENTORY_MAX_FUTURE_SECONDS:
return (
Check(
"capability.dependency_inventory.future",
"error",
"Provider dependency inventory timestamp is in the future.",
"Correct host clock skew and collect the inventory again.",
),
)
if age_seconds > DEPENDENCY_INVENTORY_MAX_AGE_SECONDS:
return (
Check(
"capability.dependency_inventory.stale",
"error",
"Provider dependency inventory is older than five minutes.",
collect_action,
),
)
impacted_ids = {item.capability_id for item in impacts}
missing_ids = sorted(impacted_ids - set(inventory.inspected_capability_ids))
if missing_ids:
return (
Check(
"capability.dependency_inventory.coverage",
"error",
"Provider dependency inventory did not inspect impacted capabilities: "
+ ", ".join(missing_ids)
+ ".",
collect_action,
),
)
matching_dependencies = sum(
len(inventory.dependencies_for(capability_id))
for capability_id in impacted_ids
)
return (
Check(
"capability.dependency_inventory.current",
"ok",
"Current provider inventory inspected every impacted capability and "
f"reported {matching_dependencies} persisted dependency record(s) from "
f"{inventory.provider_count} provider(s).",
),
)
def _memory_bytes() -> int | None:
try:
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():