"""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