build: add guarded Kubernetes plane executor
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02878-7c21-7692-bcd6-ce2838c4b448
This commit is contained in:
parent
1dd98b4f27
commit
2b318634b6
13 changed files with 1240 additions and 3 deletions
67
src/ops_mason/cli.py
Normal file
67
src/ops_mason/cli.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Command-line surface for guarded ops-mason builders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from ops_mason.kubernetes_plane import (
|
||||
PlaneBundle,
|
||||
PlaneError,
|
||||
apply,
|
||||
preflight,
|
||||
rollback_plan,
|
||||
verify,
|
||||
)
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="ops-mason")
|
||||
families = parser.add_subparsers(dest="family", required=True)
|
||||
plane = families.add_parser("plane", help="guarded Kubernetes security plane")
|
||||
commands = plane.add_subparsers(dest="command", required=True)
|
||||
|
||||
for name in ("render", "preflight", "verify", "rollback-plan"):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument("--bundle", required=True)
|
||||
|
||||
apply_parser = commands.add_parser("apply")
|
||||
apply_parser.add_argument("--bundle", required=True)
|
||||
apply_parser.add_argument("--confirm", required=True, help="exact approved plan id")
|
||||
apply_parser.add_argument(
|
||||
"--expect-digest", required=True, help="exact digest returned by preflight"
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
bundle = PlaneBundle.load(args.bundle)
|
||||
if args.command == "render":
|
||||
result = bundle.render()
|
||||
elif args.command == "preflight":
|
||||
result = preflight(bundle)
|
||||
elif args.command == "verify":
|
||||
result = verify(bundle)
|
||||
elif args.command == "rollback-plan":
|
||||
result = rollback_plan(bundle)
|
||||
elif args.command == "apply":
|
||||
result = apply(
|
||||
bundle,
|
||||
confirm_plan_id=args.confirm,
|
||||
expected_digest=args.expect_digest,
|
||||
)
|
||||
else: # pragma: no cover - argparse enforces the command set
|
||||
raise AssertionError(args.command)
|
||||
except (OSError, PlaneError) as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
606
src/ops_mason/kubernetes_plane.py
Normal file
606
src/ops_mason/kubernetes_plane.py
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
"""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 hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from ops_mason.plan import ConstructionPlan
|
||||
|
||||
|
||||
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], ...]
|
||||
|
||||
@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),
|
||||
)
|
||||
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:
|
||||
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,
|
||||
"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) -> 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,
|
||||
}
|
||||
|
||||
|
||||
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", "json"],
|
||||
)
|
||||
count = len(json.loads(result.stdout).get("items", []))
|
||||
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,
|
||||
) -> 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}"
|
||||
)
|
||||
_check_inputs_clean(bundle, runner)
|
||||
before = preflight(bundle, runner)
|
||||
|
||||
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:
|
||||
_run(
|
||||
runner,
|
||||
[
|
||||
"kubectl",
|
||||
"apply",
|
||||
"--server-side",
|
||||
"--dry-run=server",
|
||||
"--field-manager=ops-mason",
|
||||
"-f",
|
||||
str(manifest.path),
|
||||
],
|
||||
)
|
||||
_run(
|
||||
runner,
|
||||
[
|
||||
"kubectl",
|
||||
"apply",
|
||||
"--server-side",
|
||||
"--field-manager=ops-mason",
|
||||
"-f",
|
||||
str(manifest.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,
|
||||
},
|
||||
"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
|
||||
Loading…
Add table
Add a link
Reference in a new issue