NK-WP-0058-T03 step 2: executor --preflight-only
All checks were successful
All checks were successful
Reachability per endpoint, setup without the window-scoped write role (no Kubernetes or directory client), then every preflight check (admission, executor, source, trust, registry, window, approval) with its phase; no consume, emit or write. Exit 0 ready, 4 not. preflight() is now the same named checks, stopping at the first. Setup reasons keep the clients' fixed texts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 943911@bnt-lap001 Assistant-Session: fc322940-1c8e-4bed-ab39-86cec99f23a0
This commit is contained in:
parent
7350ba8003
commit
64cec27c28
3 changed files with 179 additions and 32 deletions
|
|
@ -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 <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) # "<endpoint> 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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue