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