diff --git a/bounded-executor/cli.py b/bounded-executor/cli.py index 9cd683e..d66d7af 100644 --- a/bounded-executor/cli.py +++ b/bounded-executor/cli.py @@ -17,6 +17,12 @@ The config (JSON, non-secret) names endpoints, token files and the pilot's targe Exit codes: 0 completed, 3 reverted, 4 refused before any effect, 5 stopped / unknown / revert failed / budget, 6 consumed but not in custody. The result printed is value-free. + python3 bounded-executor/cli.py --preflight-only --admission FILE --approval-id ID --config FILE [--result FILE] + +Preflight-only (NK-WP-0058-T03): reachability of every endpoint, setup without the window-scoped write role, and +every preflight check (admission, executor, source pins, trust, registry, window, approval), each reported with its +phase. No consume, no audit event, no write. Exit 0 ready, 4 not ready. Replaces hand-written diagnostic pods. + Kill switch (NK-WP-0056-T07): SIGTERM or SIGINT, e.g. the founder's `kubectl -n sso delete job `, sets the stop flag. Before consume the run refuses and spends nothing; after it, the runner finishes the step in flight, then stops and reverts what it changed (BEA stop_and_revert). The Job's terminationGracePeriodSeconds must cover a @@ -120,9 +126,10 @@ def run_endpoints(cfg: dict) -> dict: return ends -def build(cfg: dict, logins: list): +def build(cfg: dict, logins: list, *, write_login: bool = True): """`logins` collects every OpenBao client as soon as it exists, so the caller revokes them all, even when a - later step of setup fails.""" + later step of setup fails. write_login=False (--preflight-only) skips the window-scoped write role and the + Kubernetes and directory clients: the run's operations are registered but cannot act.""" o = cfg["openbao"] if cfg["journey"]["k8s_role"] == o["k8s_role"]: raise ValueError("the journey read and the bind write must use separate roles") @@ -135,15 +142,20 @@ def build(cfg: dict, logins: list): logins.append(client) return client - bao = login(o["k8s_role"]) + bao = login(o["k8s_role"]) if write_login else None journey_bao = login(cfg["journey"]["k8s_role"]) - directory = OPS.Directory(cfg["lldap"]["url"], [cfg["lldap"].get("set_password", "/app/lldap_set_password")], - ca_file=cfg["lldap"].get("ca_file")) - kube = OPS.Kube() - k = cfg["kubernetes"] + directory = delivery = restart = None + if write_login: + directory = OPS.Directory(cfg["lldap"]["url"], + [cfg["lldap"].get("set_password", "/app/lldap_set_password")], + ca_file=cfg["lldap"].get("ca_file")) + kube = OPS.Kube() + k = cfg["kubernetes"] + delivery = OPS.KubeDelivery(kube, k["namespace"], k["external_secret"]) + restart = OPS.KubeRestart(kube, k["namespace"], k["deployment"]) operations, checks = OPS.pilot_registry( - bao=bao, journey_bao=journey_bao, directory=directory, delivery=OPS.KubeDelivery(kube, k["namespace"], k["external_secret"]), - restart=OPS.KubeRestart(kube, k["namespace"], k["deployment"]), bind_kv=cfg["bind"]["kv"], + bao=bao, journey_bao=journey_bao, directory=directory, delivery=delivery, + restart=restart, bind_kv=cfg["bind"]["kv"], bind_user=cfg["bind"]["user"], journey_kv=cfg["journey"]["kv"], journey_config=cfg["journey"]["config"]) trusted = set(cfg.get("trust_satisfied", [])) @@ -315,6 +327,50 @@ def run_journey_only(cfg: dict) -> tuple[dict, int]: return result, code +def setup_reason(error: Exception) -> str: + """Value-free: the clients' own errors carry fixed texts (an endpoint name, a status code, a shape).""" + if isinstance(error, OPS.Unreachable): + return str(error) # " not reachable", unchanged since c2a0f2f + if isinstance(error, (OPS.OperationError, C.ClientError)): + return f"{type(error).__name__}:{error}" + return type(error).__name__ + + +def preflight_only(admission: dict, approval_id: str, cfg: dict) -> tuple[dict, int]: + """--preflight-only (NK-WP-0058-T03): reachability per endpoint, setup without the write role, then every + preflight check, without consume, emit or any write. Reports every failure with its phase. Exit 0 ready, 4 not.""" + checks: list[dict] = [] + + def add(phase, check, result, reason=None): + checks.append({"phase": phase, "check": check, "result": result, **({"reason": reason} if reason else {})}) + + endpoints = run_endpoints(cfg) + if cfg.get("informed_decision"): + endpoints["informed-decision"] = (cfg["informed_decision"]["url"], "/") + for name, (base, path) in endpoints.items(): + try: + OPS.wait_reachable(base, timeout=10, path=path, name=name) + add("reachability", name, "pass") + except OPS.Unreachable: + add("reachability", name, "fail", "unreachable") + logins: list = [] + try: + config, deps = build(cfg, logins, write_login=False) + add("setup", "lanes", "pass") + add("setup", "write_lane", "skipped", "window_scoped") + for item in X.Run(admission, approval_id, config, deps).preflight_report(): + add("preflight", item["check"], item["result"], item.get("reason")) + except Exception as error: # nothing was consumed or written + add("setup", "build", "fail", setup_reason(error)) + finally: + for client in logins: + client.revoke_self() + ready = all(c["result"] != "fail" for c in checks) + failures = [f"{c['phase']}:{c['check']}" for c in checks if c["result"] == "fail"] + return {"mode": "preflight-only", "ready": ready, "effects": False, "failures": failures, + "checks": checks}, (0 if ready else 4) + + def exit_code(result: dict) -> int: if result.get("reason") in ("receipt_not_in_custody", "audit_unavailable") and result["outcome"] != "refused": return 6 @@ -325,6 +381,8 @@ def main(argv=None) -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) parser.add_argument("--journey-only", action="store_true", help="read-only journey run, no admission (T10)") parser.add_argument("--request", action="store_true", help="file the approval for --admission (T18)") + parser.add_argument("--preflight-only", action="store_true", + help="setup and preflight without consume or the write role (NK-WP-0058-T03)") parser.add_argument("--admission") parser.add_argument("--approval-id") parser.add_argument("--config", required=True) @@ -336,6 +394,13 @@ def main(argv=None) -> int: result, code = run_request(args.admission, json.loads(pathlib.Path(args.config).read_text())) _emit(result, args.result) return code + if args.preflight_only: + if args.journey_only or args.request or not (args.admission and args.approval_id): + parser.error("--preflight-only takes --admission, --approval-id and --config") + result, code = preflight_only(X.bea.load(pathlib.Path(args.admission)), args.approval_id, + json.loads(pathlib.Path(args.config).read_text())) + _emit(result, args.result) + return code if args.journey_only == bool(args.admission or args.approval_id): parser.error("give either --journey-only, or --admission with --approval-id") if not args.journey_only and not (args.admission and args.approval_id): @@ -351,8 +416,7 @@ def main(argv=None) -> int: try: config, deps = build(cfg, logins) except Exception as error: # setup failed before preflight: nothing was consumed - where = str(error) if isinstance(error, OPS.Unreachable) else type(error).__name__ - result = {"outcome": "refused", "reason": "setup:" + where, "run_id": None, "effects": False} + result = {"outcome": "refused", "reason": "setup:" + setup_reason(error), "run_id": None, "effects": False} else: run = X.Run(admission, args.approval_id, config, deps) try: diff --git a/bounded-executor/executor.py b/bounded-executor/executor.py index 3245ba9..f7cb760 100644 --- a/bounded-executor/executor.py +++ b/bounded-executor/executor.py @@ -155,28 +155,61 @@ class Run: self.start_mono: float | None = None # ------------------------------------------------------------------ preflight (no effect) - def preflight(self) -> None: - report = bea.evaluate(self.doc, self.schema) - if not report["valid"]: - raise Refused("admission_invalid:" + ",".join(sorted({f["rule"] for f in report["findings"]}))) + def _preflight_steps(self) -> list[tuple[str, Callable[[], None]]]: + """The preflight as named checks, in order. preflight() stops at the first refusal; preflight_report() + runs them all (NK-WP-0058-T03 --preflight-only). None of them has an effect.""" spec = self.spec - if spec["executor"]["identity"] != self.cfg.executor_identity: - raise Refused("not_this_executor") - for pinned in spec["plan"]["source"]["files"]: - path = self.cfg.source_root / pinned["path"] - if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != pinned["sha256"]: - raise Refused("source_drift") - for state in spec["trust_requires"]: - if not self.d.trust(state): - raise Refused("trust_not_satisfied:" + state) - for step in spec["plan"]["steps"] + spec["plan"].get("revert_steps", []): - if step["operation"] not in self.d.operations: - raise Refused("unknown_operation") - for check in spec["checks"]: - if check["id"] not in self.d.checks: - raise Refused("unknown_check") - self._check_window() - self._check_approval(self.d.carrier.get(self.approval_id), consumed_ok=False) + + def admission(): + report = bea.evaluate(self.doc, self.schema) + if not report["valid"]: + raise Refused("admission_invalid:" + ",".join(sorted({f["rule"] for f in report["findings"]}))) + + def executor(): + if spec["executor"]["identity"] != self.cfg.executor_identity: + raise Refused("not_this_executor") + + def source(): + for pinned in spec["plan"]["source"]["files"]: + path = self.cfg.source_root / pinned["path"] + if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != pinned["sha256"]: + raise Refused("source_drift") + + def trust(): + for state in spec["trust_requires"]: + if not self.d.trust(state): + raise Refused("trust_not_satisfied:" + state) + + def registry(): + for step in spec["plan"]["steps"] + spec["plan"].get("revert_steps", []): + if step["operation"] not in self.d.operations: + raise Refused("unknown_operation") + for check in spec["checks"]: + if check["id"] not in self.d.checks: + raise Refused("unknown_check") + + def approval(): + self._check_approval(self.d.carrier.get(self.approval_id), consumed_ok=False) + + return [("admission", admission), ("executor", executor), ("source", source), ("trust", trust), + ("registry", registry), ("window", self._check_window), ("approval", approval)] + + def preflight(self) -> None: + for _, check in self._preflight_steps(): + check() + + def preflight_report(self) -> list[dict]: + """Every preflight check with its result; value-free. An error that is not a refusal is named by type.""" + results = [] + for name, check in self._preflight_steps(): + try: + check() + results.append({"check": name, "result": "pass"}) + except Refused as refusal: + results.append({"check": name, "result": "fail", "reason": str(refusal)}) + except Exception as error: + results.append({"check": name, "result": "fail", "reason": "error:" + type(error).__name__}) + return results def _check_window(self) -> None: now = self.d.wall() diff --git a/bounded-executor/tests/test_openbao_lanes.py b/bounded-executor/tests/test_openbao_lanes.py index a285122..f8ce0c6 100644 --- a/bounded-executor/tests/test_openbao_lanes.py +++ b/bounded-executor/tests/test_openbao_lanes.py @@ -113,6 +113,56 @@ class CliLanes(Base): self.assertEqual(self.bao.logins, []) +class PreflightOnly(CliLanes): + """NK-WP-0058-T03: setup and every preflight check, no write role, no consume, every failure named.""" + + def run_preflight(self, cfg): + (self.root / "cfg.json").write_text(json.dumps(cfg)) + admission = CLI.X.REPO / "examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml" + with contextlib.redirect_stdout(io.StringIO()) as out: + code = CLI.main(["--preflight-only", "--admission", str(admission), "--approval-id", "apr-1", + "--config", str(self.root / "cfg.json")]) + return code, json.loads(out.getvalue()) + + def pilot_cfg(self): + cfg = self.cfg() + cfg.update(executor_identity="service:nk-bind-executor", runtime_principal="p", tenant="platform", + environment="PRODUCTION", approver_subjects={"admin": "sub-admin"}, trust_satisfied=[], + bind={"kv": "platform/workloads/net-kingdom/directory-binds/svc-authelia", "user": "svc-authelia"}, + kubernetes={"namespace": "sso", "external_secret": "e", "deployment": "d"}) + return cfg + + def test_reports_every_check_without_the_write_role_or_any_consume(self): + code, report = self.run_preflight(self.pilot_cfg()) + self.assertEqual(code, 4) + self.assertEqual((report["mode"], report["ready"], report["effects"]), ("preflight-only", False, False)) + self.assertEqual([role for role, _ in self.bao.logins], ["journey-read"]) # never the write role + self.assertEqual(self.bao.revoked, ["tok-journey-read-1"]) + by = {(c["phase"], c["check"]): c for c in report["checks"]} + self.assertEqual(by[("setup", "write_lane")]["result"], "skipped") + self.assertEqual([c for p, c in by if p == "preflight"], + ["admission", "executor", "source", "trust", "registry", "window", "approval"]) + self.assertEqual(by[("preflight", "trust")]["reason"], "trust_not_satisfied:runtime_secret_trust") + self.assertEqual(by[("preflight", "approval")]["reason"], "approval_binding") # the fake approval + self.assertIn("preflight:approval", report["failures"]) + self.assertNotIn("/consume", " ".join(p for p, _ in self.bao.reads)) + + def test_an_unreachable_endpoint_is_named(self): + cfg = self.pilot_cfg() + cfg["lldap"]["url"] = "http://127.0.0.1:9" + wait = CLI.OPS.wait_reachable + self.addCleanup(setattr, CLI.OPS, "wait_reachable", wait) + CLI.OPS.wait_reachable = lambda addr, **kw: wait(addr, **{**kw, "timeout": 0.2, "interval": 0.1}) + code, report = self.run_preflight(cfg) + self.assertEqual(code, 4) + by = {(c["phase"], c["check"]): c for c in report["checks"]} + self.assertEqual(by[("reachability", "lldap")], {"phase": "reachability", "check": "lldap", + "result": "fail", "reason": "unreachable"}) + self.assertEqual(by[("reachability", "openbao")]["result"], "pass") + self.assertEqual(by[("setup", "build")]["reason"], "lldap not reachable") + self.assertEqual(self.bao.logins, []) + + class JourneyOnly(Base): SEED = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"