"""Apply a guarded plan to OpenBao. Idempotent and decision-gated. Apply only ever writes *metadata* (KV mount, ACL policy, approle role). It does NOT write secret values — that is the separate, more constrained `provision` step. """ from __future__ import annotations from dataclasses import dataclass from secrets_engine.catalog import CatalogEntry from secrets_engine.openbao import OpenBaoClient from secrets_engine.plan import Plan @dataclass class ApplyResult: applied: list[str] skipped: list[str] def render(self) -> str: out = [] for a in self.applied: out.append(f" applied: {a}") for s in self.skipped: out.append(f" unchanged: {s}") return "\n".join(out) or " (nothing to do)" def apply_plan(client: OpenBaoClient, entry: CatalogEntry, plan: Plan, ttl: str = "30m") -> ApplyResult: """Execute the plan's metadata actions idempotently. Idempotency: KV mount and approle are enable-if-absent; the policy is written only when its current body differs from the desired HCL. """ applied: list[str] = [] skipped: list[str] = [] # 1. KV mount. if client.kv_mount_exists(entry.mount): skipped.append(f"kv-mount {entry.mount} (already present)") else: client.ensure_kv_mount(entry.mount) applied.append(f"kv-mount {entry.mount}") # 2. Consumer ACL policy (write only if changed). current = client.read_policy(plan.policy_name) if current and _normalize(current) == _normalize(plan.policy_hcl): skipped.append(f"policy {plan.policy_name} (unchanged)") else: client.write_policy(plan.policy_name, plan.policy_hcl) applied.append(f"policy {plan.policy_name}") # 3. Consumer approle bound to that policy. client.ensure_approle_enabled() client.write_approle(plan.role_name, [plan.policy_name], ttl=ttl) applied.append(f"approle {plan.role_name} -> [{plan.policy_name}]") return ApplyResult(applied=applied, skipped=skipped) def _normalize(hcl: str) -> str: """Compare policy bodies ignoring comments and whitespace noise.""" lines = [] for raw in hcl.splitlines(): line = raw.strip() if not line or line.startswith("#"): continue lines.append(" ".join(line.split())) return "\n".join(lines)