Keep serve on the live lifecycle path until the job stream ends.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

The process reads one stage credential file at startup, applies each job through the CLI lifecycle functions, and waits for further jobs. A second stage credential file refuses startup. A local allow flag does not authorize the call.

Assistant: grok
Assistant-Session: 01a1609f-72b8-7222-9dff-fae300115e42
This commit is contained in:
tegwick 2026-10-22 18:15:23 +02:00
parent 06dfe12c18
commit f211525f00
4 changed files with 299 additions and 151 deletions

View file

@ -292,30 +292,62 @@ def cmd_instance(cfg: Config, args) -> int:
return 0
def execute_lifecycle_plan(cfg: Config, args, entry, plan, evidence):
"""Live lifecycle path shared by `lifecycle` and `serve`.
Builds nothing itself: the caller already called ``build_lifecycle_plan``.
Approval, the OpenBao client, and ``apply_lifecycle_plan`` stay here.
"""
from secrets_engine.errors import PolicyGuardError
from secrets_engine.lifecycle import apply_lifecycle_plan, require_destroy_confirmation
if args.operation == "destroy":
require_destroy_confirmation(entry, getattr(args, "confirm_destroy", "") or "")
raise PolicyGuardError(
"live destroy is disabled until an exact-action destruction "
"approval contract is available; use --dry-run to inspect targets"
)
decision = _require_lane_approval(
cfg, entry, args.operation, evidence, auth_args=args
)
evidence.mark_approved(decision)
with _open_backend(cfg, args, evidence) as client:
result = apply_lifecycle_plan(client, plan)
mapped = operation_state(args.operation)
record = None
if mapped:
record = save_lane_state(
cfg.evidence_dir, entry.id, mapped, operation=args.operation
)
detail = {
"applied": list(result.applied),
"preserved": list(result.preserved),
}
if record is not None:
detail["lane_state"] = record.state
evidence.finish("applied", detail=detail)
return result
def cmd_serve(cfg: Config, args) -> int:
"""Keep the lifecycle library running for the one injected stage."""
from secrets_engine.catalog import get_entry
from secrets_engine.service import local_allow_requested, run_service
"""Keep the lifecycle library running for the one injected stage.
The argv job runs first. The process then waits on stdin for more jobs
and leaves only when that stream ends. Each job uses the live lifecycle
path. ``--allow`` is not authority.
"""
from secrets_engine.service import local_allow_requested, run_service, service_jobs
local_allow_requested(bool(getattr(args, "allow", False)))
binding = getattr(args, "stage_binding", None)
entry = get_entry(cfg.catalog_dir, args.catalog_id)
foreign = getattr(args, "foreign_stage_credential_file", None) or ""
foreign_value = ""
if foreign:
foreign_value = "present"
plans = run_service(
binding,
[
{
"entry": entry,
"operation": args.operation,
"foreign_stage_credential": foreign_value,
}
],
outcomes = run_service(
cfg,
service_jobs(args.catalog_id, args.operation, sys.stdin),
allow=bool(getattr(args, "allow", False)),
auth_args=args,
)
for plan in plans:
for plan, result in outcomes:
print(plan.render())
print(result.render())
return 0
@ -853,11 +885,7 @@ def cmd_state_show(cfg: Config, args) -> int:
def cmd_lifecycle(cfg: Config, args) -> int:
from secrets_engine.lifecycle import (
apply_lifecycle_plan,
build_lifecycle_plan,
require_destroy_confirmation,
)
from secrets_engine.lifecycle import build_lifecycle_plan
entry = get_entry(cfg.catalog_dir, args.catalog_id)
if args.operation in {"compromise", "reactivate"}:
@ -873,36 +901,9 @@ def cmd_lifecycle(cfg: Config, args) -> int:
f"lifecycle-{args.operation}",
detail={"operation": plan.operation},
) as evidence:
if args.operation == "destroy":
require_destroy_confirmation(entry, args.confirm_destroy)
# A lane-level approval is not an authorization to erase custody.
# Keep the destructive live path closed until T04 supplies a
# canonical exact-action and dual-control-capable decision contract.
from secrets_engine.errors import PolicyGuardError
raise PolicyGuardError(
"live destroy is disabled until an exact-action destruction "
"approval contract is available; use --dry-run to inspect targets"
)
decision = _require_lane_approval(cfg, entry, args.operation, evidence, auth_args=args)
evidence.mark_approved(decision)
with _open_backend(cfg, args, evidence) as client:
result = apply_lifecycle_plan(client, plan)
mapped = operation_state(args.operation)
record = None
if mapped:
record = save_lane_state(
cfg.evidence_dir, entry.id, mapped, operation=args.operation
)
print(plan.render())
print(result.render())
detail = {
"applied": list(result.applied),
"preserved": list(result.preserved),
}
if record is not None:
detail["lane_state"] = record.state
evidence.finish("applied", detail=detail)
result = execute_lifecycle_plan(cfg, args, entry, plan, evidence)
print(plan.render())
print(result.render())
return 0
@ -1126,11 +1127,7 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="ignored; a local flag does not authorize this process",
)
srv.add_argument(
"--foreign-stage-credential-file",
default="",
help=argparse.SUPPRESS,
)
add_token_arg(srv)
srv.set_defaults(func=cmd_serve)
pl = sub.add_parser("plan", help="render a guarded plan (no mutation)")

