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
|
||||
from typing import Mapping
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
@@ -18,6 +19,10 @@ CAPABILITY_STATES = frozenset(
|
||||
"unavailable",
|
||||
}
|
||||
)
|
||||
DEPENDENCY_INVENTORY_SCHEMA_VERSION = 1
|
||||
DEPENDENCY_STATES = frozenset(
|
||||
{"active", "inactive", "data_present", "pending_work", "runtime_binding"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -50,13 +55,161 @@ class CapabilityChangeImpact:
|
||||
dependent_modules: tuple[str, ...]
|
||||
detail: str
|
||||
required_action: str
|
||||
actual_dependencies: tuple["CapabilityDependency", ...] = ()
|
||||
inventory_inspected: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
value = asdict(self)
|
||||
value["dependent_modules"] = list(self.dependent_modules)
|
||||
value["actual_dependencies"] = [
|
||||
item.to_dict() for item in self.actual_dependencies
|
||||
]
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CapabilityDependency:
|
||||
capability_id: str
|
||||
module_id: str
|
||||
dependency_type: str
|
||||
dependency_ref: str
|
||||
state: str
|
||||
scope: str
|
||||
summary: str
|
||||
metrics: Mapping[str, int]
|
||||
required_action: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"capability_id": self.capability_id,
|
||||
"module_id": self.module_id,
|
||||
"dependency_type": self.dependency_type,
|
||||
"dependency_ref": self.dependency_ref,
|
||||
"state": self.state,
|
||||
"scope": self.scope,
|
||||
"summary": self.summary,
|
||||
"metrics": dict(sorted(self.metrics.items())),
|
||||
"required_action": self.required_action,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependencyInventory:
|
||||
installation_id: str
|
||||
generated_at: datetime
|
||||
complete: bool
|
||||
inspected_capability_ids: tuple[str, ...]
|
||||
provider_count: int
|
||||
dependencies: tuple[CapabilityDependency, ...]
|
||||
|
||||
def dependencies_for(
|
||||
self,
|
||||
capability_id: str,
|
||||
) -> tuple[CapabilityDependency, ...]:
|
||||
return tuple(
|
||||
item for item in self.dependencies if item.capability_id == capability_id
|
||||
)
|
||||
|
||||
|
||||
def infrastructure_dependency_inventory_from_mapping(
|
||||
value: object,
|
||||
) -> InfrastructureDependencyInventory:
|
||||
if (
|
||||
not isinstance(value, Mapping)
|
||||
or value.get("schema_version") != DEPENDENCY_INVENTORY_SCHEMA_VERSION
|
||||
):
|
||||
raise ValueError("Infrastructure dependency inventory schema is unsupported.")
|
||||
installation_id = _inventory_text(value, "installation_id", maximum=100)
|
||||
generated_at_text = _inventory_text(value, "generated_at", maximum=100)
|
||||
try:
|
||||
generated_at = datetime.fromisoformat(generated_at_text.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"Infrastructure dependency inventory timestamp is invalid."
|
||||
) from exc
|
||||
if generated_at.tzinfo is None:
|
||||
raise ValueError("Infrastructure dependency inventory timestamp needs a timezone.")
|
||||
generated_at = generated_at.astimezone(UTC)
|
||||
complete = value.get("complete")
|
||||
if type(complete) is not bool:
|
||||
raise ValueError("Infrastructure dependency inventory completion state is invalid.")
|
||||
inspected = _inventory_string_list(
|
||||
value.get("inspected_capability_ids"),
|
||||
maximum_items=100,
|
||||
maximum_length=120,
|
||||
)
|
||||
if len(inspected) != len(set(inspected)):
|
||||
raise ValueError("Infrastructure dependency inventory repeats a capability id.")
|
||||
providers = value.get("providers")
|
||||
if not isinstance(providers, list) or len(providers) > 100:
|
||||
raise ValueError("Infrastructure dependency provider reports are invalid.")
|
||||
provider_states: list[str] = []
|
||||
provider_declarations: dict[str, tuple[str, ...]] = {}
|
||||
provider_counts: dict[str, int] = {}
|
||||
for provider in providers:
|
||||
if not isinstance(provider, Mapping):
|
||||
raise ValueError("Infrastructure dependency provider report is invalid.")
|
||||
module_id = _inventory_text(provider, "module_id", maximum=120)
|
||||
if module_id in provider_declarations:
|
||||
raise ValueError("Infrastructure dependency provider is repeated.")
|
||||
state = _inventory_text(provider, "state", maximum=40)
|
||||
if state not in {"complete", "error"}:
|
||||
raise ValueError("Infrastructure dependency provider state is invalid.")
|
||||
provider_states.append(state)
|
||||
count = provider.get("dependency_count")
|
||||
if type(count) is not int or count < 0:
|
||||
raise ValueError("Infrastructure dependency provider count is invalid.")
|
||||
capability_ids = _inventory_string_list(
|
||||
provider.get("capability_ids"),
|
||||
maximum_items=30,
|
||||
maximum_length=120,
|
||||
)
|
||||
if len(capability_ids) != len(set(capability_ids)):
|
||||
raise ValueError("Infrastructure dependency provider capability is repeated.")
|
||||
provider_declarations[module_id] = capability_ids
|
||||
provider_counts[module_id] = count
|
||||
if complete and any(state != "complete" for state in provider_states):
|
||||
raise ValueError("Complete dependency inventory contains a failed provider.")
|
||||
raw_dependencies = value.get("dependencies")
|
||||
if not isinstance(raw_dependencies, list) or len(raw_dependencies) > 10_000:
|
||||
raise ValueError("Infrastructure dependency records are invalid.")
|
||||
dependencies = tuple(_inventory_dependency(item) for item in raw_dependencies)
|
||||
if any(
|
||||
capability_id not in inspected
|
||||
for capability_ids in provider_declarations.values()
|
||||
for capability_id in capability_ids
|
||||
):
|
||||
raise ValueError(
|
||||
"Infrastructure dependency provider was not covered by the inspection."
|
||||
)
|
||||
if any(item.capability_id not in inspected for item in dependencies):
|
||||
raise ValueError("Dependency record was not covered by the inventory inspection.")
|
||||
identities = {
|
||||
(item.capability_id, item.module_id, item.dependency_type, item.dependency_ref)
|
||||
for item in dependencies
|
||||
}
|
||||
if len(identities) != len(dependencies):
|
||||
raise ValueError("Infrastructure dependency inventory repeats a record.")
|
||||
observed_counts = {module_id: 0 for module_id in provider_counts}
|
||||
for dependency in dependencies:
|
||||
declarations = provider_declarations.get(dependency.module_id)
|
||||
if declarations is None or dependency.capability_id not in declarations:
|
||||
raise ValueError(
|
||||
"Infrastructure dependency is outside its provider declaration."
|
||||
)
|
||||
observed_counts[dependency.module_id] += 1
|
||||
if observed_counts != provider_counts:
|
||||
raise ValueError("Infrastructure dependency provider count does not match records.")
|
||||
return InfrastructureDependencyInventory(
|
||||
installation_id=installation_id,
|
||||
generated_at=generated_at,
|
||||
complete=complete,
|
||||
inspected_capability_ids=inspected,
|
||||
provider_count=len(providers),
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
|
||||
def infrastructure_capability_document(
|
||||
spec: InstallationSpec,
|
||||
environment: Mapping[str, str],
|
||||
@@ -89,6 +242,8 @@ def infrastructure_capability_document(
|
||||
def capability_change_impacts(
|
||||
previous_document: object,
|
||||
desired_document: Mapping[str, object],
|
||||
*,
|
||||
dependency_inventory: InfrastructureDependencyInventory | None = None,
|
||||
) -> tuple[CapabilityChangeImpact, ...]:
|
||||
previous = _capability_map(previous_document)
|
||||
desired = _capability_map(desired_document)
|
||||
@@ -141,6 +296,43 @@ def capability_change_impacts(
|
||||
previous_secret_refs,
|
||||
desired_secret_refs,
|
||||
)
|
||||
actual_dependencies = (
|
||||
dependency_inventory.dependencies_for(capability_id)
|
||||
if dependency_inventory is not None
|
||||
else ()
|
||||
)
|
||||
inventory_inspected = bool(
|
||||
dependency_inventory is not None
|
||||
and capability_id in dependency_inventory.inspected_capability_ids
|
||||
)
|
||||
if inventory_inspected and actual_dependencies:
|
||||
references = ", ".join(
|
||||
f"{item.module_id}:{item.dependency_ref}"
|
||||
for item in actual_dependencies
|
||||
)
|
||||
inventory_detail = (
|
||||
f" Provider inventory reports {len(actual_dependencies)} persisted "
|
||||
f"dependency record(s): {references}."
|
||||
)
|
||||
elif inventory_inspected:
|
||||
inventory_detail = (
|
||||
" Provider inventory reports no persisted module-owned dependencies."
|
||||
)
|
||||
else:
|
||||
inventory_detail = " Provider inventory did not inspect this capability."
|
||||
dependency_actions = tuple(
|
||||
dict.fromkeys(
|
||||
item.required_action
|
||||
for item in actual_dependencies
|
||||
if item.required_action.strip()
|
||||
)
|
||||
)
|
||||
required_action = (
|
||||
"Review module-owned configuration and data migration or recovery "
|
||||
"evidence before apply."
|
||||
)
|
||||
if dependency_actions:
|
||||
required_action = f"{required_action} {' '.join(dependency_actions)}"
|
||||
impacts.append(
|
||||
CapabilityChangeImpact(
|
||||
capability_id=capability_id,
|
||||
@@ -153,11 +345,11 @@ def capability_change_impacts(
|
||||
detail=(
|
||||
f"{capability_id} changes from {previous_state}/{previous_source} "
|
||||
f"to {desired_state}/{desired_source}{binding_change}; "
|
||||
f"declared consumers: {dependent_label}."
|
||||
),
|
||||
required_action=(
|
||||
"Review module-owned configuration and data migration or recovery evidence before apply."
|
||||
f"declared consumers: {dependent_label}.{inventory_detail}"
|
||||
),
|
||||
required_action=required_action,
|
||||
actual_dependencies=actual_dependencies,
|
||||
inventory_inspected=inventory_inspected,
|
||||
)
|
||||
)
|
||||
return tuple(impacts)
|
||||
@@ -487,3 +679,73 @@ def _binding_change_label(
|
||||
if previous_secret_refs != desired_secret_refs:
|
||||
changes.append("secret-reference binding")
|
||||
return f" with changed {' and '.join(changes)}" if changes else ""
|
||||
|
||||
|
||||
def _inventory_text(
|
||||
value: Mapping[str, object],
|
||||
key: str,
|
||||
*,
|
||||
maximum: int,
|
||||
) -> str:
|
||||
raw = value.get(key)
|
||||
if not isinstance(raw, str):
|
||||
raise ValueError(f"Infrastructure dependency inventory {key} is invalid.")
|
||||
result = raw.strip()
|
||||
if not result or len(result) > maximum or any(ord(char) < 32 for char in result):
|
||||
raise ValueError(f"Infrastructure dependency inventory {key} is invalid.")
|
||||
return result
|
||||
|
||||
|
||||
def _inventory_string_list(
|
||||
value: object,
|
||||
*,
|
||||
maximum_items: int,
|
||||
maximum_length: int,
|
||||
) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or len(value) > maximum_items:
|
||||
raise ValueError("Infrastructure dependency inventory list is invalid.")
|
||||
items: list[str] = []
|
||||
for raw in value:
|
||||
if not isinstance(raw, str):
|
||||
raise ValueError("Infrastructure dependency inventory list is invalid.")
|
||||
item = raw.strip()
|
||||
if (
|
||||
not item
|
||||
or len(item) > maximum_length
|
||||
or any(ord(char) < 32 for char in item)
|
||||
):
|
||||
raise ValueError("Infrastructure dependency inventory list is invalid.")
|
||||
items.append(item)
|
||||
return tuple(items)
|
||||
|
||||
|
||||
def _inventory_dependency(value: object) -> CapabilityDependency:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("Infrastructure dependency record is invalid.")
|
||||
state = _inventory_text(value, "state", maximum=40)
|
||||
if state not in DEPENDENCY_STATES:
|
||||
raise ValueError("Infrastructure dependency state is invalid.")
|
||||
raw_metrics = value.get("metrics")
|
||||
if not isinstance(raw_metrics, Mapping) or len(raw_metrics) > 20:
|
||||
raise ValueError("Infrastructure dependency metrics are invalid.")
|
||||
metrics: dict[str, int] = {}
|
||||
for raw_key, raw_count in raw_metrics.items():
|
||||
if not isinstance(raw_key, str):
|
||||
raise ValueError("Infrastructure dependency metric name is invalid.")
|
||||
key = raw_key.strip()
|
||||
if not key or len(key) > 80 or any(ord(char) < 32 for char in key):
|
||||
raise ValueError("Infrastructure dependency metric name is invalid.")
|
||||
if type(raw_count) is not int or raw_count < 0:
|
||||
raise ValueError("Infrastructure dependency metric value is invalid.")
|
||||
metrics[key] = raw_count
|
||||
return CapabilityDependency(
|
||||
capability_id=_inventory_text(value, "capability_id", maximum=120),
|
||||
module_id=_inventory_text(value, "module_id", maximum=120),
|
||||
dependency_type=_inventory_text(value, "dependency_type", maximum=120),
|
||||
dependency_ref=_inventory_text(value, "dependency_ref", maximum=240),
|
||||
state=state,
|
||||
scope=_inventory_text(value, "scope", maximum=120),
|
||||
summary=_inventory_text(value, "summary", maximum=1000),
|
||||
metrics=metrics,
|
||||
required_action=_inventory_text(value, "required_action", maximum=1000),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user