Release Core v0.1.35 configuration package safeguards
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import base64
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
@@ -38,6 +40,12 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||
|
||||
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||
ConfigurationRollbackStatus = Literal[
|
||||
"blocked_before_apply",
|
||||
"not_required",
|
||||
"database_restore_required",
|
||||
"partial_apply_requires_recovery",
|
||||
]
|
||||
ConfigurationPackageClass = Literal[
|
||||
"reference",
|
||||
"product",
|
||||
@@ -461,6 +469,21 @@ class ConfigurationApplyResult:
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
created_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
updated_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
rollback: "ConfigurationRollbackState | None" = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationRollbackState:
|
||||
status: ConfigurationRollbackStatus
|
||||
summary: str
|
||||
recovery_action: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"summary": self.summary,
|
||||
"recovery_action": self.recovery_action,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -471,11 +494,40 @@ class ConfigurationExportSelection:
|
||||
object_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportProvenance:
|
||||
exported_at: str
|
||||
source_core_version: str
|
||||
module_versions: Mapping[str, str]
|
||||
tenant_id: str | None
|
||||
exporter_id: str | None
|
||||
scopes: tuple[str, ...] = ()
|
||||
module_ids: tuple[str, ...] = ()
|
||||
object_refs: tuple[str, ...] = ()
|
||||
redacted_secret_keys: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"exported_at": self.exported_at,
|
||||
"source_core_version": self.source_core_version,
|
||||
"module_versions": dict(self.module_versions),
|
||||
"tenant_id": self.tenant_id,
|
||||
"exporter_id": self.exporter_id,
|
||||
"selection": {
|
||||
"scopes": list(self.scopes),
|
||||
"module_ids": list(self.module_ids),
|
||||
"object_refs": list(self.object_refs),
|
||||
},
|
||||
"redacted_secret_keys": list(self.redacted_secret_keys),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportResult:
|
||||
fragments: tuple[ConfigurationPackageFragment, ...] = ()
|
||||
data_requirements: tuple[ConfigurationRequiredData, ...] = ()
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
provenance: ConfigurationExportProvenance | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -508,6 +560,7 @@ def dry_run_configuration_package(
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
required_data: list[ConfigurationRequiredData] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
declared_data: dict[str, ConfigurationRequiredData] = {}
|
||||
|
||||
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
||||
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
||||
@@ -515,6 +568,7 @@ def dry_run_configuration_package(
|
||||
for item in manifest.data_requirements:
|
||||
requirement = ConfigurationRequiredData.from_mapping(item)
|
||||
required_data.append(requirement)
|
||||
declared_data[requirement.key] = requirement
|
||||
if requirement.required and requirement.key not in context.supplied_data:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -525,6 +579,25 @@ def dry_run_configuration_package(
|
||||
))
|
||||
|
||||
for fragment in manifest.fragments:
|
||||
data_ref_diagnostics = _fragment_data_reference_diagnostics(
|
||||
fragment,
|
||||
declared_data=declared_data,
|
||||
supplied_data=context.supplied_data,
|
||||
)
|
||||
if data_ref_diagnostics:
|
||||
diagnostics.extend(data_ref_diagnostics)
|
||||
plan.append(ConfigurationPlanItem(
|
||||
action="blocked",
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
summary="Fragment needs declared deployment data before provider preflight.",
|
||||
))
|
||||
continue
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
context.supplied_data,
|
||||
)
|
||||
provider = provider_map.get(fragment.module_id)
|
||||
if provider is None:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
@@ -550,7 +623,7 @@ def dry_run_configuration_package(
|
||||
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Fragment type is unsupported."))
|
||||
continue
|
||||
try:
|
||||
result = provider.preflight(fragment, context)
|
||||
result = provider.preflight(resolved_fragment, context)
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -605,19 +678,41 @@ def apply_configuration_package(
|
||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||
blockers = [item for item in preflight.diagnostics if item.severity == "blocker"]
|
||||
if blockers:
|
||||
return ConfigurationApplyResult(diagnostics=tuple(blockers))
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(blockers),
|
||||
rollback=ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="No provider changes were attempted because package preflight is blocked.",
|
||||
),
|
||||
)
|
||||
provider_map = _configuration_provider_map(providers)
|
||||
diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics)
|
||||
created_refs: dict[str, str] = {}
|
||||
updated_refs: dict[str, str] = {}
|
||||
stopped_after_blocker = False
|
||||
for fragment in manifest.fragments:
|
||||
provider = provider_map[fragment.module_id]
|
||||
resolved_fragment = _resolve_fragment_data_references(
|
||||
fragment,
|
||||
apply_context.supplied_data,
|
||||
)
|
||||
try:
|
||||
result = provider.apply(fragment, apply_context.supplied_data, apply_context)
|
||||
result = provider.apply(
|
||||
resolved_fragment,
|
||||
apply_context.supplied_data,
|
||||
apply_context,
|
||||
)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
created_refs.update(result.created_refs)
|
||||
updated_refs.update(result.updated_refs)
|
||||
diagnostics.extend(provider.health(result, apply_context))
|
||||
health_diagnostics = provider.health(result, apply_context)
|
||||
diagnostics.extend(health_diagnostics)
|
||||
if any(
|
||||
item.severity == "blocker"
|
||||
for item in (*result.diagnostics, *health_diagnostics)
|
||||
):
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
@@ -627,10 +722,36 @@ def apply_configuration_package(
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
resolution="Stop the import, keep previous configuration, and inspect provider logs.",
|
||||
))
|
||||
stopped_after_blocker = True
|
||||
break
|
||||
changed = bool(created_refs or updated_refs)
|
||||
if stopped_after_blocker and changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="partial_apply_requires_recovery",
|
||||
summary="At least one provider committed changes before a later provider blocked the package.",
|
||||
recovery_action="Restore the reviewed pre-apply database snapshot or use module-owned compensation where explicitly supported.",
|
||||
)
|
||||
elif stopped_after_blocker:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="blocked_before_apply",
|
||||
summary="The first provider blocked before any configuration reference was created or updated.",
|
||||
)
|
||||
elif changed:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="database_restore_required",
|
||||
summary="The package changed provider-owned configuration; generic cross-module compensation is not available.",
|
||||
recovery_action="Retain the pre-apply database snapshot until verification is complete; restore it if the package must be rolled back.",
|
||||
)
|
||||
else:
|
||||
rollback = ConfigurationRollbackState(
|
||||
status="not_required",
|
||||
summary="All package fragments were no-ops, so no rollback action is required.",
|
||||
)
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
created_refs=created_refs,
|
||||
updated_refs=updated_refs,
|
||||
rollback=rollback,
|
||||
)
|
||||
|
||||
|
||||
@@ -669,10 +790,29 @@ def export_configuration_package(
|
||||
fragments.extend(result.fragments)
|
||||
data_requirements.extend(result.data_requirements)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
deduped_required_data = tuple(_dedupe_required_data(data_requirements))
|
||||
provenance = ConfigurationExportProvenance(
|
||||
exported_at=datetime.now(UTC).isoformat(),
|
||||
source_core_version=_installed_core_version(),
|
||||
module_versions={
|
||||
module_id: context.installed_modules[module_id]
|
||||
for module_id in sorted(set(module_ids))
|
||||
if module_id in context.installed_modules
|
||||
},
|
||||
tenant_id=selection.tenant_id,
|
||||
exporter_id=context.operator_user_id,
|
||||
scopes=selection.scopes,
|
||||
module_ids=tuple(module_ids),
|
||||
object_refs=selection.object_refs,
|
||||
redacted_secret_keys=tuple(
|
||||
sorted(item.key for item in deduped_required_data if item.secret)
|
||||
),
|
||||
)
|
||||
return ConfigurationExportResult(
|
||||
fragments=tuple(fragments),
|
||||
data_requirements=tuple(_dedupe_required_data(data_requirements)),
|
||||
data_requirements=deduped_required_data,
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
@@ -1283,6 +1423,103 @@ def _dedupe_required_data(items: Sequence[ConfigurationRequiredData]) -> list[Co
|
||||
return result
|
||||
|
||||
|
||||
def _fragment_data_reference_diagnostics(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
*,
|
||||
declared_data: Mapping[str, ConfigurationRequiredData],
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> list[ConfigurationDiagnostic]:
|
||||
references: set[str] = set()
|
||||
invalid = _collect_fragment_data_references(fragment.payload, references)
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
object_ref = fragment.fragment_id or fragment.fragment_type
|
||||
if invalid:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_invalid",
|
||||
message="Configuration fragment data references must be objects containing only a non-empty $data key.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Replace malformed references with {\"$data\": \"declared_requirement_key\"}.",
|
||||
))
|
||||
for key in sorted(references - set(declared_data)):
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_undeclared",
|
||||
message=f"Configuration fragment references undeclared operator data {key!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Declare the key in package data_requirements before using it in a fragment.",
|
||||
))
|
||||
for key in sorted(references & set(declared_data)):
|
||||
if key in supplied_data:
|
||||
continue
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_data_reference_missing",
|
||||
message=f"Configuration fragment needs operator data {declared_data[key].label!r} before provider preflight.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=key,
|
||||
resolution="Provide the value in the generated configuration package form.",
|
||||
))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _collect_fragment_data_references(value: object, references: set[str]) -> bool:
|
||||
invalid = False
|
||||
if isinstance(value, Mapping):
|
||||
if "$data" in value:
|
||||
key = value.get("$data")
|
||||
if len(value) != 1 or not isinstance(key, str) or not key.strip():
|
||||
return True
|
||||
references.add(key.strip())
|
||||
return False
|
||||
for item in value.values():
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
for item in value:
|
||||
invalid = _collect_fragment_data_references(item, references) or invalid
|
||||
return invalid
|
||||
|
||||
|
||||
def _resolve_fragment_data_references(
|
||||
fragment: ConfigurationPackageFragment,
|
||||
supplied_data: Mapping[str, Any],
|
||||
) -> ConfigurationPackageFragment:
|
||||
payload = _resolve_data_reference_value(fragment.payload, supplied_data)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ValueError("Resolved configuration fragment payload must remain an object.")
|
||||
return ConfigurationPackageFragment(
|
||||
module_id=fragment.module_id,
|
||||
fragment_type=fragment.fragment_type,
|
||||
fragment_id=fragment.fragment_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_data_reference_value(value: object, supplied_data: Mapping[str, Any]) -> object:
|
||||
if isinstance(value, Mapping):
|
||||
if set(value) == {"$data"}:
|
||||
key = value.get("$data")
|
||||
if not isinstance(key, str) or key not in supplied_data:
|
||||
raise ValueError("Configuration fragment contains an unresolved $data reference.")
|
||||
return supplied_data[key]
|
||||
return {
|
||||
str(key): _resolve_data_reference_value(item, supplied_data)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return [_resolve_data_reference_value(item, supplied_data) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _installed_core_version() -> str:
|
||||
try:
|
||||
return package_version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "workspace"
|
||||
|
||||
|
||||
def _catalog_source(path: Path | str | None) -> Path | str | None:
|
||||
if path is not None:
|
||||
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
|
||||
|
||||
Reference in New Issue
Block a user