View file

@ -1,20 +1,21 @@
"""Kept-running manifestation of the lifecycle library.
This is the same library the CLI uses, in a process that stays up for one
injected stage. It is not a second implementation and not a Deployment.
This is the same library the CLI uses. The process stays in the job loop
until the job stream ends. It is not a second implementation and not a
cluster workload manifest.
"""
from __future__ import annotations
import json
import os
from collections.abc import Iterable, Mapping
from collections.abc import Iterable, Iterator, Mapping
from typing import Any
from secrets_engine import lifecycle as lifecycle_lib
from secrets_engine.catalog import CatalogEntry
from secrets_engine.lifecycle import LifecyclePlan
from secrets_engine.stage_binding import StageBinding, StartupError
from secrets_engine.stage_binding import StartupError, load_stage_binding
LOCAL_ALLOW_ENV = "SECRETS_ENGINE_LOCAL_ALLOW"
CATALOG_STAGE = {"build": "build", "test": "test", "production": "prod"}
def local_allow_requested(flag: bool = False) -> bool:
@ -23,49 +24,77 @@ def local_allow_requested(flag: bool = False) -> bool:
return bool(flag) or env
def service_jobs(
catalog_id: str, operation: str, lines: Iterable[str]
) -> Iterator[dict[str, str]]:
"""Yield the argv job, then block on further lines until the stream ends."""
yield {"catalog_id": catalog_id, "operation": operation, "confirm_destroy": ""}
for line in lines:
text = line.strip()
if not text:
continue
try:
payload = json.loads(text)
except json.JSONDecodeError as exc:
raise StartupError("service job is not a JSON object") from exc
if not isinstance(payload, dict):
raise StartupError("service job must be a mapping")
next_id = str(payload.get("catalog_id") or "")
next_operation = str(payload.get("operation") or "")
if not next_id or not next_operation:
raise StartupError("service job is missing catalog_id or operation")
yield {
"catalog_id": next_id,
"operation": next_operation,
"confirm_destroy": str(payload.get("confirm_destroy") or ""),
}
def run_service(
binding: StageBinding | None,
jobs: Iterable[Mapping[str, Any]],
) -> list[LifecyclePlan]:
"""Run lifecycle jobs for one stage until the job iterable ends.
A job may carry ``foreign_stage_credential`` only as a refusal test. A
present value refuses the job. The binding credential is never rendered.
"""
if binding is None or not binding.credential or not binding.stage:
raise StartupError("startup refused: no stage credential is present")
plans: list[LifecyclePlan] = []
for job in jobs:
foreign = str(job.get("foreign_stage_credential") or "")
if foreign:
raise StartupError(
"startup refused: another stage credential is present"
)
entry = job.get("entry")
if not isinstance(entry, CatalogEntry):
raise StartupError("service job is missing a catalog entry")
operation = str(job.get("operation") or "")
plans.append(lifecycle_lib.build_lifecycle_plan(entry, operation))
return plans
def invoke_protected(
cfg: Any,
entry: CatalogEntry,
action: str,
jobs: Iterable[Mapping[str, str]],
*,
allow: bool = False,
auth_args: Any = None,
) -> Any:
"""Gate a protected call the way the CLI does.
) -> list[tuple[Any, Any]]:
"""Run lifecycle jobs until the iterator ends.
``allow`` and ``SECRETS_ENGINE_LOCAL_ALLOW`` are recorded as not authority
and then discarded. The call still requires a decision record or the
published unreachable-engine stance.
Startup reads the stage credential files. Two files refuse startup before
any job is pulled. Each job calls ``build_lifecycle_plan`` and then the
live lifecycle function the CLI uses, which applies the plan. ``allow``
is discarded and does not authorize that call.
"""
local_allow_requested(allow)
from secrets_engine.cli import _require_lane_approval
binding = load_stage_binding()
if binding is None or not binding.credential or not binding.stage:
raise StartupError("startup refused: no stage credential is present")
catalog_stage = CATALOG_STAGE.get(binding.stage, "")
from types import SimpleNamespace
return _require_lane_approval(
cfg, entry, action, None, auth_args=auth_args
)
from secrets_engine.catalog import get_entry
from secrets_engine.cli import _privileged_evidence, execute_lifecycle_plan
outcomes: list[tuple[Any, Any]] = []
for job in jobs:
entry = get_entry(cfg.catalog_dir, str(job.get("catalog_id") or ""))
if entry.stage != catalog_stage:
raise StartupError(
"startup refused: lane stage does not match the injected stage"
)
operation = str(job.get("operation") or "")
plan = lifecycle_lib.build_lifecycle_plan(entry, operation)
call = SimpleNamespace(
operation=operation,
auth=getattr(auth_args, "auth", "env"),
bootstrap_token_file=getattr(auth_args, "bootstrap_token_file", None),
confirm_destroy=str(job.get("confirm_destroy") or ""),
)
with _privileged_evidence(
cfg,
entry,
f"lifecycle-{operation}",
detail={"operation": operation},
) as evidence:
result = execute_lifecycle_plan(cfg, call, entry, plan, evidence)
outcomes.append((plan, result))
return outcomes