Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e75a-fc5c-7913-9dba-9846210c766d
652 lines
24 KiB
Python
652 lines
24 KiB
Python
"""Guarded construction of small, source-pinned Kubernetes security planes.
|
|
|
|
The executor is deliberately narrower than a generic ``kubectl apply`` wrapper.
|
|
It accepts an allowlisted bundle, refuses unexpected or secret-bearing objects,
|
|
requires an approved construction plan and an exact bundle digest for writes,
|
|
and records metadata-only evidence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import getpass
|
|
import hashlib
|
|
import json
|
|
import subprocess
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, date, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from ops_mason.plan import ConstructionPlan
|
|
from ops_mason.readiness import inspect_readiness
|
|
|
|
|
|
class PlaneError(RuntimeError):
|
|
"""The bundle or live plane is invalid."""
|
|
|
|
|
|
class PlaneRefused(PlaneError):
|
|
"""A safety or approval gate refused a mutating action."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommandResult:
|
|
returncode: int
|
|
stdout: str = ""
|
|
stderr: str = ""
|
|
|
|
|
|
Runner = Callable[[Sequence[str]], CommandResult]
|
|
|
|
|
|
def subprocess_runner(args: Sequence[str]) -> CommandResult:
|
|
proc = subprocess.run(
|
|
list(args), capture_output=True, text=True, timeout=30, check=False
|
|
)
|
|
return CommandResult(proc.returncode, proc.stdout, proc.stderr)
|
|
|
|
|
|
@dataclass(frozen=True, order=True)
|
|
class ObjectRef:
|
|
api_version: str
|
|
kind: str
|
|
name: str
|
|
namespace: str | None
|
|
resource: str
|
|
|
|
@property
|
|
def display(self) -> str:
|
|
scope = f"{self.namespace}/" if self.namespace else ""
|
|
return f"{self.kind}/{scope}{self.name}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ManifestEntry:
|
|
path: Path
|
|
source_path: str
|
|
sha256: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Dependency:
|
|
resource: str
|
|
name: str
|
|
namespace: str | None
|
|
assertions: tuple[tuple[str, Any], ...]
|
|
|
|
|
|
@dataclass
|
|
class PlaneBundle:
|
|
id: str
|
|
path: Path
|
|
repo_root: Path
|
|
plan_path: Path
|
|
expected_context: str
|
|
expected_namespace: str
|
|
source_repo: str
|
|
source_revision: str
|
|
implementation_revision: str
|
|
manifests: tuple[ManifestEntry, ...]
|
|
allowed_objects: tuple[ObjectRef, ...]
|
|
forbidden_kinds: frozenset[str]
|
|
dependencies: tuple[Dependency, ...]
|
|
evidence_path: Path
|
|
documents: tuple[dict[str, Any], ...]
|
|
readiness: dict[str, Any] | None = None
|
|
|
|
@classmethod
|
|
def load(cls, path: str | Path) -> "PlaneBundle":
|
|
bundle_path = Path(path).resolve()
|
|
try:
|
|
raw = yaml.safe_load(bundle_path.read_text()) or {}
|
|
except (OSError, yaml.YAMLError) as exc:
|
|
raise PlaneError(f"cannot load bundle {bundle_path}: {exc}") from exc
|
|
if not isinstance(raw, Mapping):
|
|
raise PlaneError("bundle root must be a mapping")
|
|
|
|
required = {
|
|
"id",
|
|
"plan",
|
|
"expected_context",
|
|
"expected_namespace",
|
|
"source_repo",
|
|
"source_revision",
|
|
"implementation_revision",
|
|
"manifests",
|
|
"allowed_objects",
|
|
"evidence_path",
|
|
}
|
|
missing = required - set(raw)
|
|
if missing:
|
|
raise PlaneError(f"bundle missing field(s): {', '.join(sorted(missing))}")
|
|
|
|
repo_root = bundle_path.parent.parent.resolve()
|
|
|
|
def local_path(value: str) -> Path:
|
|
candidate = (bundle_path.parent / value).resolve()
|
|
if not candidate.is_relative_to(repo_root):
|
|
raise PlaneRefused(f"bundle path escapes repository: {value}")
|
|
return candidate
|
|
|
|
manifests: list[ManifestEntry] = []
|
|
documents: list[dict[str, Any]] = []
|
|
for item in raw["manifests"]:
|
|
if not isinstance(item, Mapping) or not {"path", "source_path", "sha256"} <= set(item):
|
|
raise PlaneError("each manifest needs path, source_path, and sha256")
|
|
manifest_path = local_path(str(item["path"]))
|
|
content = manifest_path.read_bytes()
|
|
actual_hash = hashlib.sha256(content).hexdigest()
|
|
expected_hash = str(item["sha256"])
|
|
if actual_hash != expected_hash:
|
|
raise PlaneRefused(
|
|
f"manifest digest mismatch for {manifest_path}: "
|
|
f"expected {expected_hash}, got {actual_hash}"
|
|
)
|
|
manifests.append(
|
|
ManifestEntry(manifest_path, str(item["source_path"]), expected_hash)
|
|
)
|
|
try:
|
|
loaded = list(yaml.safe_load_all(content.decode()))
|
|
except (UnicodeDecodeError, yaml.YAMLError) as exc:
|
|
raise PlaneError(f"invalid YAML in {manifest_path}: {exc}") from exc
|
|
for doc in loaded:
|
|
if not isinstance(doc, dict):
|
|
raise PlaneError(f"{manifest_path}: every YAML document must be an object")
|
|
documents.append(doc)
|
|
|
|
allowed = tuple(_parse_ref(item) for item in raw["allowed_objects"])
|
|
dependencies = tuple(_parse_dependency(item) for item in raw.get("dependencies", []))
|
|
bundle = cls(
|
|
id=str(raw["id"]),
|
|
path=bundle_path,
|
|
repo_root=repo_root,
|
|
plan_path=local_path(str(raw["plan"])),
|
|
expected_context=str(raw["expected_context"]),
|
|
expected_namespace=str(raw["expected_namespace"]),
|
|
source_repo=str(raw["source_repo"]),
|
|
source_revision=str(raw["source_revision"]),
|
|
implementation_revision=str(raw["implementation_revision"]),
|
|
manifests=tuple(manifests),
|
|
allowed_objects=allowed,
|
|
forbidden_kinds=frozenset(str(x) for x in raw.get("forbidden_kinds", ["Pod", "Secret"])),
|
|
dependencies=dependencies,
|
|
evidence_path=local_path(str(raw["evidence_path"])),
|
|
documents=tuple(documents),
|
|
readiness=raw.get("readiness"),
|
|
)
|
|
bundle.validate()
|
|
return bundle
|
|
|
|
@property
|
|
def digest(self) -> str:
|
|
digest = hashlib.sha256()
|
|
digest.update(self.path.read_bytes())
|
|
for manifest in self.manifests:
|
|
digest.update(manifest.path.read_bytes())
|
|
return digest.hexdigest()
|
|
|
|
def validate(self) -> None:
|
|
if self.readiness is not None and (
|
|
not isinstance(self.readiness, dict)
|
|
or not isinstance(self.readiness.get("target"), dict)
|
|
or not isinstance(self.readiness.get("source"), dict)
|
|
):
|
|
raise PlaneError("readiness needs target and source mappings")
|
|
actual_refs = tuple(_document_ref(doc, self.allowed_objects) for doc in self.documents)
|
|
if len(set(actual_refs)) != len(actual_refs):
|
|
raise PlaneRefused("bundle contains duplicate Kubernetes object identities")
|
|
if set(actual_refs) != set(self.allowed_objects):
|
|
unexpected = sorted(set(actual_refs) - set(self.allowed_objects))
|
|
missing = sorted(set(self.allowed_objects) - set(actual_refs))
|
|
raise PlaneRefused(
|
|
"object allowlist mismatch: "
|
|
f"unexpected={[x.display for x in unexpected]}, "
|
|
f"missing={[x.display for x in missing]}"
|
|
)
|
|
for doc, ref in zip(self.documents, actual_refs, strict=True):
|
|
if ref.kind in self.forbidden_kinds:
|
|
raise PlaneRefused(f"forbidden Kubernetes kind: {ref.kind}")
|
|
forbidden_keys = sorted(_find_keys(doc, {"data", "stringData"}))
|
|
if forbidden_keys:
|
|
raise PlaneRefused(
|
|
f"secret-bearing key(s) forbidden in {ref.display}: {forbidden_keys}"
|
|
)
|
|
if ref.namespace and ref.namespace != self.expected_namespace:
|
|
raise PlaneRefused(
|
|
f"{ref.display} escapes expected namespace {self.expected_namespace}"
|
|
)
|
|
if ref.kind == "ServiceAccount":
|
|
if doc.get("automountServiceAccountToken") is not False:
|
|
raise PlaneRefused(
|
|
f"{ref.display} must set automountServiceAccountToken: false"
|
|
)
|
|
if doc.get("secrets") or doc.get("imagePullSecrets"):
|
|
raise PlaneRefused(f"{ref.display} must not reference credentials")
|
|
if ref.kind == "Namespace" and ref.name == self.expected_namespace:
|
|
labels = doc.get("metadata", {}).get("labels", {})
|
|
if labels.get("pod-security.kubernetes.io/enforce") != "restricted":
|
|
raise PlaneRefused(
|
|
f"Namespace/{ref.name} must enforce restricted Pod Security"
|
|
)
|
|
|
|
def plan(self) -> ConstructionPlan:
|
|
return ConstructionPlan.load(self.plan_path)
|
|
|
|
def render(self) -> dict[str, Any]:
|
|
return {
|
|
"bundle_id": self.id,
|
|
"bundle_digest": self.digest,
|
|
"source_repo": self.source_repo,
|
|
"source_revision": self.source_revision,
|
|
"implementation_revision": self.implementation_revision,
|
|
"expected_context": self.expected_context,
|
|
"readiness": self.readiness,
|
|
"objects": [asdict(ref) | {"display": ref.display} for ref in self.allowed_objects],
|
|
"forbidden_kinds": sorted(self.forbidden_kinds),
|
|
"plan_id": self.plan().id,
|
|
"plan_approved": self.plan().is_approved(),
|
|
}
|
|
|
|
|
|
def _parse_ref(item: Mapping[str, Any]) -> ObjectRef:
|
|
required = {"api_version", "kind", "name", "resource"}
|
|
if not isinstance(item, Mapping) or not required <= set(item):
|
|
raise PlaneError("allowed object needs api_version, kind, name, and resource")
|
|
return ObjectRef(
|
|
str(item["api_version"]),
|
|
str(item["kind"]),
|
|
str(item["name"]),
|
|
str(item["namespace"]) if item.get("namespace") else None,
|
|
str(item["resource"]),
|
|
)
|
|
|
|
|
|
def _parse_dependency(item: Mapping[str, Any]) -> Dependency:
|
|
if not isinstance(item, Mapping) or not {"resource", "name"} <= set(item):
|
|
raise PlaneError("dependency needs resource and name")
|
|
assertions: list[tuple[str, Any]] = []
|
|
for assertion in item.get("assertions", []):
|
|
if not isinstance(assertion, Mapping) or not {"path", "equals"} <= set(assertion):
|
|
raise PlaneError("dependency assertion needs path and equals")
|
|
assertions.append((str(assertion["path"]), assertion["equals"]))
|
|
return Dependency(
|
|
resource=str(item["resource"]),
|
|
name=str(item["name"]),
|
|
namespace=str(item["namespace"]) if item.get("namespace") else None,
|
|
assertions=tuple(assertions),
|
|
)
|
|
|
|
|
|
def _document_ref(doc: Mapping[str, Any], allowed: Sequence[ObjectRef]) -> ObjectRef:
|
|
metadata = doc.get("metadata")
|
|
if not isinstance(metadata, Mapping) or not metadata.get("name"):
|
|
raise PlaneError("Kubernetes object missing metadata.name")
|
|
identity = (
|
|
str(doc.get("apiVersion", "")),
|
|
str(doc.get("kind", "")),
|
|
str(metadata["name"]),
|
|
str(metadata["namespace"]) if metadata.get("namespace") else None,
|
|
)
|
|
matches = [ref for ref in allowed if identity == (ref.api_version, ref.kind, ref.name, ref.namespace)]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
resource = f"unmapped:{identity[1].lower()}"
|
|
return ObjectRef(*identity, resource)
|
|
|
|
|
|
def _find_keys(value: Any, forbidden: set[str], prefix: str = "") -> list[str]:
|
|
found: list[str] = []
|
|
if isinstance(value, Mapping):
|
|
for key, child in value.items():
|
|
path = f"{prefix}.{key}" if prefix else str(key)
|
|
if key in forbidden:
|
|
found.append(path)
|
|
found.extend(_find_keys(child, forbidden, path))
|
|
elif isinstance(value, list):
|
|
for index, child in enumerate(value):
|
|
found.extend(_find_keys(child, forbidden, f"{prefix}.{index}"))
|
|
return found
|
|
|
|
|
|
def _run(runner: Runner, args: Sequence[str], *, allow_not_found: bool = False) -> CommandResult:
|
|
result = runner(args)
|
|
if result.returncode == 0:
|
|
return result
|
|
if allow_not_found and "notfound" in result.stderr.lower().replace(" ", ""):
|
|
return result
|
|
command = " ".join(args)
|
|
raise PlaneError(f"`{command}` failed: {result.stderr.strip()[:400]}")
|
|
|
|
|
|
def _kubectl_get_args(resource: str, name: str, namespace: str | None) -> list[str]:
|
|
args = ["kubectl"]
|
|
if namespace:
|
|
args += ["-n", namespace]
|
|
return args + ["get", resource, name, "-o", "json"]
|
|
|
|
|
|
def _json_path(value: Any, path: str) -> Any:
|
|
current = value
|
|
if path.startswith("/"):
|
|
tokens = [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")]
|
|
else:
|
|
tokens = path.split(".")
|
|
for token in tokens:
|
|
if isinstance(current, list):
|
|
try:
|
|
current = current[int(token)]
|
|
except (ValueError, IndexError) as exc:
|
|
raise PlaneError(f"invalid dependency assertion path: {path}") from exc
|
|
elif isinstance(current, Mapping) and token in current:
|
|
current = current[token]
|
|
else:
|
|
raise PlaneError(f"dependency assertion path not found: {path}")
|
|
return current
|
|
|
|
|
|
def _is_subset(desired: Any, live: Any) -> bool:
|
|
if isinstance(desired, Mapping):
|
|
return isinstance(live, Mapping) and all(
|
|
key in live and _is_subset(value, live[key]) for key, value in desired.items()
|
|
)
|
|
if isinstance(desired, list):
|
|
return isinstance(live, list) and len(desired) == len(live) and all(
|
|
_is_subset(left, right) for left, right in zip(desired, live, strict=True)
|
|
)
|
|
return desired == live
|
|
|
|
|
|
def _desired_by_ref(bundle: PlaneBundle) -> dict[ObjectRef, dict[str, Any]]:
|
|
return {
|
|
_document_ref(doc, bundle.allowed_objects): doc for doc in bundle.documents
|
|
}
|
|
|
|
|
|
def _check_inputs_clean(bundle: PlaneBundle, runner: Runner) -> None:
|
|
result = _run(
|
|
runner,
|
|
["git", "-C", str(bundle.repo_root), "status", "--porcelain"],
|
|
)
|
|
if result.stdout.strip():
|
|
raise PlaneRefused("repository must be committed and clean before Kubernetes mutation")
|
|
|
|
|
|
def preflight(
|
|
bundle: PlaneBundle, runner: Runner = subprocess_runner, *,
|
|
readiness_repo: Path | None = None, activation: str = "APPROVED", today: date | None = None,
|
|
) -> dict[str, Any]:
|
|
context = _run(runner, ["kubectl", "config", "current-context"]).stdout.strip()
|
|
if context != bundle.expected_context:
|
|
raise PlaneRefused(
|
|
f"Kubernetes context mismatch: expected {bundle.expected_context!r}, got {context!r}"
|
|
)
|
|
|
|
for ref in bundle.allowed_objects:
|
|
args = ["kubectl", "auth", "can-i", "create", ref.resource]
|
|
if ref.namespace:
|
|
args += ["-n", ref.namespace]
|
|
allowed = _run(runner, args).stdout.strip().lower()
|
|
if allowed != "yes":
|
|
raise PlaneRefused(f"create permission denied for {ref.display}")
|
|
|
|
for manifest in bundle.manifests:
|
|
_run(runner, ["kubectl", "apply", "--dry-run=client", "-f", str(manifest.path)])
|
|
|
|
desired = _desired_by_ref(bundle)
|
|
live_state: list[dict[str, str]] = []
|
|
namespace_exists = False
|
|
for ref in bundle.allowed_objects:
|
|
result = _run(
|
|
runner,
|
|
_kubectl_get_args(ref.resource, ref.name, ref.namespace),
|
|
allow_not_found=True,
|
|
)
|
|
if result.returncode != 0:
|
|
state = "absent"
|
|
else:
|
|
live = json.loads(result.stdout)
|
|
state = "exact" if _is_subset(desired[ref], live) else "drift"
|
|
if state == "drift":
|
|
raise PlaneRefused(f"unmanaged live drift for {ref.display}")
|
|
if ref.kind == "Namespace" and ref.name == bundle.expected_namespace:
|
|
namespace_exists = True
|
|
live_state.append({"object": ref.display, "state": state})
|
|
|
|
server_validated: list[str] = []
|
|
for manifest in bundle.manifests:
|
|
docs = list(yaml.safe_load_all(manifest.path.read_text()))
|
|
namespaced = any(doc.get("metadata", {}).get("namespace") for doc in docs)
|
|
if namespaced and not namespace_exists:
|
|
continue
|
|
_run(
|
|
runner,
|
|
[
|
|
"kubectl",
|
|
"apply",
|
|
"--server-side",
|
|
"--dry-run=server",
|
|
"--field-manager=ops-mason",
|
|
"-f",
|
|
str(manifest.path),
|
|
],
|
|
)
|
|
server_validated.append(str(manifest.path.relative_to(bundle.repo_root)))
|
|
|
|
dependency_evidence: list[dict[str, Any]] = []
|
|
for dependency in bundle.dependencies:
|
|
result = _run(
|
|
runner,
|
|
_kubectl_get_args(dependency.resource, dependency.name, dependency.namespace),
|
|
)
|
|
live = json.loads(result.stdout)
|
|
checked: dict[str, Any] = {}
|
|
for path, expected in dependency.assertions:
|
|
actual = _json_path(live, path)
|
|
if actual != expected:
|
|
raise PlaneRefused(
|
|
f"dependency {dependency.resource}/{dependency.name} {path}: "
|
|
f"expected {expected!r}, got {actual!r}"
|
|
)
|
|
checked[path] = actual
|
|
dependency_evidence.append(
|
|
{
|
|
"resource": dependency.resource,
|
|
"namespace": dependency.namespace,
|
|
"name": dependency.name,
|
|
"assertions": checked,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"bundle_id": bundle.id,
|
|
"bundle_digest": bundle.digest,
|
|
"context": context,
|
|
"plan_id": bundle.plan().id,
|
|
"plan_approved": bundle.plan().is_approved(),
|
|
"objects": live_state,
|
|
"client_validated_manifests": [
|
|
str(item.path.relative_to(bundle.repo_root)) for item in bundle.manifests
|
|
],
|
|
"server_validated_manifests": server_validated,
|
|
"deferred_server_validation": [
|
|
str(item.path.relative_to(bundle.repo_root))
|
|
for item in bundle.manifests
|
|
if str(item.path.relative_to(bundle.repo_root)) not in server_validated
|
|
],
|
|
"dependencies": dependency_evidence,
|
|
"readiness": inspect_readiness(bundle, runner, readiness_repo, activation, today),
|
|
}
|
|
|
|
|
|
def verify(bundle: PlaneBundle, runner: Runner = subprocess_runner) -> dict[str, Any]:
|
|
desired = _desired_by_ref(bundle)
|
|
objects: list[dict[str, Any]] = []
|
|
for ref in bundle.allowed_objects:
|
|
result = _run(runner, _kubectl_get_args(ref.resource, ref.name, ref.namespace))
|
|
live = json.loads(result.stdout)
|
|
if not _is_subset(desired[ref], live):
|
|
raise PlaneError(f"live object does not match bundle: {ref.display}")
|
|
metadata = live.get("metadata", {})
|
|
objects.append(
|
|
{
|
|
"object": ref.display,
|
|
"uid": metadata.get("uid"),
|
|
"resource_version": metadata.get("resourceVersion"),
|
|
"generation": metadata.get("generation"),
|
|
}
|
|
)
|
|
|
|
negative_scope: dict[str, int] = {}
|
|
for resource in ("pods", "secrets"):
|
|
result = _run(
|
|
runner,
|
|
["kubectl", "-n", bundle.expected_namespace, "get", resource, "-o", "name"],
|
|
)
|
|
count = len(result.stdout.splitlines())
|
|
if count:
|
|
raise PlaneError(
|
|
f"negative-scope check failed: {count} {resource} exist in "
|
|
f"{bundle.expected_namespace}"
|
|
)
|
|
negative_scope[resource] = count
|
|
|
|
return {
|
|
"ok": True,
|
|
"bundle_id": bundle.id,
|
|
"bundle_digest": bundle.digest,
|
|
"context": bundle.expected_context,
|
|
"objects": objects,
|
|
"negative_scope": negative_scope,
|
|
}
|
|
|
|
|
|
def rollback_plan(bundle: PlaneBundle) -> dict[str, Any]:
|
|
namespaced = [ref for ref in bundle.allowed_objects if ref.namespace]
|
|
namespaces = [ref for ref in bundle.allowed_objects if ref.kind == "Namespace"]
|
|
commands = [
|
|
" ".join(
|
|
["kubectl", "-n", ref.namespace or "", "delete", ref.resource, ref.name]
|
|
)
|
|
for ref in namespaced
|
|
]
|
|
namespace_commands = [
|
|
f"kubectl delete {ref.resource} {ref.name}" for ref in namespaces
|
|
]
|
|
return {
|
|
"bundle_id": bundle.id,
|
|
"object_scoped_commands": commands,
|
|
"inventory_before_namespace_delete": [
|
|
f"kubectl -n {bundle.expected_namespace} get all,configmaps,secrets,serviceaccounts,networkpolicies"
|
|
],
|
|
"conditional_namespace_commands": namespace_commands,
|
|
"warning": "Delete the namespace only after proving it contains no later or unrelated objects.",
|
|
}
|
|
|
|
|
|
def apply(
|
|
bundle: PlaneBundle,
|
|
*,
|
|
confirm_plan_id: str,
|
|
expected_digest: str,
|
|
runner: Runner = subprocess_runner,
|
|
readiness_repo: Path | None = None,
|
|
activation: str = "APPROVED",
|
|
break_glass_reason: str | None = None,
|
|
today: date | None = None,
|
|
) -> dict[str, Any]:
|
|
plan = bundle.plan()
|
|
if not plan.is_approved():
|
|
raise PlaneRefused(
|
|
f"plan {plan.id!r} is not approved; refusing Kubernetes mutation"
|
|
)
|
|
if confirm_plan_id != plan.id:
|
|
raise PlaneRefused(
|
|
f"confirmation must exactly match approved plan id {plan.id!r}"
|
|
)
|
|
if expected_digest != bundle.digest:
|
|
raise PlaneRefused(
|
|
f"bundle digest confirmation mismatch: expected {bundle.digest}"
|
|
)
|
|
if activation not in {"APPROVED", "BREAK_GLASS"}:
|
|
raise PlaneRefused("unknown activation")
|
|
if activation == "BREAK_GLASS" and not (break_glass_reason or "").strip():
|
|
raise PlaneRefused("BREAK_GLASS requires a non-empty reason")
|
|
_check_inputs_clean(bundle, runner)
|
|
readiness = inspect_readiness(bundle, runner, readiness_repo, activation, today)
|
|
if not readiness["direct_apply_allowed"]:
|
|
raise PlaneRefused(f"production tier requires GitOps or BREAK_GLASS: {readiness['reason']}")
|
|
before = preflight(bundle, runner, readiness_repo=readiness_repo, activation=activation, today=today)
|
|
if not before["readiness"]["direct_apply_allowed"]:
|
|
raise PlaneRefused("readiness changed during preflight; refusing mutation")
|
|
|
|
server_validated = list(before["server_validated_manifests"])
|
|
persisted: list[str] = []
|
|
for manifest in bundle.manifests:
|
|
relative_path = str(manifest.path.relative_to(bundle.repo_root))
|
|
docs = list(yaml.safe_load_all(manifest.path.read_text()))
|
|
namespaced = any(doc.get("metadata", {}).get("namespace") for doc in docs)
|
|
if namespaced:
|
|
_run(
|
|
runner,
|
|
[
|
|
"kubectl",
|
|
"apply",
|
|
"--server-side",
|
|
"--dry-run=server",
|
|
"--field-manager=ops-mason",
|
|
"-f",
|
|
str(manifest.path),
|
|
],
|
|
)
|
|
server_validated.append(relative_path)
|
|
_run(
|
|
runner,
|
|
[
|
|
"kubectl",
|
|
"apply",
|
|
"--server-side",
|
|
"--field-manager=ops-mason",
|
|
"-f",
|
|
str(manifest.path),
|
|
],
|
|
)
|
|
persisted.append(relative_path)
|
|
|
|
verified = verify(bundle, runner)
|
|
evidence = {
|
|
"schema_version": "ops-mason.kubernetes-plane-evidence/v1",
|
|
"recorded_at": datetime.now(UTC).isoformat(),
|
|
"plan": {
|
|
"id": plan.id,
|
|
"approved_by": plan.approved_by,
|
|
"approved_at": plan.approved_at,
|
|
},
|
|
"source": {
|
|
"repo": bundle.source_repo,
|
|
"revision": bundle.source_revision,
|
|
"implementation_revision": bundle.implementation_revision,
|
|
},
|
|
"apply": {
|
|
"field_manager": "ops-mason",
|
|
"server_validated_manifests": server_validated,
|
|
"persisted_manifests": persisted,
|
|
},
|
|
"readiness": before["readiness"],
|
|
"activation": activation,
|
|
"break_glass": {
|
|
"reason": break_glass_reason.strip(),
|
|
"actor": getpass.getuser(),
|
|
"recorded_at": datetime.now(UTC).isoformat(),
|
|
"follow_up": "Commit the same change to the manifest repository that ArgoCD reconciles.",
|
|
} if activation == "BREAK_GLASS" else None,
|
|
"preflight": before,
|
|
"verification": verified,
|
|
"rollback": rollback_plan(bundle),
|
|
}
|
|
bundle.evidence_path.parent.mkdir(parents=True, exist_ok=True)
|
|
bundle.evidence_path.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n")
|
|
return evidence
|