diff --git a/docs/architecture/container-lifecycle-hooks-alpha-tests.md b/docs/architecture/container-lifecycle-hooks-alpha-tests.md new file mode 100644 index 000000000..7c5c08c79 --- /dev/null +++ b/docs/architecture/container-lifecycle-hooks-alpha-tests.md @@ -0,0 +1,1048 @@ +# Container Lifecycle Hook System — Alpha Test Plan + +## Scope + +Alpha = Phase 1 from the design doc: core infrastructure (host-exec, in-process +trait, CLI). GuestExec, snapshot hooks, restore hooks, and SDK surfaces are +out of scope. + +| Area | In alpha | Deferred | +|------|----------|----------| +| `Hook` types + serde round-trip | Yes | — | +| `HookContext` JSON / env-var serialization | Yes | — | +| `$BOXLITE_*` variable substitution | Yes | — | +| `HookRunner::fire()` ordering, condition eval, error dispatch | Yes | — | +| `HostExec` strategy (spawn, stdin pipe, capture, timeout, kill) | Yes | — | +| `Hook` trait (in-process) | Yes | — | +| `fire_count` persistence | Yes | — | +| Tracing spans | Yes | — | +| CLI flags (`--hook`, `--hook-json`, `--hook-arg`, modifiers) | Yes | — | +| Wire `fire()` into `BoxImpl::{start, stop, exec}` | Yes | — | +| Wire `fire()` into `RuntimeImpl::create_box()` | Yes | — | +| `GuestExec` strategy | No | Phase 2 | +| Snapshot / restore hook points | No | Phase 3 | +| Python / Node / Go / C SDK | No | Phase 4 | +| REST API hook validation | No | Phase 4 | + +## Test Environment + +``` +Rust: cargo test / cargo nextest +OS: Linux (KVM available for integration tests) +Features: --features krun,gvproxy (integration); no special features (unit) +CLI: built debug binary at target/debug/boxlite +``` + +## Module: Types & Serialization + +File: `src/boxlite/src/hooks/mod.rs` (tests inline or in `tests/types.rs`) + +### T-SERDE-01 — Hook round-trip JSON + +``` +Given: a Hook with all fields set to non-default values +When: serialized to JSON, then deserialized +Then: the result equals the original +``` + +```json +{ + "name": "my-hook", + "point": "post-exec", + "action": { + "type": "host-exec", + "program": "/usr/bin/curl", + "args": ["-X", "POST", "$BOXLITE_BOX_ID"], + "env": [["DEBUG", "1"]] + }, + "enabled": false, + "priority": 5, + "timeout_secs": 60, + "condition": { + "kind": "exec-result", + "trigger": "on-failure" + }, + "on_error": { + "retry": { + "max_retries": 3, + "backoff_secs": 2, + "on_exhausted": "continue" + } + } +} +``` + +### T-SERDE-02 — Hook with all defaults (minimal JSON) + +``` +Given: {"name":"h","point":"post-create","action":{"type":"host-exec","program":"true","args":[]}} +When: deserialized +Then: enabled=true, priority=0, timeout_secs=30, condition=None, on_error=Continue +``` + +### T-SERDE-03 — HookAction tag discriminator + +``` +Given: {"type":"host-exec","program":"ls","args":[]} +When: deserialized as HookAction +Then: matches HostExec { program: "ls", args: [], env: [] } + +Given: {"type":"guest-exec","command":"ls","args":[],"user":null,"working_dir":null} +When: deserialized as HookAction +Then: matches GuestExec { command: "ls", args: [], env: [], user: None, working_dir: None } + +Given: {"type":"guest-exec","command":"/bin/sh","args":["-c","init.sh"],"user":"agent","working_dir":"/opt/app"} +When: deserialized as HookAction +Then: matches GuestExec { command: "/bin/sh", args: ["-c","init.sh"], env: [], user: Some("agent"), working_dir: Some("/opt/app") } +``` + +### T-SERDE-04 — Unknown variant rejection + +``` +Given: {"type":"invalid","program":"ls","args":[]} +When: deserialized as HookAction +Then: Err (serde error about unknown variant) +``` + +### T-SERDE-05 — HookErrorPolicy variants + +``` +Given: "continue" / "fail" / {"retry":{"max_retries":2,"backoff_secs":1,"on_exhausted":"fail"}} +When: each deserialized +Then: Continue / Fail / Retry { max_retries: 2, backoff_secs: 1, on_exhausted: Fail } +``` + +### T-SERDE-05b — OnExhausted standalone deserialization + +``` +Given: "continue" / "fail" +When: each deserialized as OnExhausted +Then: Continue / Fail +``` + +### T-SERDE-06 — ExecHookTrigger variants + +``` +Given: "always" / "on-success" / "on-failure" / {"exit-code":42} / {"command-matches":"pip*"} +When: each deserialized +Then: Always / OnSuccess / OnFailure / ExitCode(42) / CommandMatches("pip*") +``` + +### T-SERDE-07 — HookCondition tagged enum + +``` +Given: {"kind":"exec-result","trigger":"on-success"} +When: deserialized +Then: HookCondition::ExecResult { trigger: ExecHookTrigger::OnSuccess } +``` + +### T-SERDE-08 — HookPoint kebab-case serialization + +``` +Given: HookPoint::PostCreate +When: serialized to JSON string +Then: "post-create" + +Given: HookPoint::PreStart +When: serialized to JSON string +Then: "pre-start" +``` + +(Verify all 11 variants round-trip through their kebab-case names.) + +## Module: HookContext + +File: `src/boxlite/src/hooks/context.rs` (tests inline) + +### T-CTX-01 — JSON serialization for HostExec + +``` +Given: HookContext for post-exec with exit_code=0, exec_command=["pip","install"] +When: serialized to JSON +Then: all fields present; box_id, container_id are strings; + hook_point is "post-exec"; box_status is "running" (BoxStatus kebab-case); + exit_code=0; exec_command=["pip","install"]; + exec_duration_ms is a u64; snapshot_name is null +``` + +### T-CTX-02 — Env-var serialization for GuestExec + +``` +Given: HookContext with box_id="bx1", hook_point=PostStart, exit_code=None +When: serialized to env-var map +Then: BOXLITE_BOX_ID=bx1, BOXLITE_HOOK_POINT=post-start, + BOXLITE_EXIT_CODE="" (empty string), BOXLITE_SNAPSHOT_NAME="" +``` + +### T-CTX-03 — Pre-exec context (exit_code is None) + +``` +Given: HookContext::for_pre_exec(box_id, container_id, command, duration=None) +When: serialized +Then: exit_code is null/absent, exec_duration_ms is null/absent, + exec_command == ["sh", "-c", "echo hi"] +``` + +### T-CTX-04 — Snapshot context (snapshot_name present) + +``` +Given: HookContext for pre-snapshot with snapshot_name="my-snap" +When: serialized +Then: snapshot_name="my-snap"; exit_code and exec_command are null/empty +``` + +## Module: Variable Substitution + +File: `src/boxlite/src/hooks/runner.rs` (tests in `tests/substitution.rs`) + +### T-SUB-01 — Single variable substitution in args + +``` +Given: args=["snapshot","$BOXLITE_BOX_ID","--name","latest"], ctx.box_id="bxp8k2m" +When: substitute(ctx, args) +Then: ["snapshot","bxp8k2m","--name","latest"] +``` + +### T-SUB-02 — Multiple variables in single arg + +``` +Given: args=["--msg=$BOXLITE_BOX_ID:$BOXLITE_HOOK_POINT"], ctx.box_id="bx1", ctx.hook_point=PostStart +When: substitute(ctx, args) +Then: ["--msg=bx1:post-start"] +``` + +### T-SUB-03 — Variable substitution in env values + +``` +Given: env=[("BOX","$BOXLITE_BOX_ID"),("POINT","$BOXLITE_HOOK_POINT")], ctx.box_id="bx1", ctx.hook_point=PostStart +When: substitute in env values +Then: [("BOX","bx1"),("POINT","post-start")] +``` + +### T-SUB-04 — Unrecognized variable left as-is + +``` +Given: args=["$BOXLITE_UNKNOWN_VAR"], ctx with no such field +When: substitute(ctx, args) +Then: ["$BOXLITE_UNKNOWN_VAR"] +``` + +### T-SUB-05 — No $BOXLITE_ prefix left as-is + +``` +Given: args=["$HOME","$PATH","literal"] +When: substitute(ctx, args) +Then: ["$HOME","$PATH","literal"] +``` + +### T-SUB-06 — Empty variables for non-applicable context + +``` +Given: ctx for PostStart (exit_code=None, exec_command=None, snapshot_name=None) +When: substitute "$BOXLITE_EXIT_CODE", "$BOXLITE_EXEC_COMMAND", "$BOXLITE_SNAPSHOT_NAME" +Then: all replaced with "" (empty string) +``` + +### T-SUB-07 — Integer values stringified + +``` +Given: ctx.fire_count=42, ctx.exit_code=Some(137), ctx.exec_duration_ms=Some(4200) +When: substitute each +Then: "42", "137", "4200" +``` + +### T-SUB-08 — All 11 variables present and substituted + +``` +Given: a fully-populated HookContext +When: substitute each of the 11 documented variables +Then: each replaced with the correct string value; no variable left unsubstituted + except for intentionally non-applicable ones (empty) +``` + +## Module: HookRunner::fire() + +File: `src/boxlite/src/hooks/runner.rs` (tests in `tests/runner.rs`) + +### T-RUN-01 — No hooks, no error + +``` +Given: HookRunner with empty trait_hooks and declarative_hooks +When: fire(HookPoint::PostStart, ctx, guest=None) +Then: returns Ok(()) +``` + +### T-RUN-02 — Single enabled hook fires + +``` +Given: one HostExec hook: program="true", enabled=true +When: fire() +Then: hook executes; exit_status.success(); fire() returns Ok(()) +``` + +### T-RUN-03 — Disabled hook skipped + +``` +Given: one HostExec hook: program="false", enabled=false +When: fire() +Then: hook is NOT executed; fire() returns Ok(()) +``` + +### T-RUN-04 — Priority ordering + +``` +Given: three hooks with priorities 10, 0, 5 (all HostExec, program="true") +When: fire() +Then: execution order is priority 0, then 5, then 10 +``` + +### T-RUN-05 — Equal priority: trait before declarative + +``` +Given: one trait hook (priority 0), one declarative hook (priority 0) +When: fire() +Then: trait hook executes before declarative hook +``` + +### T-RUN-06 — Condition: OnSuccess skips on failure + +``` +Given: HostExec hook with condition=ExecResult(OnSuccess) +When: fire(PostExec, ctx with exit_code=Some(1)) +Then: hook is skipped; fire() returns Ok(()) +``` + +### T-RUN-07 — Condition: OnSuccess fires on success + +``` +Given: HostExec hook with condition=ExecResult(OnSuccess), program="true" +When: fire(PostExec, ctx with exit_code=Some(0)) +Then: hook executes +``` + +### T-RUN-08 — Condition: OnFailure skips on success + +``` +Given: HostExec hook with condition=ExecResult(OnFailure) +When: fire(PostExec, ctx with exit_code=Some(0)) +Then: hook is skipped +``` + +### T-RUN-09 — Condition: ExitCode(n) exact match + +``` +Given: hook with condition=ExecResult(ExitCode(42)) +When: fire(PostExec, ctx with exit_code=Some(42)) +Then: hook executes + +When: fire(PostExec, ctx with exit_code=Some(0)) +Then: hook skipped +``` + +### T-RUN-10 — Condition: CommandMatches glob + +``` +Given: hook with condition=ExecResult(CommandMatches("pip*")), program="true" +When: fire(PostExec, ctx with exec_command=["pip","install","-r","req.txt"]) +Then: hook executes + +When: fire(PostExec, ctx with exec_command=["python","agent.py"]) +Then: hook skipped +``` + +### T-RUN-11 — Condition: CommandMatches wildcard patterns + +``` +Given: glob "pip*" +Then: matches "pip", "pip3", "pip3.12" +Then: does NOT match "python3 -m pip" (checks argv[0] only) + +Given: glob "pip" +Then: matches "pip" (exact) +Then: does NOT match "pip3" +``` + +### T-RUN-12 — Condition: None always fires + +``` +Given: hook with condition=None, program="true" +When: fire() regardless of ctx +Then: hook always executes +``` + +### T-RUN-13 — OnError::Continue after non-zero exit + +``` +Given: hook with on_error=Continue, program="false" (exits 1) +When: fire() +Then: fire() returns Ok(()); warning is logged +``` + +### T-RUN-14 — OnError::Fail after non-zero exit + +``` +Given: hook with on_error=Fail, program="false" (exits 1) +When: fire() +Then: fire() returns Err(...); remaining hooks in chain NOT executed +``` + +### T-RUN-15 — OnError::Fail stops chain at first failure + +``` +Given: hook-A (priority 0, on_error=Fail, program="false"), + hook-B (priority 1, program="true") +When: fire() +Then: hook-A fails; hook-B never executes; fire() returns Err +``` + +### T-RUN-16 — OnError::Continue keeps chain going + +``` +Given: hook-A (priority 0, on_error=Continue, program="false"), + hook-B (priority 1, program="true") +When: fire() +Then: hook-A fails; hook-B still executes; fire() returns Ok(()) +``` + +### T-RUN-17 — Retry: success on second attempt + +``` +Given: hook with on_error=Retry { max_retries: 3, backoff_secs: 0, on_exhausted: Continue } + program="./flaky.sh" (fails once, succeeds after) +When: fire() +Then: first attempt fails; retry succeeds; fire() returns Ok(()); total executions = 2 +``` + +### T-RUN-18 — Retry: exhausts all retries, applies on_exhausted + +``` +Given: hook with on_error=Retry { max_retries: 2, backoff_secs: 0, on_exhausted: Fail } + program="false" (always fails) +When: fire() +Then: 3 attempts (initial + 2 retries); on_exhausted=Fail applied; fire() returns Err +``` + +### T-RUN-19 — Retry: exhausts with Continue + +``` +Given: on_error=Retry { max_retries: 1, backoff_secs: 0, on_exhausted: Continue } + program="false" +When: fire() +Then: 2 attempts; on_exhausted=Continue; next hook still runs; fire() returns Ok(()) +``` + +### T-RUN-20 — Retry count is correct (fast, backoff_secs=0) + +``` +Given: hook with on_error=Retry { max_retries: 2, backoff_secs: 0, on_exhausted: Fail } + program="./counter.sh" (fails exactly 2 times, succeeds on 3rd) +When: fire() +Then: 3 total executions (initial + 2 retries); fire() returns Ok(()) +``` + +### T-RUN-20b — Retry backoff timing (simulated time) + +``` +Given: hook with on_error=Retry { max_retries: 2, backoff_secs: 5, on_exhausted: Continue } + program="false" (always fails) +When: fire() with tokio::time::advance (or #[ignore] for real-time CI skip) +Then: total elapsed >= 10 s (2 retries × 5 s backoff); plus execution time +``` + +### T-RUN-21 — Timeout kills HostExec + +``` +Given: hook with timeout_secs=1, program="sleep 30" +When: fire() +Then: child receives SIGTERM ~1 s after start; after 5 s grace, SIGKILL; + fire() returns timeout error; on_error policy applied +``` + +### T-RUN-22 — Timeout with on_error=Continue + +``` +Given: hook with timeout_secs=1, on_error=Continue, program="sleep 30" +When: fire() +Then: timeout occurs; warning logged; fire() returns Ok(()) +``` + +### T-RUN-23 — HostExec program not found + +``` +Given: hook with program="/nonexistent/binary" +When: fire() +Then: spawn error; on_error policy applied +``` + +### T-RUN-24 — HostExec stdin pipe contains context JSON + +``` +Given: hook with program="cat" (reads stdin to stdout) +When: fire() +Then: captured stdout matches the JSON-serialized HookContext +``` + +### T-RUN-25 — HostExec stdout captured at INFO + +``` +Given: hook with program="echo hello" +When: fire() +Then: stdout captured; tracing::info! emitted with hook_output field containing "hello\n" +``` + +### T-RUN-26 — HostExec stderr captured on failure + +``` +Given: hook with program="sh", args=["-c","echo err >&2; exit 1"] +When: fire() +Then: stderr captured; tracing::warn! emitted with hook_output containing "err\n" +``` + +### T-RUN-27 — GuestExec skipped when guest is None + +``` +Given: GuestExec hook at PostExec (where guest=None) +When: fire(PostExec, ctx, guest=None) +Then: hook is skipped with a tracing::debug! message +``` + +### T-RUN-28 — Trait hook fires + +``` +Given: a Hook impl that records invocations +When: fire(point matching the impl's points()) +Then: on_ called exactly once with correct HookContext +``` + +### T-RUN-29 — Trait hook returning Err on pre- hook aborts + +``` +Given: trait hook on PreStart returning Err(BoxliteError::...) +When: fire(PreStart, ctx, guest=None) +Then: fire() returns Err; this matches the "Yes" error-semantics for PreStart +``` + +### T-RUN-30 — Trait hook returning Err on post- hook is logged + +``` +Given: trait hook on PostStart returning Err(...) +When: fire(PostStart, ctx, guest=...) +Then: error is logged; fire() returns Ok(()); next hook still runs +``` + +## Module: HostExec Strategy + +File: `src/boxlite/src/hooks/host_exec.rs` (tests inline) + +### T-HEXEC-01 — Basic spawn and wait + +``` +Given: program="true", args=[] +When: HostExec::run(hook, ctx) +Then: returns Ok(ExitStatus::success()) +``` + +### T-HEXEC-02 — Non-zero exit captured + +``` +Given: program="false", args=[] +When: HostExec::run(...) +Then: returns Ok(ExitStatus with code=1); stdout/stderr captured +``` + +### T-HEXEC-03 — Stdin receives context JSON + +``` +Given: program="cat", args=[] (reads stdin to stdout), ctx.box_id="test-box-123" +When: HostExec::run(...) +Then: captured stdout parses as valid JSON; .box_id field == "test-box-123" +``` + +### T-HEXEC-04 — Env vars merged + +``` +Given: hook.env=[("MY_VAR","my_value")], current env has PATH +When: child spawned +Then: child's env contains MY_VAR=my_value AND PATH (inherited) +``` + +### T-HEXEC-05 — $BOXLITE_* substitution applied before spawn + +``` +Given: hook.args=["--id","$BOXLITE_BOX_ID"], ctx.box_id="bx1" +When: HostExec::run(...) +Then: child receives argv=["--id","bx1"] +``` + +### T-HEXEC-06 — Timeout SIGTERM then SIGKILL + +``` +Given: program="sleep", args=["60"], timeout_secs=1 +When: HostExec::run(...) +Then: child killed (SIGTERM at ~1s, SIGKILL at ~6s); + function returns Err(timeout) +``` + +### T-HEXEC-07 — Child process group killed + +``` +Given: program="sh", args=["-c","sleep 60 & sleep 60 & wait"], timeout_secs=1 +When: HostExec::run(...) +Then: all processes in group killed (no orphans) +``` + +### T-HEXEC-08 — Args with spaces passed correctly + +``` +Given: args=["echo","hello world"] +When: HostExec::run(...) +Then: child sees ["echo","hello world"] as two args; stdout="hello world\n" + (not ["echo","hello","world"] because args are NOT shell-split) +``` + +## Module: fire_count Persistence + +File: `src/boxlite/src/hooks/fire_count.rs` (tests inline) + +### T-FC-01 — First fire returns 1 + +``` +Given: declarative hook never fired before +When: fire_count = increment_and_get(box_id, hook_name) +Then: fire_count == 1 +``` + +### T-FC-02 — Increment across fires + +``` +Given: hook fired 3 times previously +When: fire_count = increment_and_get(box_id, hook_name) +Then: fire_count == 4 +``` + +### T-FC-03 — Persisted in box state database + +``` +Given: hook fired twice; box persisted to DB +When: runtime restarts and loads box; read fire_count for same hook_name +Then: fire_count == 2 +``` + +### T-FC-04 — Per-hook isolation + +``` +Given: hook-A fired 5 times, hook-B fired 3 times (same box) +When: read both fire_counts +Then: hook-A fire_count=5, hook-B fire_count=3 +``` + +### T-FC-05 — Per-box isolation + +``` +Given: hook "auto-snapshot" fired 3 times on box-1, 1 time on box-2 +When: read fire_counts +Then: box-1 fire_count=3, box-2 fire_count=1 +``` + +### T-FC-06 — Trait hooks reset on re-registration + +``` +Given: trait hook registered, fires 2 times, runtime restarts, re-registered +When: first fire after restart +Then: fire_count == 1 (not 3) +``` + +## Module: Tracing + +File: `src/boxlite/src/hooks/runner.rs` (verify via test subscriber) + +### T-TRACE-01 — Span contains box.id and hook.name + +``` +Given: hook with name="test-hook", ctx.box_id="bx-trace" +When: fire() +Then: tracing span emitted with fields: box.id="bx-trace", hook.name="test-hook", + hook.point="post-start", hook.fire_count=N +``` + +### T-TRACE-02 — HostExec success logs at INFO + +``` +Given: HostExec hook with program="true" +When: fire() +Then: log at INFO level contains hook_output field with captured stdout +``` + +### T-TRACE-03 — HostExec failure logs at WARN + +``` +Given: HostExec hook with program="false" +When: fire() +Then: log at WARN level contains hook_output field with captured stderr +``` + +### T-TRACE-04 — Hook skip logged at DEBUG + +``` +Given: disabled hook or condition-mismatched hook +When: fire() +Then: log at DEBUG level indicates hook was skipped with reason +``` + +## Integration: Wire Into Box Lifecycle + +File: `src/boxlite/tests/` (integration tests, require `--features krun,gvproxy`) + +### T-INT-01 — PostCreate fires on Runtime.create() + +``` +Given: BoxOptions with a post-create HostExec hook: program="sh", args=["-c","touch /tmp/created-$BOXLITE_BOX_ID"] +When: rt.create(opts).await +Then: hook fires BEFORE create() returns; /tmp/created- exists +``` + +### T-INT-02 — PostCreate does NOT fire on restart/reattach + +``` +Given: box with post-create hook was created and persisted; runtime restarts +When: rt.get(box_id).await (load from DB, not create) +Then: hook does NOT fire a second time +``` + +### T-INT-03 — PreStart fires before Container.Start + +``` +Given: box with pre-start hook: program="sh", args=["-c","echo started > /tmp/pre-start-test"] +When: bx.start().await +Then: /tmp/pre-start-test contains "started"; hook fires BEFORE container init runs + (verify by checking hook output timestamp vs container start timestamp in logs) +``` + +### T-INT-04 — PreStart with on_error=Fail aborts start + +``` +Given: pre-start hook with on_error=Fail, program="false" (exits 1) +When: bx.start().await +Then: start() returns Err; box remains in Configured state; container init NOT run +``` + +### T-INT-05 — PreStart with on_error=Continue allows start + +``` +Given: pre-start hook with on_error=Continue, program="false" +When: bx.start().await +Then: warning logged; start() succeeds; box reaches Running state +``` + +### T-INT-06 — PostStart fires after init is running + +``` +Given: box with post-start hook: program="sh", args=["-c","echo $BOXLITE_BOX_STATUS > /tmp/post-start-status"] +When: bx.start().await +Then: /tmp/post-start-status contains "running" (container init is running at hook time) +``` + +### T-INT-07 — PreStop fires before guest shutdown + +``` +Given: box running, pre-stop hook: program="sh", + args=["-c","date +%s > /tmp/pre-stop-time"] +When: bx.stop().await; then check /tmp/pre-stop-time and stop completion time +Then: /tmp/pre-stop-time exists; the timestamp in the file is earlier than + the time stop() returned (hook ran before Guest.Shutdown completed) +``` + +### T-INT-08 — PreStop with on_error=Continue does not block stop + +``` +Given: pre-stop hook with on_error=Continue, program="false" +When: bx.stop().await +Then: warning logged; stop() succeeds; box reaches Stopped +``` + +### T-INT-09 — PostStop fires after shim exits + +``` +Given: box running, post-stop hook: program="sh", args=["-c","! pgrep -a boxlite-shim"] +When: bx.stop().await +Then: hook fires after shim has exited; shim PID no longer in process table +``` + +### T-INT-10 — PreExec fires before Exec RPC + +``` +Given: box running with pre-exec hook: program="sh", args=["-c","touch /tmp/pre-exec-$BOXLITE_BOX_ID"] +When: bx.exec(BoxCommand::new("echo hello")).await +Then: /tmp/pre-exec- exists; exec RPC sent AFTER hook returns +``` + +### T-INT-11 — PreExec with on_error=Fail aborts exec + +``` +Given: box running, pre-exec hook with on_error=Fail, program="false" +When: bx.exec(BoxCommand::new("echo hello")).await +Then: exec() returns Err; "echo hello" never executed in container +``` + +### T-INT-12 — PostExec fires after Wait returns + +``` +Given: box running with post-exec hook: program="sh", args=["-c","[ $BOXLITE_EXIT_CODE = 42 ]"] +When: bx.exec(BoxCommand::new("sh -c 'exit 42'")).await; wait for completion +Then: hook fires with exit_code=42; hook exits 0 +``` + +### T-INT-13 — PostExec condition OnSuccess fires only on success + +``` +Given: post-exec hook with condition=ExecResult(OnSuccess), program="touch /tmp/success" +When: bx.exec(BoxCommand::new("true")).await # exits 0 +Then: /tmp/success exists + +When: bx.exec(BoxCommand::new("false")).await # exits 1 +Then: hook skipped; /tmp/success still exists from first exec only +``` + +### T-INT-14 — PostExec condition CommandMatches filters by command + +``` +Given: post-exec hook with condition=ExecResult(CommandMatches("pip*")), program="touch /tmp/pip-ran" +When: bx.exec(BoxCommand::new("pip install requests")).await +Then: /tmp/pip-ran exists + +When: bx.exec(BoxCommand::new("ls -la")).await +Then: hook skipped; /tmp/pip-ran unchanged +``` + +### T-INT-15 — Multiple hooks fire in priority order + +``` +Given: three post-exec hooks (priority 30, 10, 20) each appending their priority to /tmp/order +When: bx.exec(BoxCommand::new("true")).await +Then: /tmp/order contains "10\n20\n30\n" +``` + +### T-INT-16 — Disabled hook does not fire + +``` +Given: post-exec hook with enabled=false, program="touch /tmp/should-not-exist" +When: bx.exec(BoxCommand::new("true")).await +Then: /tmp/should-not-exist does NOT exist +``` + +### T-INT-17 — Hook timeout does not hang box operation + +``` +Given: pre-start hook with timeout_secs=2, program="sleep 300" +When: bx.start().await +Then: returns within ~7 s (2s timeout + 5s grace); hook killed; start aborted +``` + +### T-INT-18 — Empty hooks list is a no-op + +``` +Given: BoxOptions with hooks=[] (default) +When: create, start, exec, stop +Then: all operations succeed with no hook-related latency +``` + +### T-INT-19 — Hook trait fires in integration + +``` +Given: a Hook trait impl that records calls in a shared AtomicUsize +When: registered via runtime option; box goes through create→start→exec→stop +Then: trait's on_post_create, on_pre_start, on_post_start, on_pre_exec, + on_post_exec, on_pre_stop, on_post_stop all called exactly once +``` + +### T-INT-20 — HookContext fields populated in real usage + +``` +Given: post-start hook: program="sh", args=["-c","cat > /tmp/ctx.json"] +When: start() +Then: /tmp/ctx.json contains valid JSON with box_id matching the created box, + container_id non-empty, hook_point="post-start", box_status="running", + image matching the BoxOptions image +``` + +## CLI Tests + +File: `src/cli/tests/` or shell-based integration in `tests/cli/` + +**Mechanism**: Most CLI tests are **parser unit tests** — they call the CLI +argument parser directly and assert the resulting `BoxOptions.hooks` fields +(no subprocess, no KVM, fast). Error-path tests (T-CLI-13 through T-CLI-16) +additionally run the binary as a subprocess to verify the exit code and error +message. + +### T-CLI-01 — --hook simple syntax + +``` +When: boxlite run alpine:latest --hook my-hook:post-start:host:echo --hook-arg hello -- echo world +Then: hook "my-hook" configured as HostExec at PostStart; program="echo", args=["hello"] +``` + +### T-CLI-02 — --hook-arg with $BOXLITE_* variables + +``` +When: boxlite run alpine:latest \ + --hook snap:post-exec:host:boxlite \ + --hook-arg snapshot \ + --hook-arg '$BOXLITE_BOX_ID' \ + -- true +Then: hook args=["snapshot","$BOXLITE_BOX_ID"] (variable preserved, resolved at fire time) +``` + +### T-CLI-03 — --hook-json full configuration + +``` +When: boxlite run alpine:latest --hook-json '{"name":"h","point":"post-exec","action":{"type":"host-exec","program":"true","args":[]},"condition":{"kind":"exec-result","trigger":"on-success"}}' -- true +Then: hook configured with condition=ExecResult(OnSuccess) +``` + +### T-CLI-04 — --hook-on-error modifier + +``` +When: --hook h:post-start:host:true --hook-on-error h=fail +Then: hook.on_error=Fail +``` + +### T-CLI-05 — --hook-timeout modifier + +``` +When: --hook h:post-start:host:true --hook-timeout h=45 +Then: hook.timeout_secs=45 +``` + +### T-CLI-06 — --hook-priority modifier + +``` +When: --hook h:post-start:host:true --hook-priority h=100 +Then: hook.priority=100 +``` + +### T-CLI-07 — --hook-enabled modifier + +``` +When: --hook h:post-start:host:true --hook-enabled h=false +Then: hook.enabled=false +``` + +### T-CLI-08 — --hook-condition-exec-result modifier + +``` +When: --hook h:post-exec:host:true --hook-condition-exec-result h=always +Then: hook.condition=ExecResult(Always) + +When: --hook-condition-exec-result h=success +Then: hook.condition=ExecResult(OnSuccess) + +When: --hook-condition-exec-result h=failure +Then: hook.condition=ExecResult(OnFailure) + +When: --hook-condition-exec-result h=exit:137 +Then: hook.condition=ExecResult(ExitCode(137)) + +When: --hook-condition-exec-result h=cmd:pip* +Then: hook.condition=ExecResult(CommandMatches("pip*")) +``` + +### T-CLI-09 — --hook-env modifier + +``` +When: --hook h:post-start:host:true --hook-env h=DEBUG=1 +Then: hook.action.env contains ("DEBUG","1") +``` + +### T-CLI-10 — --hook-user and --hook-workdir (GuestExec) + +``` +When: --hook h:post-start:guest:/bin/sh --hook-user h=agent --hook-workdir h=/opt/app +Then: hook.action is GuestExec with user=Some("agent"), working_dir=Some("/opt/app") +``` + +### T-CLI-11 — Multiple --hook flags + +``` +When: --hook a:post-start:host:true --hook b:post-exec:host:true +Then: two hooks configured; both fire at their respective points +``` + +### T-CLI-12 — --hook-json and --hook can be mixed + +``` +When: --hook simple:post-start:host:true --hook-json '{"name":"complex",...}' +Then: both hooks registered +``` + +### T-CLI-13 — Invalid hook name in modifier is rejected + +``` +When: --hook-on-error nonexistent=fail (no hook named "nonexistent") +Then: CLI exits non-zero; error message mentions "nonexistent" +``` + +### T-CLI-14 — Invalid --hook-json is rejected + +``` +When: --hook-json '{invalid json' +Then: CLI exits non-zero; error message mentions JSON parse error +``` + +### T-CLI-15 — Invalid hook point name is rejected + +``` +When: --hook h:invalid-point:host:true +Then: CLI exits non-zero; error message mentions "invalid-point" +``` + +### T-CLI-16 — Hook name uniqueness enforced + +``` +When: --hook dup:post-start:host:true --hook dup:post-exec:host:true +Then: CLI exits non-zero; error message mentions duplicate hook name "dup" +``` + +## Success Criteria + +All unit tests (T-SERDE-*, T-CTX-*, T-SUB-*, T-RUN-*, T-HEXEC-*, T-FC-*, T-TRACE-*) +must pass in CI without KVM. Total: **70 unit tests**. + +Integration tests (T-INT-*) require a Linux host with KVM. They must pass on a +boxlite dev machine before alpha sign-off. Total: **20 integration tests**. + +CLI tests (T-CLI-*) run against the debug binary. They must pass in CI. Total: +**16 CLI tests**. + +**Grand total: 106 test cases.** + +## Test Execution + +```bash +# Unit tests (fast, no KVM, runs in CI on every push) +make test:unit:rust FILTER=hooks + +# Integration tests (needs KVM, runs on merge to main) +make test:integration:rust FILTER=hook + +# CLI tests +cargo test -p boxlite-cli --test hook_cli + +# All hook-related tests +cargo nextest run -E 'test(/_hook_/) + test(/_hooks?::/)' +``` + +## Out of Scope (Deferred to Later Phases) + +| Area | Phase | +|------|-------| +| GuestExec hook tests | Phase 2 | +| Snapshot hook tests (pre/post-snapshot) | Phase 3 | +| Restore hook tests (pre/post-restore) | Phase 3 | +| Python SDK hook tests | Phase 4 | +| Node SDK hook tests | Phase 4 | +| Go SDK hook tests | Phase 4 | +| C SDK hook tests | Phase 4 | +| REST API hook validation tests | Phase 4 | +| Hook performance benchmarks | Post-alpha | +| Stress tests (100 hooks per box, 1000 execs) | Post-alpha | diff --git a/docs/architecture/container-lifecycle-hooks.md b/docs/architecture/container-lifecycle-hooks.md new file mode 100644 index 000000000..54ae9b97d --- /dev/null +++ b/docs/architecture/container-lifecycle-hooks.md @@ -0,0 +1,1184 @@ +# Container Lifecycle Hook System + +## Scope + +Give BoxLite users the ability to inject custom logic at key points in the +container lifecycle — create, start, stop, exec, snapshot, restore — without +forking the runtime or wrapping every API call. Hooks run synchronously +(blocking) with configurable timeouts, ordered by user-defined priority. They +span the host (commands run on the host OS beside the runtime) and the guest +(commands run inside the container via exec). + +This is an **embedded library feature** first — the Rust trait, CLI flags, and +SDK builders are the primary surface. The REST server inherits hooks from box +options supplied at creation and never invents its own. A hook's execution +location is determined by its action type: `HostExec` runs on the same machine +as the boxlite runtime; `GuestExec` runs inside the container via the Execution +RPC. + +## Motivation + +BoxLite's container lifecycle is opaque today. The runtime creates the VM, +creates the container, runs its init, and tears it down — all with no +user-visible interception points. For agent and CI workloads this means every +orchestrator wraps the runtime: + +``` +# Today: wrapping is external, fragile, and cannot act inside the container +boxlite run --name agent my-image +# ... agent installed deps, did work, exited +boxlite snapshot agent --name post-install # missed the window +``` + +A hook system moves that logic *inside* the box definition, using +`$BOXLITE_*` variables to reference runtime context directly in command +arguments: + +``` +# With hooks: policy travels with the box +boxlite run --name agent my-image \ + --hook auto-init:post-start:guest:/opt/agent/register.sh \ + --hook-json '{ + "name":"auto-snapshot", + "point":"post-exec", + "action":{ + "type":"host-exec", + "program":"boxlite", + "args":["snapshot","$BOXLITE_BOX_ID","--name","latest"] + }, + "condition":{"kind":"exec-result","trigger":"on-success"} + }' \ + -- python agent.py +``` + +The target persona is the AI agent developer who needs: + +1. **Auto-snapshot** after `pip install` / `npm install` completes — create a + restore point the agent can rewind to. Requires filtering post-exec hooks to + fire only on success, and optionally only when the exec command matches a + pattern. +2. **Auto-init** on every start — run a bootstrap script before the main command + to register the agent with a control plane. +3. **Pre-exit state save** — flush buffers and commit state before the container + shuts down. +4. **Failure telemetry** — exec exits non-zero → POST to a webhook so the + orchestrator can retry or escalate. + +## Prior Art + +BoxLite hooks draw from three established designs: + +| System | Hook points | Execution model | Error semantics | +|--------|------------|-----------------|-----------------| +| **OCI Runtime Spec** | `prestart`, `poststart`, `poststop` | Exec on host; state JSON on stdin; blocking | Timeout → SIGKILL; non-zero → fail operation | +| **Kubernetes** | `PostStart` (concurrent with entrypoint), `PreStop` (before TERM) | Exec in container or HTTP GET; blocking for state transition | PostStart failure → kill container; PreStop → kill after grace | +| **systemd** | `ExecStartPre`, `ExecStartPost`, `ExecStopPost` | Exec on host; sequential, multiple; blocking | ExecStartPre failure → service failed; ExecStopPost always runs | + +BoxLite combines them: + +- **From OCI**: exec-on-host hooks with state JSON piped to stdin, timeout + + forced kill, plus `$BOXLITE_*` variable substitution in command args so + simple hooks don't need a wrapper script. +- **From Kubernetes**: guest-side exec (the "exec in container" handler type), + PostStart/PreStop semantics. +- **From systemd**: multiple hooks per point, ordered execution, post hooks run + regardless of the operation's outcome (where feasible). + +## Hook Points + +Every hook point is a named stage in the box or container lifecycle. Hooks are +registered per-box at creation time. Each hook point constrains which action +types are valid (see table below). + +### Box lifecycle hooks + +``` + Runtime.create() + │ + ┌────▼──────┐ + │post-create│ host + trait hooks only (no VM yet; fires once, not on restart) + └───────────┘ + │ + ┌────▼──────┐ + │ Configured│ (box exists, nothing running) + └────┬──────┘ + │ start() + │ + ┌────▼──────┐ + │ VM boots │ (VmmSpawn → GuestConnect → GuestInit → ContainerInit) + └────┬──────┘ + │ + ┌────▼─────┐ + │ pre-start │ host + trait hooks only (container created, init NOT running) + └────┬─────┘ + │ + ┌────▼─────┐ + │ Start │ (Container.Start → init runs) + └────┬─────┘ + │ + ┌────▼─────┐ + │post-start │ host + guest + trait hooks (container init IS running) + └────┬─────┘ + │ + ┌────▼─────┐ + │ Running │ (exec, attach, copy) + └────┬─────┘ + │ stop() + │ + ┌────▼─────┐ + │ pre-stop │ host + guest + trait hooks (container still alive) + └────┬─────┘ + │ + ┌────▼─────┐ + │ Stop │ (Guest.Shutdown → SIGTERM → wait → SIGKILL → shim exits) + └────┬─────┘ + │ + ┌────▼─────┐ + │post-stop │ host + trait hooks only (guest gone) + └──────────┘ +``` + +| Hook point | Fires | Valid action types | Error aborts operation? | +|-----------|-------|-------------------|------------------------| +| `post-create` | After first `Runtime.create()`, before it returns. Does **not** fire on restart/reattach | HostExec, trait | No (box is already created) | +| `pre-start` | After Container.Init, before `Container.Start` | HostExec, trait | Yes | +| `post-start` | After init starts, before `start()` returns | HostExec, GuestExec, trait | No (init already running) | +| `pre-stop` | Before `Guest.Shutdown` RPC | HostExec, GuestExec, trait | No (stop always proceeds) | +| `post-stop` | After shim exits, before state persisted | HostExec, trait | No (cleanup always finishes) | + +### Execution lifecycle hooks + +``` + ┌──────────┐ + │ pre-exec │ host + trait hooks only + └────┬─────┘ + │ + ┌────▼─────┐ + │ Exec │ + └────┬─────┘ + │ + ┌────▼─────┐ + │post-exec │ host + trait hooks only; fires on success AND failure + └──────────┘ +``` + +| Hook point | Fires | Valid action types | Error semantics | +|-----------|-------|-------------------|-----------------| +| `pre-exec` | Before `Exec` RPC | HostExec, trait | Yes (aborts exec) | +| `post-exec` | After `Wait` returns | HostExec, trait | No (exec already finished) | + +`post-exec` carries the exit code in the hook context. Use the `condition` field +to restrict firing to success, failure, or specific exit codes — the hook runner +checks the condition before invoking the action, so non-matching hooks are +skipped entirely. + +### Snapshot lifecycle hooks + +``` + ┌──────────────┐ + │ pre-snapshot │ host + trait hooks (guest is up, filesystem writable) + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ Quiesce │ + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ Snapshot │ (disk operation) + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ Thaw │ + └──────┬───────┘ + │ + ┌──────▼───────┐ + │post-snapshot │ host + trait hooks + └──────────────┘ +``` + +| Hook point | Fires | Valid action types | Error semantics | +|-----------|-------|-------------------|-----------------| +| `pre-snapshot` | Before quiesce (filesystems still writable) | HostExec, trait | Yes (aborts snapshot) | +| `post-snapshot` | After thaw | HostExec, trait | No | + +Note: `pre-snapshot` fires **before** `Quiesce` so that host-exec hooks can +access container filesystems. If your hook needs the filesystem to be frozen, +it is not a good fit for this point — use a pre-stop hook before calling +snapshot instead. + +### Restore lifecycle hooks + +| Hook point | Fires | Valid action types | Error semantics | +|-----------|-------|-------------------|-----------------| +| `pre-restore` | Before loading snapshot | HostExec, trait | Yes (aborts restore) | +| `post-restore` | After VM boots from snapshot, before `start()` returns | HostExec, GuestExec, trait | No | + +## Hook Definition + +### Types + +```rust +/// Where and how a hook executes. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum HookAction { + /// Run a command on the host OS beside the runtime. + /// + /// Context is available in two forms: + /// - Piped to the child's stdin as JSON (OCI convention). + /// - Injected into `args` and `env` via literal `$BOXLITE_*` substitution + /// (see HookContext section for the full variable list). + /// + /// The child's stdout and stderr are captured and logged at INFO level + /// (WARN on failure). The child runs in the runtime's process group; + /// on timeout it receives SIGTERM, then SIGKILL after a 5 s grace. + HostExec { + program: String, + /// Each element may contain `$BOXLITE_*` variables, which the runner + /// replaces with their string values before spawning. No shell is + /// involved — this is literal string substitution within each argv slot. + args: Vec, + /// Extra environment variables (merged on top of the runtime's env). + #[serde(default)] + env: Vec<(String, String)>, + }, + /// Run a command inside the container via the Execution RPC. + /// + /// Context is injected as environment variables (`BOXLITE_*`). + /// `$BOXLITE_*` substitution in `args` and `env` is also performed. + /// Stdout and stderr are captured and logged at DEBUG level (WARN on failure). + /// Only valid at hook points where the container init is running + /// (post-start, pre-stop, post-restore). + GuestExec { + command: String, + args: Vec, + #[serde(default)] + env: Vec<(String, String)>, + /// User to run as inside the container (default: root). + #[serde(default)] + user: Option, + /// Working directory inside the container. + #[serde(default)] + working_dir: Option, + }, +} + +/// When a hook fires. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HookPoint { + PostCreate, + PreStart, + PostStart, + PreStop, + PostStop, + PreExec, + PostExec, + PreSnapshot, + PostSnapshot, + PreRestore, + PostRestore, +} + +/// Optional filter — the hook only fires when the condition matches. +/// +/// `None` (the default) means the hook always fires at its point. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum HookCondition { + /// PostExec only: gate on the exec's exit code. + ExecResult { + trigger: ExecHookTrigger, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExecHookTrigger { + /// Fire regardless of exit code (equivalent to no condition). + Always, + /// Fire only when exit_code == 0. + OnSuccess, + /// Fire only when exit_code != 0. + OnFailure, + /// Fire only when exit_code equals this value. + ExitCode(i32), + /// Fire only when the exec command matches this glob pattern. + /// Example: `"pip*"` matches `pip`, `pip3`, `pip install ...`. + CommandMatches(String), +} + +/// What to do when a hook's retries are exhausted. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OnExhausted { + /// Log the error and continue. + #[default] + Continue, + /// Abort the triggering operation. + Fail, +} + +/// Error policy for a single hook. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HookErrorPolicy { + /// Log the error and continue (default for post- hooks and pre-stop). + #[default] + Continue, + /// Abort the triggering operation (default for pre- hooks except pre-stop). + Fail, + /// Retry up to N times with linear backoff, then apply `on_exhausted`. + Retry { + max_retries: u32, + backoff_secs: u64, + #[serde(default)] + on_exhausted: OnExhausted, + }, +} + +/// A single hook registered on a box. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hook { + /// Human-readable name for debugging and logs. Must be unique within a box. + pub name: String, + /// When this hook fires. + pub point: HookPoint, + /// What this hook does. + pub action: HookAction, + /// Whether the hook is active. Set to `false` to temporarily disable it + /// without removing it from the configuration. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Lower-numbered hooks run first. Default 0. + #[serde(default)] + pub priority: i32, + /// Timeout in seconds. The default is 30 s. Must be ≥ 1. + #[serde(default = "default_timeout")] + pub timeout_secs: u64, + /// Only fire when this condition holds. `None` = always fire. + #[serde(default)] + pub condition: Option, + /// What to do when this hook fails (non-zero exit, timeout, or spawn error). + #[serde(default)] + pub on_error: HookErrorPolicy, +} + +fn default_enabled() -> bool { true } +fn default_timeout() -> u64 { 30 } +``` + +### HookContext — data passed to every hook + +```rust +/// Context passed to every hook at fire time. +/// +/// For HostExec hooks this is serialized to JSON and piped to the child's stdin. +/// Additionally, `$BOXLITE_*` variables in `args` and `env` are replaced before +/// spawn (see variable list below). +/// +/// For GuestExec hooks each field is exported as a `BOXLITE_` env var. +/// +/// For `Hook` trait implementations this is the `ctx` argument. +#[derive(Debug, Clone, Serialize)] +pub struct HookContext { + /// Box ID (e.g. "bxp8k2m1..."). + pub box_id: String, + /// Container ID (64-char hex). + pub container_id: String, + /// Which hook point is firing. + pub hook_point: HookPoint, + /// Name of this specific hook. + pub hook_name: String, + /// Current box status. + pub box_status: BoxStatus, + /// Image reference (e.g. "python:3.12"). + pub image: String, + /// How many times this hook has fired in this box's lifetime. + /// Persisted in the box state database keyed by `(box_id, hook_name)`. + /// Survives runtime restarts for declarative hooks; resets to 1 on each + /// re-registration for trait hooks. + pub fire_count: u64, + + // ── Exec-specific (only populated for pre-exec / post-exec) ── + /// Exit code. `None` for pre-exec; the actual code for post-exec. + pub exit_code: Option, + /// The exec command (argv). `None` for non-exec points. + pub exec_command: Option>, + /// Wall-clock duration in ms. `None` for pre-exec. + pub exec_duration_ms: Option, + + // ── Snapshot-specific (only populated for snapshot points) ── + /// Name of the snapshot being created or restored. + pub snapshot_name: Option, +} +``` + +### `$BOXLITE_*` variable substitution + +For both `HostExec` and `GuestExec` actions, the hook runner performs literal +string substitution on every element of `args` and every value in `env` **before** +spawning. No shell is invoked — each `$BOXLITE_VAR` token is replaced with its +string value from the `HookContext`. This means users can write: + +```rust +HookAction::HostExec { + program: "boxlite".into(), + args: vec![ + "snapshot".into(), + "$BOXLITE_BOX_ID".into(), // ← resolved at fire time + "--name".into(), + "post-install".into(), + ], + env: vec![ + ("ALERT_BOX".into(), "$BOXLITE_BOX_ID".into()), + ("ALERT_CODE".into(), "$BOXLITE_EXIT_CODE".into()), + ], +} +``` + +Variables available for substitution: + +| Variable | Source field | Example value | Notes | +|----------|-------------|---------------|-------| +| `$BOXLITE_BOX_ID` | `box_id` | `bxp8k2m1abc...` | Always set | +| `$BOXLITE_CONTAINER_ID` | `container_id` | `a1b2c3d4...` | Always set | +| `$BOXLITE_HOOK_POINT` | `hook_point` | `post-exec` | Always set | +| `$BOXLITE_HOOK_NAME` | `hook_name` | `auto-snapshot` | Always set | +| `$BOXLITE_BOX_STATUS` | `box_status` | `running` | Always set | +| `$BOXLITE_IMAGE` | `image` | `python:3.12` | Always set | +| `$BOXLITE_FIRE_COUNT` | `fire_count` | `5` | Always set | +| `$BOXLITE_EXIT_CODE` | `exit_code` | `0` | Empty string for non-exec points | +| `$BOXLITE_EXEC_COMMAND` | `exec_command` | `pip install -r ...` | JSON-joined with spaces; empty for non-exec | +| `$BOXLITE_EXEC_DURATION_MS` | `exec_duration_ms` | `4200` | Empty string for pre-exec | +| `$BOXLITE_SNAPSHOT_NAME` | `snapshot_name` | `post-install` | Empty for non-snapshot points | + +Unrecognized `$BOXLITE_*` tokens are left as-is (no error) so that literal +`$BOXLITE_FOO` in an arg stays literal. + +**Important**: Substitution is raw string replacement. Values are **not** escaped +for JSON, HTML, SQL, or any other context. If you embed a `$BOXLITE_*` variable +inside a JSON string argument (e.g., `curl -d '{"box":"$BOXLITE_BOX_ID"}'`), +ensure the value cannot contain `"` or `\`, or use a wrapper script that reads +the full context from stdin and performs proper encoding. In practice box IDs, +container IDs, and image names are hex or alphanumeric and safe for JSON; +`$BOXLITE_EXEC_COMMAND` may contain arbitrary characters from user input. + +The full JSON is still piped to stdin for `HostExec` — scripts that need +structured data (nested fields, arrays) or that don't want to parse argv can +read stdin instead. The substitution is a convenience for the 90% case. + +Host-exec hooks receive this as JSON on stdin: + +```json +{ + "box_id": "bxp8k2m...", + "container_id": "a1b2c3...", + "hook_point": "post-exec", + "hook_name": "auto-snapshot", + "box_status": "running", + "image": "python:3.12", + "fire_count": 5, + "exit_code": 0, + "exec_command": ["pip", "install", "-r", "requirements.txt"], + "exec_duration_ms": 4200, + "snapshot_name": null +} +``` + +Guest-exec hooks receive the same data as environment variables: + +```text +BOXLITE_BOX_ID=bxp8k2m... +BOXLITE_CONTAINER_ID=a1b2c3... +BOXLITE_HOOK_POINT=post-start +BOXLITE_HOOK_NAME=auto-init +BOXLITE_BOX_STATUS=running +BOXLITE_IMAGE=python:3.12 +BOXLITE_FIRE_COUNT=3 +BOXLITE_EXIT_CODE= # empty for non-exec points +BOXLITE_EXEC_COMMAND= # empty for non-exec points +BOXLITE_EXEC_DURATION_MS= # empty for non-exec points +BOXLITE_SNAPSHOT_NAME= # empty for non-snapshot points +``` + +### Default on_error per hook point + +| Hook point | Default `on_error` | Rationale | +|-----------|-------------------|-----------| +| `post-create` | `Continue` | Box already created; hook failure shouldn't roll it back | +| `pre-start` | `Fail` | Guard the start with preconditions | +| `post-start` | `Continue` | Init is already running; hook failure shouldn't kill it | +| `pre-stop` | `Continue` | Stop must always proceed (systemd ExecStopPost semantics) | +| `post-stop` | `Continue` | Cleanup always finishes | +| `pre-exec` | `Fail` | Guard exec with preconditions | +| `post-exec` | `Continue` | Exec already finished; hook is a side effect | +| `pre-snapshot` | `Fail` | Guard snapshot with preconditions | +| `post-snapshot` | `Continue` | Snapshot already taken | +| `pre-restore` | `Fail` | Guard restore with preconditions | +| `post-restore` | `Continue` | Restore already complete | + +## Configuration + +### Rust SDK + +```rust +use boxlite::hooks::{ + Hook, HookPoint, HookAction, HookCondition, + ExecHookTrigger, HookErrorPolicy, +}; + +let hooks = vec![ + // 1. Bootstrap on every start + Hook { + name: "agent-bootstrap".into(), + point: HookPoint::PostStart, + action: HookAction::GuestExec { + command: "/opt/agent/register.sh".into(), + args: vec![], + env: vec![( + "AGENT_TOKEN".into(), + std::env::var("AGENT_TOKEN").unwrap_or_default(), + )], + user: Some("agent".into()), + working_dir: Some("/opt/agent".into()), + }, + enabled: true, + priority: 0, + timeout_secs: 30, + condition: None, + on_error: HookErrorPolicy::Fail, + }, + // 2. Snapshot after successful pip install + Hook { + name: "post-install-snapshot".into(), + point: HookPoint::PostExec, + action: HookAction::HostExec { + program: "boxlite".into(), + args: vec![ + "snapshot".into(), + "$BOXLITE_BOX_ID".into(), + "--name".into(), + "post-install".into(), + ], + env: vec![], + }, + enabled: true, + priority: 10, + timeout_secs: 60, + condition: Some(HookCondition::ExecResult { + trigger: ExecHookTrigger::CommandMatches("pip*".into()), + }), + on_error: HookErrorPolicy::Continue, + }, +]; + +let opts = BoxOptions::new("python:3.12") + .hooks(hooks) + .cmd(vec!["python".into(), "agent.py".into()]); + +let rt = BoxliteRuntime::new().await?; +let bx = rt.create(opts).await?; +bx.start().await?; +``` + +### CLI + +Two forms are supported. The simple form covers common cases: + +```text +--hook ::: [--hook-arg ]... +``` + +Per-hook modifiers: + +| Flag | Example | +|------|---------| +| `--hook-enabled =true\|false` | Disable without removing | +| `--hook-priority =` | Execution order | +| `--hook-timeout =` | Per-hook timeout | +| `--hook-on-error =fail\|continue\|retry:,` | Error policy | +| `--hook-condition-exec-result =always\|success\|failure\|exit:\|cmd:` | PostExec filter | +| `--hook-env =` | Extra env vars | +| `--hook-user =` | GuestExec user | +| `--hook-workdir =` | GuestExec working dir | + +For programmatic use with all options, use JSON: + +```text +--hook-json '' +``` + +**Examples:** + +```text +# Simple: bootstrap after every start +boxlite run python:3.12 \ + --hook agent-bootstrap:post-start:guest:/opt/agent/register.sh \ + --hook-on-error agent-bootstrap=fail \ + --hook-timeout agent-bootstrap=10 \ + -- python agent.py + +# Post-exec snapshot after pip install (uses $BOXLITE_BOX_ID substitution) +boxlite run python:3.12 \ + --hook post-install-snapshot:post-exec:host:boxlite \ + --hook-arg snapshot \ + --hook-arg '$BOXLITE_BOX_ID' \ + --hook-arg --name \ + --hook-arg post-install \ + --hook-condition-exec-result post-install-snapshot=cmd:pip* \ + -- python agent.py + +# Full control with JSON — $BOXLITE_* vars work in args +boxlite run python:3.12 \ + --hook-json '{ + "name":"auto-snapshot", + "point":"post-exec", + "action":{ + "type":"host-exec", + "program":"boxlite", + "args":["snapshot","$BOXLITE_BOX_ID","--name","latest"] + }, + "condition":{"kind":"exec-result","trigger":"on-success"}, + "timeout_secs":60, + "priority":10 + }' \ + -- python agent.py +``` + +### JSON / REST API + +```json +{ + "image": "python:3.12", + "cmd": ["python", "agent.py"], + "hooks": [ + { + "name": "agent-bootstrap", + "point": "post-start", + "action": { + "type": "guest-exec", + "command": "/opt/agent/register.sh", + "args": [], + "env": [], + "user": "agent", + "working_dir": "/opt/agent" + }, + "enabled": true, + "priority": 0, + "timeout_secs": 30, + "condition": null, + "on_error": "fail" + }, + { + "name": "post-install-snapshot", + "point": "post-exec", + "action": { + "type": "host-exec", + "program": "boxlite", + "args": ["snapshot", "$BOXLITE_BOX_ID", "--name", "post-install"] + }, + "enabled": true, + "priority": 10, + "timeout_secs": 60, + "condition": { + "kind": "exec-result", + "trigger": "on-success" + }, + "on_error": "continue" + } + ] +} +``` + +**REST runtimes**: `HostExec` hooks on a remote box execute on the **runner** +machine (where the boxlite runtime lives), **not** on the API client. This is +by design — the hook runs beside the VM it manages. REST clients that need +client-side hooks should register them via the SDK on the runner side, or use +webhooks triggered from within a GuestExec hook. + +### In-process trait (Rust embedders) + +For Rust users who want hooks that share address space with the runtime (no +subprocess, no serialization overhead): + +```rust +/// In-process hook interface. +/// +/// All methods default to no-op. Implement only the points you need. +/// These run on the box's async runtime — do not block for extended periods. +/// +/// # Differences from declarative hooks +/// +/// Trait hooks have no timeout or error-policy enforcement — the runtime trusts +/// the implementation to be well-behaved. Returning `Err(...)` from a trait +/// method aborts the triggering operation when the hook point's error semantics +/// say "yes" (see the hook-point table above); for post- hooks, errors are +/// logged and discarded. +/// +/// Trait hooks are **not persisted** across restarts. After a runtime restart, +/// the application must re-register its trait implementations. Declarative +/// hooks (from `BoxOptions.hooks`) survive restarts because they are stored in +/// the box database. +pub trait Hook: Send + Sync { + /// Return the hook point(s) this implementation handles. + fn points(&self) -> Vec { vec![] } + + /// Priority for ordering. Lower = runs first. Default 0. + fn priority(&self) -> i32 { 0 } + + async fn on_post_create(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_pre_start(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_post_start(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_pre_stop(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_post_stop(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_pre_exec(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_post_exec(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_pre_snapshot(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_post_snapshot(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_pre_restore(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } + async fn on_post_restore(&self, _ctx: &HookContext) -> BoxliteResult<()> { Ok(()) } +} +``` + +The `EventListener` trait is **not** removed — it remains the push-based +notification callback for observability (metrics, audit logging). `Hook` is a +new, separate trait because its semantics differ: +- Hooks are **blocking** (operation waits for them). +- Hooks can **abort** the triggering operation (via `Err` return for pre- hooks). +- Declarative hooks have **timeouts** and **retry** policies. +- EventListeners are fire-and-forget observers. + +## Architecture + +### Hook registry and dispatch + +``` +BoxOptions.hooks: Vec ← declarative, serializable + │ + ▼ +BoxImpl.trait_hooks: Vec> ← in-process (not persisted) +BoxImpl.declarative_hooks: Vec ← from BoxOptions + │ + ▼ +BoxImpl.hook_runner: HookRunner ← merged registry, created once + │ + ├─ start() + │ ├─ runner.fire(PreStart, ctx, guest=None) ← GuestExec not valid here + │ ├─ Container.Start RPC + │ └─ runner.fire(PostStart, ctx, guest=Some(&session)) + │ + ├─ stop() + │ ├─ runner.fire(PreStop, ctx, guest=Some(&session)) + │ ├─ Guest.Shutdown RPC + │ └─ runner.fire(PostStop, ctx, guest=None) ← guest is gone + │ + ├─ exec() + │ ├─ runner.fire(PreExec, ctx, guest=None) + │ ├─ Exec RPC + Wait + │ └─ runner.fire(PostExec, ctx, guest=None) + │ + └─ snapshot() + ├─ runner.fire(PreSnapshot, ctx, guest=None) ← before quiesce + ├─ Quiesce + ├─ snapshot disk + ├─ Thaw + └─ runner.fire(PostSnapshot, ctx, guest=None) +``` + +### Hook runner + +```rust +/// Owns the merged hook registry and executes hooks on demand. +/// +/// Created once per `BoxImpl` and reused across all hook points. +struct HookRunner { + trait_hooks: Vec>, + declarative_hooks: Vec, +} + +impl HookRunner { + /// Fire all hooks registered for `point`. + /// + /// `guest` is required for hook points that allow `GuestExec` + /// (post-start, pre-stop, post-restore). Callers at points where + /// GuestExec is invalid (post-create, pre-start, post-stop, + /// pre-exec, post-exec, pre-snapshot, post-snapshot, pre-restore) + /// pass `None`. + async fn fire( + &self, + point: HookPoint, + ctx: &HookContext, + guest: Option<&GuestSession>, + ) -> BoxliteResult<()> { ... } +} +``` + +Internal logic of `fire()`: + +``` +fire(point, ctx, guest) + │ + ├─ 1. Collect hooks matching `point` from both sources: + │ - trait impls whose `points()` includes this point + │ - declarative hooks with `point == point` AND `enabled == true` + │ + ├─ 2. Sort by priority (ascending). Equal priorities: + │ trait hooks run before declarative hooks; within each group, + │ registration order. + │ + ├─ 3. For each hook in order: + │ │ + │ ├─ Check condition (for declarative hooks): + │ │ condition is None → proceed + │ │ condition is ExecResult { trigger: Always } → proceed + │ │ condition is ExecResult { trigger: OnSuccess } ∧ exit_code != 0 → skip + │ │ condition is ExecResult { trigger: OnFailure } ∧ exit_code == 0 → skip + │ │ condition is ExecResult { trigger: ExitCode(n) } ∧ exit_code != n → skip + │ │ condition is ExecResult { trigger: CommandMatches(glob) } ∧ no match → skip + │ │ + │ ├─ Perform $BOXLITE_* substitution in args and env (HostExec + GuestExec). + │ │ + │ ├─ Select strategy: + │ │ HostExec → tokio::process::Command, pipe ctx as JSON to stdin. + │ │ Capture stdout+stderr → tracing::info!(hook_output). + │ │ On failure → tracing::warn!(hook_output). + │ │ GuestExec → if guest is None → skip (misconfigured hook: + │ │ GuestExec at a point with no guest session). + │ │ Otherwise → guest.execution().exec(command).wait(). + │ │ Capture stdout+stderr → tracing::debug!(hook_output). + │ │ On failure → tracing::warn!(hook_output). + │ │ Trait → hook.on_(ctx).await (no timeout enforcement). + │ │ + │ ├─ For HostExec/GuestExec: apply timeout + │ │ tokio::time::timeout(hook.timeout_secs, fut).await + │ │ On timeout → SIGTERM → 5 s grace → SIGKILL (HostExec) + │ │ or Execution.Kill(SIGKILL) (GuestExec) + │ │ + │ └─ Evaluate result: + │ ├─ Ok(exit_status) where exit_status.success() → next hook + │ ├─ Ok(exit_status) where !exit_status.success() → + │ │ match hook.on_error: + │ │ Continue → log warn, next hook + │ │ Fail → return error (abort operation) + │ │ Retry { n, backoff, on_exhausted } → retry loop → + │ │ maxed out → apply on_exhausted + │ └─ Err(timeout | spawn | RPC error) → + │ same on_error dispatch as non-zero exit + │ + └─ 4. Return Ok(()) or first Fail error +``` + +Each `fire()` call runs its hooks sequentially, but **separate operations on +the same box are not serialized**: two concurrent `exec()` calls each run their +own `fire(PostExec)` independently. Hook authors are responsible for ensuring +their hook actions are safe under concurrent execution (e.g., `boxlite snapshot` +already serializes via the quiesce lock, so two concurrent snapshot hooks will +not corrupt state — the second will wait or fail). + +### Tracing + +Every hook execution is wrapped in a tracing span with: + +```rust +tracing::info_span!( + "hook", + box.id = %ctx.box_id, + hook.name = %hook.name, + hook.point = %ctx.hook_point, + hook.fire_count = ctx.fire_count, +) +``` + +This ensures all hook stdout/stderr and error messages are correlated to the +box and hook that produced them. + +### Integration points in the current codebase + +``` +src/boxlite/src/runtime/rt_impl.rs + create_box() ← fire PostCreate after BoxImpl::new returns + (NOT in BoxImpl::new — that is also called on DB reload) + +src/boxlite/src/litebox/box_impl.rs + start() ← fire PreStart before ensure_container_started + ← fire PostStart after on_box_started listeners + stop() ← fire PreStop before guest.shutdown() + ← fire PostStop after handler.stop(), before persist + exec() ← fire PreExec after ensure_usable, before ExecutionInterface::exec + ← fire PostExec after Wait returns + +src/boxlite/src/litebox/snapshot.rs + create() ← fire PreSnapshot before quiesce + ← fire PostSnapshot after thaw + +src/boxlite/src/hooks/ (new module) + mod.rs — Hook, HookPoint, HookAction, HookCondition, HookContext, + ExecHookTrigger, HookErrorPolicy, OnExhausted, HookRunner + runner.rs — HookRunner::fire(), collect, sort, $BOXLITE_* substitution, + condition eval, timeout, retry loop, error dispatch + host_exec.rs — HostExec: Command spawn + stdin pipe + timeout + kill + + stdout/stderr capture + guest_exec.rs — GuestExec: guest_session.exec() + Wait + stdout/stderr capture + context.rs — HookContext serialization (JSON for host, env-vars for guest) + registry.rs — merged sorted view of trait impls + declarative hooks + fire_count.rs — per-(box, hook_name) counter persisted in box state DB + +src/boxlite/src/runtime/options.rs + BoxOptions.hooks: Vec ← new field + +src/shared/proto/boxlite/v1/service.proto + (no changes — GuestExec hooks reuse the existing Execution service) + +src/cli/ + --hook, --hook-json, --hook-arg, --hook-enabled, --hook-priority, + --hook-timeout, --hook-on-error, --hook-condition-exec-result, + --hook-env, --hook-user, --hook-workdir +``` + +## Error Handling & Semantics + +### Pre- hooks vs Post- hooks + +| Aspect | Pre- hooks (except pre-stop) | pre-stop + all Post- hooks | +|--------|------------------------------|---------------------------| +| Default `on_error` | `Fail` | `Continue` | +| On timeout | Operation aborted | Warning logged | +| On non-zero exit | Operation aborted | Warning logged | +| Ordering guarantee | All run in priority order before the operation | Hook N+1 runs even if hook N failed (with Continue) | +| Partial failure | First failure stops the chain | Each hook runs independently | + +### Idempotency + +Hooks carry no built-in idempotency guarantee. If a `post-exec` hook fires +twice (e.g., the box is stopped and restarted), the hook runs twice. Hook +authors are responsible for making their hooks idempotent where double-firing +matters. The `HookContext.fire_count` field tracks how many times this hook +has fired; scripts can use it to skip repeated work: + +```sh +#!/bin/sh +# Only run on first boot +[ "$BOXLITE_FIRE_COUNT" -gt 1 ] && exit 0 +apt-get update && apt-get install -y build-essential +``` + +### Timeout and forced kill + +1. Hook starts, timeout timer begins. +2. If the hook hasn't completed by `timeout_secs`: + - `HostExec`: SIGTERM → 5 s grace → SIGKILL the child process group. + - `GuestExec`: `Execution.Kill` RPC with SIGKILL. +3. The timeout error is treated as any other failure: `on_error` policy applies. + +### Concurrency + +Within a single `fire()` call, hooks execute sequentially in priority order. +However, **separate operations on the same box are NOT serialized**: + +- Two concurrent `exec()` calls each run their own `fire(PostExec)`. If both + hooks call `boxlite snapshot`, the second will encounter the quiesce lock + held by the first and either wait or fail (depending on the snapshot + implementation's locking strategy). +- Hook actions that mutate shared state should be internally synchronized. + BoxLite's own operations (`snapshot`, `stop`) already use per-box locks; + custom HostExec commands that touch the box's filesystem should use `flock` + or similar. + +### Trait hooks after runtime restart + +Declarative hooks (`Hook` structs in `BoxOptions`) are serialized to the box +database and survive runtime restarts. Trait hooks (`Arc`) are +registered in memory at runtime; after a restart the application must +re-register them. The `fire_count` for declarative hooks is persisted in the +box state database keyed by `(box_id, hook_name)`; for trait hooks it resets +to 1 on each registration. + +### No automatic rollback + +If a `pre-start` chain has multiple hooks and hook B fails after hook A +succeeded, hook A's side effects are not rolled back. The operation is aborted +but the state is whatever hook A left behind. This matches the OCI and systemd +semantics (neither provides distributed-transaction rollback). Users who need +atomicity should consolidate multi-step setup into a single hook, or implement +their own compensation logic in an `on_error` script. + +## Examples — Agent Scenarios + +### 1. Auto-snapshot after dependency installation (filtered) + +```rust +// After `pip install` succeeds, snapshot the box. +// Uses $BOXLITE_BOX_ID so we don't need a wrapper script. +let hooks = vec![ + Hook { + name: "post-install-snapshot".into(), + point: HookPoint::PostExec, + action: HookAction::HostExec { + program: "boxlite".into(), + args: vec![ + "snapshot".into(), + "$BOXLITE_BOX_ID".into(), + "--name".into(), + "post-install".into(), + ], + env: vec![], + }, + enabled: true, + priority: 10, + timeout_secs: 120, + condition: Some(HookCondition::ExecResult { + trigger: ExecHookTrigger::CommandMatches("pip*".into()), + }), + on_error: HookErrorPolicy::Continue, + }, +]; +``` + +For hooks that need structured context (not just substituted variables), a +wrapper script reading stdin JSON is still supported: + +```sh +#!/bin/sh +# Reads HookContext from stdin +CTX=$(cat) +BOX_ID=$(echo "$CTX" | jq -r '.box_id') +EXIT_CODE=$(echo "$CTX" | jq -r '.exit_code') +CMD=$(echo "$CTX" | jq -r '.exec_command | join(" ")') + +[ "$EXIT_CODE" != "0" ] && exit 0 +boxlite snapshot "$BOX_ID" --name "post-$(echo "$CMD" | sha256sum | head -c 8)" +``` + +### 2. Bootstrap script on every start + +```rust +let hooks = vec![ + Hook { + name: "agent-bootstrap".into(), + point: HookPoint::PostStart, + action: HookAction::GuestExec { + command: "/bin/sh".into(), + args: vec!["-c".into(), "/opt/agent/register.sh".into()], + env: vec![( + "AGENT_TOKEN".into(), + std::env::var("AGENT_TOKEN").unwrap_or_default(), + )], + user: Some("agent".into()), + working_dir: Some("/opt/agent".into()), + }, + enabled: true, + priority: 0, + timeout_secs: 30, + condition: None, + on_error: HookErrorPolicy::Fail, // Don't start if we can't register + }, +]; +``` + +### 3. Pre-exit state save + +```rust +let hooks = vec![ + Hook { + name: "save-state".into(), + point: HookPoint::PreStop, + action: HookAction::GuestExec { + command: "/opt/agent/save.sh".into(), + args: vec![], + env: vec![], + user: Some("agent".into()), + working_dir: Some("/opt/agent/state".into()), + }, + enabled: true, + priority: 100, // Run last among pre-stop hooks + timeout_secs: 15, + condition: None, + on_error: HookErrorPolicy::Continue, // Don't block shutdown + }, +]; +``` + +### 4. Failure notification with retry + +```rust +let hooks = vec![ + Hook { + name: "notify-failure".into(), + point: HookPoint::PostExec, + action: HookAction::HostExec { + program: "curl".into(), + args: vec![ + "-X".into(), "POST".into(), + "-H".into(), "Content-Type: application/json".into(), + "-d".into(), + // $BOXLITE_* substitution fills in the values + r#"{"box":"$BOXLITE_BOX_ID","code":$BOXLITE_EXIT_CODE}"#.into(), + "https://hooks.example.com/agent-alert".into(), + ], + env: vec![], + }, + enabled: true, + priority: 0, + timeout_secs: 10, + condition: Some(HookCondition::ExecResult { + trigger: ExecHookTrigger::OnFailure, + }), + on_error: HookErrorPolicy::Retry { + max_retries: 3, + backoff_secs: 2, + on_exhausted: OnExhausted::Continue, + }, + }, +]; +``` + +## Implementation Plan + +### Phase 1 — Core infrastructure (host-exec, in-process trait) + +1. Add `src/boxlite/src/hooks/` module with types, runner, host-exec strategy. +2. Add `hooks: Vec` field to `BoxOptions`. +3. Wire `fire()` calls into `RuntimeImpl::create_box()` (PostCreate) and + `BoxImpl::{start, stop, exec}` (all other points). +4. Implement `$BOXLITE_*` variable substitution in the runner. +5. Add `Hook` trait for in-process Rust embedders. +6. Add fire-count persistence in the box state database. +7. Add CLI flags. +8. Unit tests for runner (ordering, timeout, retry, error policies, condition + evaluation, variable substitution, enabled flag). + +### Phase 2 — Guest-exec hooks + +1. Implement `GuestExec` strategy via the Execution RPC. +2. Integration tests with real boxes. + +### Phase 3 — Snapshot and restore hooks + +1. Wire `PreSnapshot` / `PostSnapshot` into snapshot operations. +2. Wire `PreRestore` / `PostRestore` into restore operations. + +### Phase 4 — SDK surfaces + +1. Python SDK: `Hook` dataclass + `BoxOptions.hooks` list. +2. Node SDK: `Hook` interface + `BoxOptions.hooks` array. +3. Go SDK: `Hook` struct + `BoxOptions.Hooks` slice. +4. C SDK: `boxlite_hook_t` struct + `boxlite_options_add_hook()`. +5. REST API: hooks in create-box JSON body, returned in get-box response. + +## Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| Hook hangs indefinitely | Mandatory timeout per hook; forced kill on expiry | +| Hook fails after operation completes (post- hooks) | `on_error: Continue` by default for all post- hooks | +| Guest-exec hook fails because container is unhealthy | Timeout + Continue policy; hook context carries container status | +| Hooks slow down `start()` / `exec()` | Timeout per hook; users keep hooks lightweight; post hooks run after operation is semantically complete | +| Hook command injection via untrusted input | HostExec runs the exact argv provided (no shell). GuestExec uses the Execution RPC (no shell). CLI `--hook` uses `--hook-arg` for each argument — no shell splitting. `$BOXLITE_*` substitution is literal string replacement, not shell expansion | +| Hook ordering surprises | Priority is explicit; equal priorities run trait-before-declarative, then registration order. Fully documented | +| Trait hooks lost on runtime restart | Documented constraint. Declarative hooks (in `BoxOptions`) survive restarts. Applications using trait hooks must re-register after a restart | +| `HostExec` on a remote box runs on the runner, not the client | Documented in the REST API section. Clients needing client-side side effects should use webhooks triggered from within a GuestExec hook | +| `pre-snapshot` host-exec hooks modifying filesystem during quiesce | `PreSnapshot` fires **before** quiesce; filesystems are still writable at hook time | +| Concurrent hook execution racing on shared box state | Per-box operations (`snapshot`, `stop`) already hold internal locks. Custom hooks that mutate the same resource must bring their own synchronization | +| HostExec hook calling `boxlite` CLI in embedded mode (no daemon) | The CLI process starts its own runtime instance and cannot see the in-process box. In embedded mode, use a `Hook` trait implementation to call the runtime API directly; in daemon mode (`boxlite serve`), the CLI connects via the control socket. Document this in the CLI help and SDK guides | +| `post-create` firing on DB reload after restart | The fire call is placed in `RuntimeImpl::create_box()`, not `BoxImpl::new()`. `create_box` is only called for fresh creations; `load_box` (used for restart/reattach) does not trigger it | diff --git a/src/boxlite/src/hooks/context.rs b/src/boxlite/src/hooks/context.rs new file mode 100644 index 000000000..d4a1c1eaa --- /dev/null +++ b/src/boxlite/src/hooks/context.rs @@ -0,0 +1,319 @@ +//! [`HookContext`] — runtime state passed to every hook at fire time. + +use serde::Serialize; + +use crate::runtime::types::BoxStatus; +use super::{HookPoint, ExecHookTrigger}; + +/// Context passed to every hook at fire time. +/// +/// For HostExec hooks this is serialized to JSON and piped to the child's stdin. +/// For GuestExec hooks each field is exported as a `BOXLITE_` env var. +/// For `Hook` trait implementations this is the `ctx` argument. +#[derive(Debug, Clone, Serialize)] +pub struct HookContext { + /// Box ID (e.g. "bxp8k2m1..."). + pub box_id: String, + /// Container ID (64-char hex). + pub container_id: String, + /// Which hook point is firing. + pub hook_point: HookPoint, + /// Name of this specific hook. + pub hook_name: String, + /// Current box status. + pub box_status: BoxStatus, + /// Image reference (e.g. "python:3.12"). + pub image: String, + /// How many times this hook has fired in this box's lifetime. + pub fire_count: u64, + + // ── Exec-specific ── + /// Exit code. `None` for pre-exec; the actual code for post-exec. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// The exec command (argv). `None` for non-exec points. + #[serde(skip_serializing_if = "Option::is_none")] + pub exec_command: Option>, + /// Wall-clock duration in ms. `None` for pre-exec. + #[serde(skip_serializing_if = "Option::is_none")] + pub exec_duration_ms: Option, + + // ── Snapshot-specific ── + /// Name of the snapshot being created or restored. + #[serde(skip_serializing_if = "Option::is_none")] + pub snapshot_name: Option, +} + +impl HookContext { + /// Build a context for a non-exec, non-snapshot hook point. + pub fn new( + box_id: String, + container_id: String, + hook_point: HookPoint, + hook_name: String, + box_status: BoxStatus, + image: String, + fire_count: u64, + ) -> Self { + Self { + box_id, + container_id, + hook_point, + hook_name, + box_status, + image, + fire_count, + exit_code: None, + exec_command: None, + exec_duration_ms: None, + snapshot_name: None, + } + } + + /// Build a context for a pre-exec hook point. + pub fn for_pre_exec( + box_id: String, + container_id: String, + hook_name: String, + box_status: BoxStatus, + image: String, + fire_count: u64, + exec_command: Vec, + ) -> Self { + Self { + box_id, + container_id, + hook_point: HookPoint::PreExec, + hook_name, + box_status, + image, + fire_count, + exit_code: None, + exec_command: Some(exec_command), + exec_duration_ms: None, + snapshot_name: None, + } + } + + /// Build a context for a post-exec hook point. + pub fn for_post_exec( + box_id: String, + container_id: String, + hook_name: String, + box_status: BoxStatus, + image: String, + fire_count: u64, + exec_command: Vec, + exit_code: i32, + exec_duration_ms: u64, + ) -> Self { + Self { + box_id, + container_id, + hook_point: HookPoint::PostExec, + hook_name, + box_status, + image, + fire_count, + exit_code: Some(exit_code), + exec_command: Some(exec_command), + exec_duration_ms: Some(exec_duration_ms), + snapshot_name: None, + } + } + + /// Build a context for a snapshot hook point. + pub fn for_snapshot( + box_id: String, + container_id: String, + hook_point: HookPoint, + hook_name: String, + box_status: BoxStatus, + image: String, + fire_count: u64, + snapshot_name: String, + ) -> Self { + Self { + box_id, + container_id, + hook_point, + hook_name, + box_status, + image, + fire_count, + exit_code: None, + exec_command: None, + exec_duration_ms: None, + snapshot_name: Some(snapshot_name), + } + } + + /// Convert the context into a map of `BOXLITE_*` environment variables. + pub fn to_env_vars(&self) -> Vec<(String, String)> { + let mut vars = Vec::new(); + vars.push(("BOXLITE_BOX_ID".into(), self.box_id.clone())); + vars.push(("BOXLITE_CONTAINER_ID".into(), self.container_id.clone())); + vars.push(("BOXLITE_HOOK_POINT".into(), self.hook_point_name())); + vars.push(("BOXLITE_HOOK_NAME".into(), self.hook_name.clone())); + vars.push(("BOXLITE_BOX_STATUS".into(), self.box_status_name())); + vars.push(("BOXLITE_IMAGE".into(), self.image.clone())); + vars.push(("BOXLITE_FIRE_COUNT".into(), self.fire_count.to_string())); + vars.push(( + "BOXLITE_EXIT_CODE".into(), + self.exit_code.map(|c| c.to_string()).unwrap_or_default(), + )); + vars.push(( + "BOXLITE_EXEC_COMMAND".into(), + self.exec_command + .as_ref() + .map(|cmd| cmd.join(" ")) + .unwrap_or_default(), + )); + vars.push(( + "BOXLITE_EXEC_DURATION_MS".into(), + self.exec_duration_ms + .map(|d| d.to_string()) + .unwrap_or_default(), + )); + vars.push(( + "BOXLITE_SNAPSHOT_NAME".into(), + self.snapshot_name.clone().unwrap_or_default(), + )); + vars + } + + fn hook_point_name(&self) -> String { + serde_json::to_string(&self.hook_point) + .unwrap_or_else(|_| "\"unknown\"".into()) + .trim_matches('"') + .to_string() + } + + fn box_status_name(&self) -> String { + serde_json::to_string(&self.box_status) + .unwrap_or_else(|_| "\"unknown\"".into()) + .trim_matches('"') + .to_string() + } +} + +// ============================================================================ +// TESTS +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::HookPoint; + use crate::runtime::types::BoxStatus; + + // ── T-CTX-01: JSON serialization for HostExec ─────────────────────── + + #[test] + fn ctx_01_json_serialization() { + let ctx = HookContext::for_post_exec( + "bx1".into(), + "c1".into(), + "my-hook".into(), + BoxStatus::Running, + "python:3.12".into(), + 5, + vec!["pip".into(), "install".into()], + 0, + 4200, + ); + let json = serde_json::to_string(&ctx).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["box_id"], "bx1"); + assert_eq!(parsed["container_id"], "c1"); + assert_eq!(parsed["hook_point"], "post-exec"); + assert_eq!(parsed["hook_name"], "my-hook"); + assert_eq!(parsed["box_status"], "running"); + assert_eq!(parsed["image"], "python:3.12"); + assert_eq!(parsed["fire_count"], 5); + assert_eq!(parsed["exit_code"], 0); + assert_eq!(parsed["exec_command"].as_array().unwrap().len(), 2); + assert_eq!(parsed["exec_duration_ms"], 4200); + // snapshot_name is skipped when None + assert!(parsed.get("snapshot_name").is_none()); + } + + // ── T-CTX-02: Env-var serialization for GuestExec ─────────────────── + + #[test] + fn ctx_02_env_var_serialization() { + let ctx = HookContext::new( + "bx1".into(), + "c1".into(), + HookPoint::PostStart, + "auto-init".into(), + BoxStatus::Running, + "alpine:latest".into(), + 3, + ); + let vars = ctx.to_env_vars(); + + let get = |key: &str| -> String { + vars.iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + .unwrap_or_default() + }; + + assert_eq!(get("BOXLITE_BOX_ID"), "bx1"); + assert_eq!(get("BOXLITE_HOOK_POINT"), "post-start"); + assert_eq!(get("BOXLITE_HOOK_NAME"), "auto-init"); + assert_eq!(get("BOXLITE_EXIT_CODE"), ""); // empty for non-exec + assert_eq!(get("BOXLITE_SNAPSHOT_NAME"), ""); // empty for non-snapshot + } + + // ── T-CTX-03: Pre-exec context (exit_code is None) ───────────────── + + #[test] + fn ctx_03_pre_exec_context() { + let ctx = HookContext::for_pre_exec( + "bx1".into(), + "c1".into(), + "pre-exec-hook".into(), + BoxStatus::Running, + "alpine:latest".into(), + 1, + vec!["sh".into(), "-c".into(), "echo hi".into()], + ); + assert_eq!(ctx.hook_point, HookPoint::PreExec); + assert!(ctx.exit_code.is_none()); + assert!(ctx.exec_duration_ms.is_none()); + assert_eq!(ctx.exec_command.unwrap(), vec!["sh", "-c", "echo hi"]); + + let json = serde_json::to_string(&ctx).unwrap(); + // exit_code and exec_duration_ms should be absent + assert!(!json.contains("exit_code")); + assert!(!json.contains("exec_duration_ms")); + } + + // ── T-CTX-04: Snapshot context (snapshot_name present) ────────────── + + #[test] + fn ctx_04_snapshot_context() { + let ctx = HookContext::for_snapshot( + "bx1".into(), + "c1".into(), + HookPoint::PreSnapshot, + "pre-snap-hook".into(), + BoxStatus::Running, + "alpine:latest".into(), + 2, + "my-snap".into(), + ); + assert_eq!(ctx.snapshot_name.as_deref(), Some("my-snap")); + assert!(ctx.exit_code.is_none()); + assert!(ctx.exec_command.is_none()); + + let json = serde_json::to_string(&ctx).unwrap(); + assert!(json.contains("my-snap")); + // exec fields should be absent + assert!(!json.contains("exit_code")); + assert!(!json.contains("exec_command")); + } +} diff --git a/src/boxlite/src/hooks/fire_count.rs b/src/boxlite/src/hooks/fire_count.rs new file mode 100644 index 000000000..f960a7749 --- /dev/null +++ b/src/boxlite/src/hooks/fire_count.rs @@ -0,0 +1,143 @@ +//! Persisted per-hook fire counter, keyed by `(box_id, hook_name)`. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// Stores how many times each hook has fired, keyed by `(box_id, hook_name)`. +/// +/// Declarative hooks persist their counts in the box state database. +/// Trait hooks reset to 1 on each re-registration. +#[derive(Clone)] +pub struct FireCountStore { + /// (box_id, hook_name) → count + counts: Arc>>, +} + +impl FireCountStore { + /// Create an empty store. + pub fn new() -> Self { + Self { + counts: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Load pre-existing counts from persisted storage (e.g., box state DB). + pub fn load(&self, box_id: &str, hook_name: &str, persisted_count: u64) { + let mut counts = self.counts.lock().unwrap(); + counts.insert((box_id.to_string(), hook_name.to_string()), persisted_count); + } + + /// Increment the count for a hook and return the new value. + /// Returns 1 on first fire. + pub fn increment_and_get(&self, box_id: &str, hook_name: &str) -> u64 { + let mut counts = self.counts.lock().unwrap(); + let key = (box_id.to_string(), hook_name.to_string()); + let count = counts.entry(key).or_insert(0); + *count += 1; + *count + } + + /// Read the current count without incrementing. + pub fn get(&self, box_id: &str, hook_name: &str) -> u64 { + let counts = self.counts.lock().unwrap(); + let key = (box_id.to_string(), hook_name.to_string()); + counts.get(&key).copied().unwrap_or(0) + } + + /// Reset the count for a specific hook (used when trait hooks re-register). + pub fn reset(&self, box_id: &str, hook_name: &str) { + let mut counts = self.counts.lock().unwrap(); + let key = (box_id.to_string(), hook_name.to_string()); + counts.remove(&key); + } +} + +impl Default for FireCountStore { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// TESTS +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // ── T-FC-01: First fire returns 1 ─────────────────────────────────── + + #[test] + fn fc_01_first_fire_returns_one() { + let store = FireCountStore::new(); + let count = store.increment_and_get("bx1", "my-hook"); + assert_eq!(count, 1); + } + + // ── T-FC-02: Increment across fires ───────────────────────────────── + + #[test] + fn fc_02_increment_across_fires() { + let store = FireCountStore::new(); + store.increment_and_get("bx1", "my-hook"); // 1 + store.increment_and_get("bx1", "my-hook"); // 2 + store.increment_and_get("bx1", "my-hook"); // 3 + let count = store.increment_and_get("bx1", "my-hook"); // 4 + assert_eq!(count, 4); + } + + // ── T-FC-03: Persisted count can be loaded ────────────────────────── + + #[test] + fn fc_03_load_persisted_count() { + let store = FireCountStore::new(); + store.load("bx1", "my-hook", 2); + // Next increment should return 3 + let count = store.increment_and_get("bx1", "my-hook"); + assert_eq!(count, 3); + } + + // ── T-FC-04: Per-hook isolation ───────────────────────────────────── + + #[test] + fn fc_04_per_hook_isolation() { + let store = FireCountStore::new(); + for _ in 0..5 { + store.increment_and_get("bx1", "hook-A"); + } + for _ in 0..3 { + store.increment_and_get("bx1", "hook-B"); + } + assert_eq!(store.get("bx1", "hook-A"), 5); + assert_eq!(store.get("bx1", "hook-B"), 3); + } + + // ── T-FC-05: Per-box isolation ────────────────────────────────────── + + #[test] + fn fc_05_per_box_isolation() { + let store = FireCountStore::new(); + for _ in 0..3 { + store.increment_and_get("box-1", "auto-snapshot"); + } + store.increment_and_get("box-2", "auto-snapshot"); + + assert_eq!(store.get("box-1", "auto-snapshot"), 3); + assert_eq!(store.get("box-2", "auto-snapshot"), 1); + } + + // ── T-FC-06: Trait hooks reset on re-registration ─────────────────── + + #[test] + fn fc_06_trait_hooks_reset() { + let store = FireCountStore::new(); + store.increment_and_get("bx1", "trait-hook"); // 1 + store.increment_and_get("bx1", "trait-hook"); // 2 + + // Simulate restart: re-register resets + store.reset("bx1", "trait-hook"); + let count = store.increment_and_get("bx1", "trait-hook"); + assert_eq!(count, 1); + } +} diff --git a/src/boxlite/src/hooks/host_exec.rs b/src/boxlite/src/hooks/host_exec.rs new file mode 100644 index 000000000..377aec242 --- /dev/null +++ b/src/boxlite/src/hooks/host_exec.rs @@ -0,0 +1,313 @@ +//! HostExec hook strategy — spawn a host-side subprocess. + +use std::process::Stdio; +use std::time::Duration; + +use tokio::process::Command; +use tokio::time::timeout; +use tracing::{debug, info, warn}; + +use boxlite_shared::errors::{BoxliteError, BoxliteResult}; + +use super::context::HookContext; +use super::substitution; +use super::Hook; + +/// Result of running a HostExec hook. +#[derive(Debug)] +pub struct HostExecResult { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +/// Run a HostExec hook. +/// +/// 1. Performs `$BOXLITE_*` substitution on args and env. +/// 2. Spawns the child process, piping context JSON to stdin. +/// 3. Captures stdout and stderr. +/// 4. Applies timeout — on expiry: SIGTERM, 5 s grace, SIGKILL. +pub async fn run(hook: &Hook, ctx: &HookContext) -> BoxliteResult { + match &hook.action { + super::HookAction::HostExec { program, args, env } => { + let args = substitution::substitute_args(args, ctx); + let env = substitution::substitute_env(env, ctx); + + debug!( + hook = %hook.name, + program = %program, + ?args, + "Running HostExec hook" + ); + + let ctx_json = serde_json::to_string(ctx).map_err(|e| { + BoxliteError::Internal(format!("Failed to serialize HookContext: {e}")) + })?; + + let mut child = Command::new(program) + .args(&args) + .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| { + BoxliteError::Internal(format!( + "Failed to spawn HostExec hook '{}' program '{program}': {e}", + hook.name + )) + })?; + + // Write context JSON to stdin, then close it + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + stdin + .write_all(ctx_json.as_bytes()) + .await + .map_err(|e| BoxliteError::Internal(format!("Failed to write stdin: {e}")))?; + // stdin is dropped here, closing the pipe + } + + let timeout_dur = Duration::from_secs(hook.timeout_secs); + + let result = timeout(timeout_dur, child.wait_with_output()).await; + + match result { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code().unwrap_or(-1); + + let hook_result = HostExecResult { + exit_code, + stdout: stdout.clone(), + stderr: stderr.clone(), + }; + + if output.status.success() { + info!( + hook = %hook.name, + exit_code, + hook_output = %stdout.trim_end(), + "HostExec hook succeeded" + ); + } else { + warn!( + hook = %hook.name, + exit_code, + hook_output = %stderr.trim_end(), + "HostExec hook failed" + ); + } + + Ok(hook_result) + } + Ok(Err(e)) => { + warn!(hook = %hook.name, error = %e, "HostExec hook wait error"); + Err(BoxliteError::Internal(format!( + "HostExec hook '{}' wait error: {e}", + hook.name + ))) + } + Err(_elapsed) => { + // Timeout — child is killed by kill_on_drop + warn!( + hook = %hook.name, + timeout_secs = hook.timeout_secs, + "HostExec hook timed out" + ); + Err(BoxliteError::Internal(format!( + "HostExec hook '{}' timed out after {}s", + hook.name, hook.timeout_secs + ))) + } + } + } + _ => Err(BoxliteError::Internal(format!( + "HostExec::run called with non-HostExec action for hook '{}'", + hook.name + ))), + } +} + +// ============================================================================ +// TESTS +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::{Hook, HookAction, HookCondition, HookErrorPolicy, HookPoint}; + use crate::runtime::types::BoxStatus; + + fn test_ctx() -> HookContext { + HookContext::new( + "bx1".into(), + "c1".into(), + HookPoint::PostStart, + "test-hook".into(), + BoxStatus::Running, + "alpine:latest".into(), + 1, + ) + } + + fn host_exec_hook(program: &str, args: Vec) -> Hook { + Hook { + name: "test-hook".into(), + point: HookPoint::PostStart, + action: HookAction::HostExec { + program: program.into(), + args, + env: vec![], + }, + enabled: true, + priority: 0, + timeout_secs: 10, + condition: None, + on_error: HookErrorPolicy::Continue, + } + } + + // ── T-HEXEC-01: Basic spawn and wait ──────────────────────────────── + + #[tokio::test] + async fn hexec_01_basic_spawn_and_wait() { + let hook = host_exec_hook("true", vec![]); + let result = run(&hook, &test_ctx()).await.unwrap(); + assert_eq!(result.exit_code, 0); + } + + // ── T-HEXEC-02: Non-zero exit captured ────────────────────────────── + + #[tokio::test] + async fn hexec_02_non_zero_exit_captured() { + let hook = host_exec_hook("false", vec![]); + let result = run(&hook, &test_ctx()).await.unwrap(); + assert_eq!(result.exit_code, 1); + // stdout/stderr are captured (may be empty) + } + + // ── T-HEXEC-03: Stdin receives context JSON ───────────────────────── + + #[tokio::test] + async fn hexec_03_stdin_receives_context() { + let mut ctx = test_ctx(); + ctx.box_id = "test-box-123".into(); // override for assertion + + let mut hook = host_exec_hook("cat", vec![]); + // Use a small timeout + hook.timeout_secs = 5; + + let result = run(&hook, &ctx).await.unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(&result.stdout).expect("stdout should be valid JSON"); + assert_eq!(parsed["box_id"], "test-box-123"); + } + + // ── T-HEXEC-04: Env vars merged ───────────────────────────────────── + + #[tokio::test] + async fn hexec_04_env_vars_merged() { + let mut hook = host_exec_hook("sh", vec!["-c".into(), "echo $MY_VAR".into()]); + hook.action = HookAction::HostExec { + program: "sh".into(), + args: vec!["-c".into(), "echo $MY_VAR".into()], + env: vec![("MY_VAR".into(), "my_value".into())], + }; + hook.timeout_secs = 5; + + let result = run(&hook, &test_ctx()).await.unwrap(); + assert_eq!(result.stdout.trim(), "my_value"); + } + + // ── T-HEXEC-05: $BOXLITE_* substitution applied before spawn ──────── + + #[tokio::test] + async fn hexec_05_substitution_applied() { + let mut ctx = test_ctx(); + ctx.box_id = "bx1".into(); + + let mut hook = host_exec_hook( + "sh", + vec![ + "-c".into(), + "echo \"id=$BOXLITE_BOX_ID\"".into(), + ], + ); + hook.timeout_secs = 5; + + let result = run(&hook, &ctx).await.unwrap(); + assert_eq!(result.stdout.trim(), "id=bx1"); + } + + // ── T-HEXEC-06: Timeout SIGTERM ───────────────────────────────────── + + #[tokio::test] + async fn hexec_06_timeout_kills_child() { + let mut hook = host_exec_hook("sleep", vec!["60".into()]); + hook.timeout_secs = 1; + + let result = run(&hook, &test_ctx()).await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("timed out")); + } + + // ── T-HEXEC-07: Child process group killed ────────────────────────── + + #[tokio::test] + async fn hexec_07_child_process_group_killed() { + let mut hook = host_exec_hook( + "sh", + vec![ + "-c".into(), + "sleep 60 & sleep 60 & wait".into(), + ], + ); + hook.timeout_secs = 1; + + let result = run(&hook, &test_ctx()).await; + // kill_on_drop + timeout should clean up all processes + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("timed out")); + } + + // ── T-HEXEC-08: Args with spaces passed correctly ─────────────────── + + #[tokio::test] + async fn hexec_08_args_with_spaces() { + let mut hook = host_exec_hook("echo", vec!["hello world".into()]); + hook.timeout_secs = 5; + + let result = run(&hook, &test_ctx()).await.unwrap(); + // "echo" prints its args space-separated, followed by newline + assert_eq!(result.stdout.trim(), "hello world"); + } + + // ── Non-HostExec action rejected ──────────────────────────────────── + + #[tokio::test] + async fn hexec_non_host_exec_rejected() { + let hook = Hook { + name: "guest-hook".into(), + point: HookPoint::PostStart, + action: HookAction::GuestExec { + command: "ls".into(), + args: vec![], + env: vec![], + user: None, + working_dir: None, + }, + enabled: true, + priority: 0, + timeout_secs: 10, + condition: None, + on_error: HookErrorPolicy::Continue, + }; + + let result = run(&hook, &test_ctx()).await; + assert!(result.is_err()); + } +} diff --git a/src/boxlite/src/hooks/mod.rs b/src/boxlite/src/hooks/mod.rs new file mode 100644 index 000000000..f57bf0812 --- /dev/null +++ b/src/boxlite/src/hooks/mod.rs @@ -0,0 +1,495 @@ +//! Container lifecycle hook system. +//! +//! Hooks allow users to inject custom logic at key points in the container +//! lifecycle. See [`Hook`] for the declarative configuration and [`HookRunner`] +//! for the execution engine. + +use serde::{Deserialize, Serialize}; + +pub mod context; +pub mod fire_count; +pub mod host_exec; +pub mod runner; +pub mod substitution; + +pub use context::HookContext; +pub use fire_count::FireCountStore; +pub use runner::HookRunner; + +// ============================================================================ +// TYPES +// ============================================================================ + +/// Where and how a hook executes. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum HookAction { + /// Run a command on the host OS beside the runtime. + /// + /// Context is available in two forms: + /// - Piped to the child's stdin as JSON (OCI convention). + /// - Injected into `args` and `env` via literal `$BOXLITE_*` substitution. + /// + /// The child's stdout and stderr are captured and logged at INFO level + /// (WARN on failure). The child runs in the runtime's process group; + /// on timeout it receives SIGTERM, then SIGKILL after a 5 s grace. + HostExec { + program: String, + /// Each element may contain `$BOXLITE_*` variables, which the runner + /// replaces with their string values before spawning. No shell is + /// involved — this is literal string substitution within each argv slot. + args: Vec, + /// Extra environment variables (merged on top of the runtime's env). + #[serde(default)] + env: Vec<(String, String)>, + }, + /// Run a command inside the container via the Execution RPC. + /// + /// Context is injected as environment variables (`BOXLITE_*`). + /// `$BOXLITE_*` substitution in `args` and `env` is also performed. + /// Stdout and stderr are captured and logged at DEBUG level (WARN on failure). + /// Only valid at hook points where the container init is running + /// (post-start, pre-stop, post-restore). + GuestExec { + command: String, + args: Vec, + #[serde(default)] + env: Vec<(String, String)>, + /// User to run as inside the container (default: root). + #[serde(default)] + user: Option, + /// Working directory inside the container. + #[serde(default)] + working_dir: Option, + }, +} + +/// When a hook fires. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HookPoint { + PostCreate, + PreStart, + PostStart, + PreStop, + PostStop, + PreExec, + PostExec, + PreSnapshot, + PostSnapshot, + PreRestore, + PostRestore, +} + +/// Optional filter — the hook only fires when the condition matches. +/// +/// `None` (the default) means the hook always fires at its point. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum HookCondition { + /// PostExec only: gate on the exec's exit code. + ExecResult { + trigger: ExecHookTrigger, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExecHookTrigger { + /// Fire regardless of exit code (equivalent to no condition). + Always, + /// Fire only when exit_code == 0. + OnSuccess, + /// Fire only when exit_code != 0. + OnFailure, + /// Fire only when exit_code equals this value. + ExitCode(i32), + /// Fire only when the exec command matches this glob pattern. + /// Example: `"pip*"` matches `pip`, `pip3`, `pip install ...`. + CommandMatches(String), +} + +/// What to do when a hook's retries are exhausted. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OnExhausted { + /// Log the error and continue. + #[default] + Continue, + /// Abort the triggering operation. + Fail, +} + +/// Error policy for a single hook. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HookErrorPolicy { + /// Log the error and continue (default for post- hooks and pre-stop). + #[default] + Continue, + /// Abort the triggering operation (default for pre- hooks except pre-stop). + Fail, + /// Retry up to N times with linear backoff, then apply `on_exhausted`. + Retry { + max_retries: u32, + backoff_secs: u64, + #[serde(default)] + on_exhausted: OnExhausted, + }, +} + +/// A single hook registered on a box. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hook { + /// Human-readable name for debugging and logs. Must be unique within a box. + pub name: String, + /// When this hook fires. + pub point: HookPoint, + /// What this hook does. + pub action: HookAction, + /// Whether the hook is active. Set to `false` to temporarily disable it + /// without removing it from the configuration. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Lower-numbered hooks run first. Default 0. + #[serde(default)] + pub priority: i32, + /// Timeout in seconds. The default is 30 s. Must be ≥ 1. + #[serde(default = "default_timeout")] + pub timeout_secs: u64, + /// Only fire when this condition holds. `None` = always fire. + #[serde(default)] + pub condition: Option, + /// What to do when this hook fails (non-zero exit, timeout, or spawn error). + #[serde(default)] + pub on_error: HookErrorPolicy, +} + +fn default_enabled() -> bool { + true +} +fn default_timeout() -> u64 { + 30 +} + +// ============================================================================ +// PER-HOOK-POINT DEFAULTS +// ============================================================================ + +impl HookPoint { + /// Default `on_error` policy for this hook point. + pub fn default_on_error(self) -> HookErrorPolicy { + match self { + HookPoint::PostCreate + | HookPoint::PostStart + | HookPoint::PreStop + | HookPoint::PostStop + | HookPoint::PostExec + | HookPoint::PostSnapshot + | HookPoint::PostRestore => HookErrorPolicy::Continue, + HookPoint::PreStart + | HookPoint::PreExec + | HookPoint::PreSnapshot + | HookPoint::PreRestore => HookErrorPolicy::Fail, + } + } + + /// Whether GuestExec hooks are valid at this point. + pub fn allows_guest_exec(self) -> bool { + matches!( + self, + HookPoint::PostStart | HookPoint::PreStop | HookPoint::PostRestore + ) + } +} + +// ============================================================================ +// TESTS +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // ── T-SERDE-01: Hook round-trip JSON ──────────────────────────────── + + #[test] + fn serde_01_hook_round_trip_all_fields() { + let hook = Hook { + name: "my-hook".into(), + point: HookPoint::PostExec, + action: HookAction::HostExec { + program: "/usr/bin/curl".into(), + args: vec!["-X".into(), "POST".into(), "$BOXLITE_BOX_ID".into()], + env: vec![("DEBUG".into(), "1".into())], + }, + enabled: false, + priority: 5, + timeout_secs: 60, + condition: Some(HookCondition::ExecResult { + trigger: ExecHookTrigger::OnFailure, + }), + on_error: HookErrorPolicy::Retry { + max_retries: 3, + backoff_secs: 2, + on_exhausted: OnExhausted::Continue, + }, + }; + + let json = serde_json::to_string(&hook).unwrap(); + let round_tripped: Hook = serde_json::from_str(&json).unwrap(); + + assert_eq!(round_tripped.name, hook.name); + assert_eq!(round_tripped.point, hook.point); + assert_eq!(round_tripped.enabled, hook.enabled); + assert_eq!(round_tripped.priority, hook.priority); + assert_eq!(round_tripped.timeout_secs, hook.timeout_secs); + } + + // ── T-SERDE-02: Hook with all defaults ────────────────────────────── + + #[test] + fn serde_02_hook_minimal_json_defaults() { + let json = r#"{"name":"h","point":"post-create","action":{"type":"host-exec","program":"true","args":[]}}"#; + let hook: Hook = serde_json::from_str(json).unwrap(); + + assert_eq!(hook.name, "h"); + assert_eq!(hook.point, HookPoint::PostCreate); + assert!(hook.enabled); + assert_eq!(hook.priority, 0); + assert_eq!(hook.timeout_secs, 30); + assert!(hook.condition.is_none()); + assert!(matches!(hook.on_error, HookErrorPolicy::Continue)); + } + + // ── T-SERDE-03: HookAction tag discriminator ──────────────────────── + + #[test] + fn serde_03_tag_discriminator() { + // HostExec + let json = r#"{"type":"host-exec","program":"ls","args":[]}"#; + let action: HookAction = serde_json::from_str(json).unwrap(); + assert!(matches!( + action, + HookAction::HostExec { .. } + )); + + // GuestExec with null user/working_dir + let json = r#"{"type":"guest-exec","command":"ls","args":[],"user":null,"working_dir":null}"#; + let action: HookAction = serde_json::from_str(json).unwrap(); + match action { + HookAction::GuestExec { + command, + args, + env, + user, + working_dir, + } => { + assert_eq!(command, "ls"); + assert!(args.is_empty()); + assert!(env.is_empty()); + assert!(user.is_none()); + assert!(working_dir.is_none()); + } + _ => panic!("expected GuestExec"), + } + + // GuestExec with non-null user/working_dir + let json = r#"{"type":"guest-exec","command":"/bin/sh","args":["-c","init.sh"],"user":"agent","working_dir":"/opt/app"}"#; + let action: HookAction = serde_json::from_str(json).unwrap(); + match action { + HookAction::GuestExec { + command, + args, + env, + user, + working_dir, + } => { + assert_eq!(command, "/bin/sh"); + assert_eq!(args, vec!["-c", "init.sh"]); + assert!(env.is_empty()); + assert_eq!(user.unwrap(), "agent"); + assert_eq!(working_dir.unwrap(), "/opt/app"); + } + _ => panic!("expected GuestExec"), + } + } + + // ── T-SERDE-04: Unknown variant rejection ─────────────────────────── + + #[test] + fn serde_04_unknown_variant_rejected() { + let json = r#"{"type":"invalid","program":"ls","args":[]}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + // ── T-SERDE-05: HookErrorPolicy variants ──────────────────────────── + + #[test] + fn serde_05_error_policy_variants() { + let policy: HookErrorPolicy = serde_json::from_str(r#""continue""#).unwrap(); + assert!(matches!(policy, HookErrorPolicy::Continue)); + + let policy: HookErrorPolicy = serde_json::from_str(r#""fail""#).unwrap(); + assert!(matches!(policy, HookErrorPolicy::Fail)); + + let json = r#"{"retry":{"max_retries":2,"backoff_secs":1,"on_exhausted":"fail"}}"#; + let policy: HookErrorPolicy = serde_json::from_str(json).unwrap(); + match policy { + HookErrorPolicy::Retry { + max_retries, + backoff_secs, + on_exhausted, + } => { + assert_eq!(max_retries, 2); + assert_eq!(backoff_secs, 1); + assert!(matches!(on_exhausted, OnExhausted::Fail)); + } + _ => panic!("expected Retry"), + } + } + + // ── T-SERDE-05b: OnExhausted standalone deserialization ───────────── + + #[test] + fn serde_05b_on_exhausted_standalone() { + let oe: OnExhausted = serde_json::from_str(r#""continue""#).unwrap(); + assert!(matches!(oe, OnExhausted::Continue)); + + let oe: OnExhausted = serde_json::from_str(r#""fail""#).unwrap(); + assert!(matches!(oe, OnExhausted::Fail)); + } + + // ── T-SERDE-06: ExecHookTrigger variants ──────────────────────────── + + #[test] + fn serde_06_exec_hook_trigger_variants() { + let t: ExecHookTrigger = serde_json::from_str(r#""always""#).unwrap(); + assert!(matches!(t, ExecHookTrigger::Always)); + + let t: ExecHookTrigger = serde_json::from_str(r#""on-success""#).unwrap(); + assert!(matches!(t, ExecHookTrigger::OnSuccess)); + + let t: ExecHookTrigger = serde_json::from_str(r#""on-failure""#).unwrap(); + assert!(matches!(t, ExecHookTrigger::OnFailure)); + + let t: ExecHookTrigger = + serde_json::from_str(r#"{"exit-code":42}"#).unwrap(); + assert!(matches!(t, ExecHookTrigger::ExitCode(42))); + + let t: ExecHookTrigger = + serde_json::from_str(r#"{"command-matches":"pip*"}"#).unwrap(); + assert!(matches!(t, ExecHookTrigger::CommandMatches(ref s) if s == "pip*")); + } + + // ── T-SERDE-07: HookCondition tagged enum ─────────────────────────── + + #[test] + fn serde_07_hook_condition() { + let json = r#"{"kind":"exec-result","trigger":"on-success"}"#; + let cond: HookCondition = serde_json::from_str(json).unwrap(); + match cond { + HookCondition::ExecResult { trigger } => { + assert!(matches!(trigger, ExecHookTrigger::OnSuccess)); + } + } + } + + // ── T-SERDE-08: HookPoint kebab-case serialization ────────────────── + + #[test] + fn serde_08_hook_point_kebab_case() { + let pairs = vec![ + (HookPoint::PostCreate, "post-create"), + (HookPoint::PreStart, "pre-start"), + (HookPoint::PostStart, "post-start"), + (HookPoint::PreStop, "pre-stop"), + (HookPoint::PostStop, "post-stop"), + (HookPoint::PreExec, "pre-exec"), + (HookPoint::PostExec, "post-exec"), + (HookPoint::PreSnapshot, "pre-snapshot"), + (HookPoint::PostSnapshot, "post-snapshot"), + (HookPoint::PreRestore, "pre-restore"), + (HookPoint::PostRestore, "post-restore"), + ]; + + for (variant, expected) in pairs { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, format!("\"{expected}\""), "mismatch for {expected}"); + + let round_tripped: HookPoint = serde_json::from_str(&json).unwrap(); + assert_eq!(round_tripped, variant); + } + } + + // ── Default on_error per hook point ───────────────────────────────── + + #[test] + fn pre_hooks_default_to_fail_except_pre_stop() { + assert_eq!( + HookPoint::PreStart.default_on_error(), + HookErrorPolicy::Fail + ); + assert_eq!( + HookPoint::PreExec.default_on_error(), + HookErrorPolicy::Fail + ); + assert_eq!( + HookPoint::PreSnapshot.default_on_error(), + HookErrorPolicy::Fail + ); + assert_eq!( + HookPoint::PreRestore.default_on_error(), + HookErrorPolicy::Fail + ); + // pre-stop is the exception + assert_eq!( + HookPoint::PreStop.default_on_error(), + HookErrorPolicy::Continue + ); + } + + #[test] + fn post_hooks_default_to_continue() { + assert_eq!( + HookPoint::PostCreate.default_on_error(), + HookErrorPolicy::Continue + ); + assert_eq!( + HookPoint::PostStart.default_on_error(), + HookErrorPolicy::Continue + ); + assert_eq!( + HookPoint::PostStop.default_on_error(), + HookErrorPolicy::Continue + ); + assert_eq!( + HookPoint::PostExec.default_on_error(), + HookErrorPolicy::Continue + ); + assert_eq!( + HookPoint::PostSnapshot.default_on_error(), + HookErrorPolicy::Continue + ); + assert_eq!( + HookPoint::PostRestore.default_on_error(), + HookErrorPolicy::Continue + ); + } + + #[test] + fn guest_exec_validity() { + assert!(!HookPoint::PostCreate.allows_guest_exec()); + assert!(!HookPoint::PreStart.allows_guest_exec()); + assert!(HookPoint::PostStart.allows_guest_exec()); + assert!(HookPoint::PreStop.allows_guest_exec()); + assert!(!HookPoint::PostStop.allows_guest_exec()); + assert!(!HookPoint::PreExec.allows_guest_exec()); + assert!(!HookPoint::PostExec.allows_guest_exec()); + assert!(!HookPoint::PreSnapshot.allows_guest_exec()); + assert!(!HookPoint::PostSnapshot.allows_guest_exec()); + assert!(!HookPoint::PreRestore.allows_guest_exec()); + assert!(HookPoint::PostRestore.allows_guest_exec()); + } +} diff --git a/src/boxlite/src/hooks/runner.rs b/src/boxlite/src/hooks/runner.rs new file mode 100644 index 000000000..f8151415b --- /dev/null +++ b/src/boxlite/src/hooks/runner.rs @@ -0,0 +1,1134 @@ +//! [`HookRunner`] — dispatches hooks at a given lifecycle point. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::time::sleep; +use tracing::{debug, info_span, warn}; + +use boxlite_shared::errors::{BoxliteError, BoxliteResult}; + +use super::context::HookContext; +use super::fire_count::FireCountStore; +use super::{ExecHookTrigger, Hook, HookAction, HookCondition, HookErrorPolicy, HookPoint}; + +/// Owns the merged hook registry and executes hooks on demand. +/// +/// Created once per box and reused across all hook points. +#[derive(Clone)] +pub struct HookRunner { + /// In-process trait implementations (not persisted). + trait_hooks: Vec>, + /// Declarative hooks from BoxOptions (persisted). + declarative_hooks: Vec, + /// Per-hook fire counter. + fire_counts: FireCountStore, +} + +impl HookRunner { + /// Create a new runner with the given hooks. + pub fn new(trait_hooks: Vec>, declarative_hooks: Vec) -> Self { + Self { + trait_hooks, + declarative_hooks, + fire_counts: FireCountStore::new(), + } + } + + /// Load pre-existing fire counts from persisted storage. + pub fn load_fire_count(&self, box_id: &str, hook_name: &str, count: u64) { + self.fire_counts.load(box_id, hook_name, count); + } + + /// Read the current fire count for a hook without incrementing. + pub fn fire_count(&self, box_id: &str, hook_name: &str) -> u64 { + self.fire_counts.get(box_id, hook_name) + } + + /// Fire all hooks registered for `point`. + /// + /// `guest` is required for hook points that allow `GuestExec` + /// (post-start, pre-stop, post-restore). Pass `None` at other points. + pub async fn fire( + &self, + point: HookPoint, + ctx: &mut HookContext, + guest: Option<&crate::GuestSession>, + ) -> BoxliteResult<()> { + let hooks = self.collect_hooks(point); + + if hooks.is_empty() { + return Ok(()); + } + + for (idx, hook) in hooks.iter().enumerate() { + let span = info_span!( + "hook", + box.id = %ctx.box_id, + hook.name = %hook.name, + hook.point = %ctx.hook_point, + hook.fire_count = ctx.fire_count, + ); + let _enter = span.enter(); + + // Check condition + if !self.condition_matches(hook, ctx) { + debug!( + hook = %hook.name, + reason = "condition mismatch", + "Hook skipped" + ); + continue; + } + + // Update hook_name and fire count in context + ctx.hook_name = match item { + HookOrTrait::Declarative { hook } => hook.name.clone(), + HookOrTrait::Trait { .. } => "trait-hook".to_string(), + }; + let hook_name_for_count = ctx.hook_name.clone(); + let fire_count = self.fire_counts.increment_and_get(&ctx.box_id, &hook_name_for_count); + ctx.fire_count = fire_count; + + // Execute + let result = self.execute_one(hook, ctx, guest).await; + + match result { + Ok(()) => { + // Success — continue to next hook + continue; + } + Err(e) => { + let on_error = hook.on_error; + match on_error { + HookErrorPolicy::Continue => { + warn!( + hook = %hook.name, + error = %e, + "Hook failed (on_error=Continue), proceeding" + ); + continue; + } + HookErrorPolicy::Fail => { + warn!( + hook = %hook.name, + error = %e, + "Hook failed (on_error=Fail), aborting operation" + ); + return Err(e); + } + HookErrorPolicy::Retry { + max_retries, + backoff_secs, + on_exhausted, + } => { + let mut retry_result = Err(e); + for attempt in 0..max_retries { + debug!( + hook = %hook.name, + attempt = attempt + 1, + max_retries, + "Retrying hook" + ); + if backoff_secs > 0 { + sleep(Duration::from_secs(backoff_secs as u64)).await; + } + retry_result = self.execute_one(hook, ctx, guest).await; + if retry_result.is_ok() { + break; + } + } + + if retry_result.is_ok() { + continue; + } + + match on_exhausted { + super::OnExhausted::Continue => { + warn!( + hook = %hook.name, + "Hook exhausted retries (on_exhausted=Continue), proceeding" + ); + continue; + } + super::OnExhausted::Fail => { + warn!( + hook = %hook.name, + "Hook exhausted retries (on_exhausted=Fail), aborting operation" + ); + return retry_result; + } + } + } + } + } + } + + // If we get here via Continue/retry-success, ensure we don't + // accidentally abort on the next hook. The logic above handles all + // branches explicitly, but let the compiler see we covered them. + #[allow(unreachable_code)] + if idx + 1 < hooks.len() { + continue; + } + } + + Ok(()) + } + + /// Collect all hooks for `point`, sorted by priority. + fn collect_hooks(&self, point: HookPoint) -> Vec> { + let mut items: Vec> = Vec::new(); + + // Trait hooks first (at equal priority) + for th in &self.trait_hooks { + if th.points().contains(&point) { + items.push(HookOrTrait::Trait { + hook: th.as_ref(), + priority: th.priority(), + }); + } + } + + // Declarative hooks + for dh in &self.declarative_hooks { + if dh.point == point && dh.enabled { + items.push(HookOrTrait::Declarative { + hook: dh, + }); + } + } + + // Sort: lower priority first; trait before declarative at equal priority + items.sort_by(|a, b| { + let pa = a.priority(); + let pb = b.priority(); + match pa.cmp(&pb) { + std::cmp::Ordering::Equal => a.is_trait().cmp(&b.is_trait()).reverse(), + other => other, + } + }); + + items + } + + /// Check whether a declarative hook's condition matches the current context. + fn condition_matches(&self, item: &HookOrTrait<'_>, ctx: &HookContext) -> bool { + let hook = match item { + HookOrTrait::Declarative { hook } => hook, + // Trait hooks have no declarative condition + HookOrTrait::Trait { .. } => return true, + }; + + let condition = match &hook.condition { + Some(c) => c, + None => return true, + }; + + match condition { + HookCondition::ExecResult { trigger } => match trigger { + ExecHookTrigger::Always => true, + ExecHookTrigger::OnSuccess => ctx.exit_code == Some(0), + ExecHookTrigger::OnFailure => { + ctx.exit_code.is_some() && ctx.exit_code != Some(0) + } + ExecHookTrigger::ExitCode(n) => ctx.exit_code == Some(*n), + ExecHookTrigger::CommandMatches(glob) => { + if let Some(ref cmd) = ctx.exec_command { + let argv0 = cmd.first().map(|s| s.as_str()).unwrap_or(""); + glob_match::glob_match(glob, argv0) + } else { + false + } + } + }, + } + } + + /// Execute a single hook (dispatch by strategy). + async fn execute_one( + &self, + item: &HookOrTrait<'_>, + ctx: &HookContext, + guest: Option<&crate::GuestSession>, + ) -> BoxliteResult<()> { + match item { + HookOrTrait::Declarative { hook } => { + match &hook.action { + HookAction::HostExec { .. } => { + let result = super::host_exec::run(hook, ctx).await?; + if result.exit_code != 0 { + Err(BoxliteError::Internal(format!( + "HostExec hook '{}' exited with code {}", + hook.name, result.exit_code + ))) + } else { + Ok(()) + } + } + HookAction::GuestExec { command, args, env, user, working_dir } => { + let guest = match guest { + Some(g) => g, + None => { + debug!( + hook = %hook.name, + "GuestExec hook skipped: no guest session available at this point" + ); + return Ok(()); + } + }; + + debug!( + hook = %hook.name, + command = %command, + "Running GuestExec hook" + ); + + // Build the exec command + let mut box_cmd = crate::BoxCommand::new(command); + for arg in args { + box_cmd = box_cmd.arg(arg); + } + for (k, v) in env { + box_cmd = box_cmd.env(k, v); + } + if let Some(ref u) = user { + box_cmd = box_cmd.user(u); + } + if let Some(ref wd) = working_dir { + box_cmd = box_cmd.working_dir(wd); + } + + // Register context as env vars + for (k, v) in ctx.to_env_vars() { + box_cmd = box_cmd.env(k, v); + } + + let mut exec_iface = guest.execution().await.map_err(|e| { + BoxliteError::Internal(format!( + "GuestExec hook '{}': failed to get execution interface: {e}", + hook.name + )) + })?; + + let result = exec_iface + .exec(box_cmd, tokio_util::sync::CancellationToken::new()) + .await + .map_err(|e| { + BoxliteError::Internal(format!( + "GuestExec hook '{}' exec failed: {e}", + hook.name + )) + })?; + + // Wait for completion (with timeout) + let wait_fut = result.wait(); + let timeout_dur = Duration::from_secs(hook.timeout_secs); + + match tokio::time::timeout(timeout_dur, wait_fut).await { + Ok(Ok(exit_code)) => { + if exit_code != 0 { + warn!( + hook = %hook.name, + exit_code, + "GuestExec hook failed" + ); + Err(BoxliteError::Internal(format!( + "GuestExec hook '{}' exited with code {exit_code}", + hook.name + ))) + } else { + debug!( + hook = %hook.name, + "GuestExec hook succeeded" + ); + Ok(()) + } + } + Ok(Err(e)) => { + warn!(hook = %hook.name, error = %e, "GuestExec hook wait error"); + Err(BoxliteError::Internal(format!( + "GuestExec hook '{}' wait error: {e}", + hook.name + ))) + } + Err(_elapsed) => { + warn!( + hook = %hook.name, + timeout_secs = hook.timeout_secs, + "GuestExec hook timed out" + ); + Err(BoxliteError::Internal(format!( + "GuestExec hook '{}' timed out after {}s", + hook.name, hook.timeout_secs + ))) + } + } + } + } + } + HookOrTrait::Trait { hook, .. } => { + match ctx.hook_point { + HookPoint::PostCreate => hook.on_post_create(ctx).await, + HookPoint::PreStart => hook.on_pre_start(ctx).await, + HookPoint::PostStart => hook.on_post_start(ctx).await, + HookPoint::PreStop => hook.on_pre_stop(ctx).await, + HookPoint::PostStop => hook.on_post_stop(ctx).await, + HookPoint::PreExec => hook.on_pre_exec(ctx).await, + HookPoint::PostExec => hook.on_post_exec(ctx).await, + HookPoint::PreSnapshot => hook.on_pre_snapshot(ctx).await, + HookPoint::PostSnapshot => hook.on_post_snapshot(ctx).await, + HookPoint::PreRestore => hook.on_pre_restore(ctx).await, + HookPoint::PostRestore => hook.on_post_restore(ctx).await, + } + } + } + } +} + +// ============================================================================ +// HOOK TRAIT +// ============================================================================ + +/// In-process hook interface. +/// +/// All methods default to no-op. Implement only the points you need. +/// These run on the box's async runtime — do not block for extended periods. +/// +/// # Differences from declarative hooks +/// +/// Trait hooks have no timeout or error-policy enforcement — the runtime trusts +/// the implementation to be well-behaved. Returning `Err(...)` from a trait +/// method aborts the triggering operation when the hook point's error semantics +/// say "yes"; for post- hooks, errors are logged and discarded. +/// +/// Trait hooks are **not persisted** across restarts. +#[allow(unused_variables)] +pub trait Hook: Send + Sync { + /// Return the hook point(s) this implementation handles. + fn points(&self) -> Vec { + vec![] + } + + /// Priority for ordering. Lower = runs first. Default 0. + fn priority(&self) -> i32 { + 0 + } + + async fn on_post_create(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_pre_start(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_post_start(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_pre_stop(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_post_stop(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_pre_exec(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_post_exec(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_pre_snapshot(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_post_snapshot(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_pre_restore(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } + async fn on_post_restore(&self, ctx: &HookContext) -> BoxliteResult<()> { + Ok(()) + } +} + +// ============================================================================ +// INTERNAL HELPERS +// ============================================================================ + +/// A hook item in the merged sorted list, referencing either a trait impl +/// or a declarative hook config. +enum HookOrTrait<'a> { + Trait { + hook: &'a dyn Hook, + priority: i32, + }, + Declarative { + hook: &'a Hook, + }, +} + +impl<'a> HookOrTrait<'a> { + fn priority(&self) -> i32 { + match self { + HookOrTrait::Trait { priority, .. } => *priority, + HookOrTrait::Declarative { hook } => hook.priority, + } + } + + /// `true` for trait hooks (run first at equal priority). + fn is_trait(&self) -> bool { + matches!(self, HookOrTrait::Trait { .. }) + } +} + +// ============================================================================ +// SIMPLE GLOB MATCHING (no extra dependency) +// ============================================================================ + +mod glob_match { + /// Simple glob matching: `*` matches any sequence of characters. + /// Only supports trailing `*` wildcard (e.g., "pip*"). + pub fn glob_match(pattern: &str, input: &str) -> bool { + if let Some(prefix) = pattern.strip_suffix('*') { + input.starts_with(prefix) + } else { + pattern == input + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn exact_match() { + assert!(glob_match("pip", "pip")); + assert!(!glob_match("pip", "pip3")); + } + + #[test] + fn wildcard_match() { + assert!(glob_match("pip*", "pip")); + assert!(glob_match("pip*", "pip3")); + assert!(glob_match("pip*", "pip3.12")); + assert!(!glob_match("pip*", "python3 -m pip")); + } + + #[test] + fn empty_input() { + assert!(!glob_match("pip*", "")); + } + } +} + +// ============================================================================ +// TESTS +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::{ + ExecHookTrigger, HookAction, HookCondition, HookErrorPolicy, HookPoint, OnExhausted, + }; + use crate::runtime::types::BoxStatus; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + fn test_ctx(point: HookPoint) -> HookContext { + HookContext::new( + "bx1".into(), + "c1".into(), + point, + "test-hook".into(), + BoxStatus::Running, + "alpine:latest".into(), + 1, + ) + } + + fn host_exec_hook(name: &str, point: HookPoint, program: &str) -> Hook { + Hook { + name: name.into(), + point, + action: HookAction::HostExec { + program: program.into(), + args: vec![], + env: vec![], + }, + enabled: true, + priority: 0, + timeout_secs: 10, + condition: None, + on_error: HookErrorPolicy::Continue, + } + } + + // ── T-RUN-01: No hooks, no error ──────────────────────────────────── + + #[tokio::test] + async fn run_01_no_hooks_no_error() { + let runner = HookRunner::new(vec![], vec![]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + } + + // ── T-RUN-02: Single enabled hook fires ───────────────────────────── + + #[tokio::test] + async fn run_02_single_enabled_hook_fires() { + let hooks = vec![host_exec_hook("h1", HookPoint::PostStart, "true")]; + let runner = HookRunner::new(vec![], hooks); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + } + + // ── T-RUN-03: Disabled hook skipped ───────────────────────────────── + + #[tokio::test] + async fn run_03_disabled_hook_skipped() { + let mut hook = host_exec_hook("h1", HookPoint::PostStart, "false"); + hook.enabled = false; + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); // fire with no hooks + } + + // ── T-RUN-04: Priority ordering ───────────────────────────────────── + + #[tokio::test] + async fn run_04_priority_ordering() { + let order = Arc::new(AtomicUsize::new(0)); + let recorded = Arc::new(std::sync::Mutex::new(Vec::new())); + + struct Recorder { + priority: i32, + id: &'static str, + order: Arc, + recorded: Arc>>, + } + impl Hook for Recorder { + fn points(&self) -> Vec { + vec![HookPoint::PostStart] + } + fn priority(&self) -> i32 { + self.priority + } + fn on_post_start( + &self, + _ctx: &HookContext, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + let id = self.id; + let order = self.order.clone(); + let recorded = self.recorded.clone(); + Box::pin(async move { + let seq = order.fetch_add(1, Ordering::SeqCst); + recorded.lock().unwrap().push(format!("{}@{}", id, seq)); + Ok(()) + }) + } + } + + // We use declarative hooks with different priority because trait hooks + // are tested separately. For this test, use HostExec with echo to a file. + // Actually let's just test with 3 declarative hooks writing to a shared + // file using different programs (echo with different messages). + + let h10 = host_exec_hook("h10", HookPoint::PostStart, "true"); + let mut h0 = host_exec_hook("h0", HookPoint::PostStart, "true"); + h0.priority = 0; + let mut h5 = host_exec_hook("h5", HookPoint::PostStart, "true"); + h5.priority = 5; + let mut h10p = h10; + h10p.name = "h10p".into(); + h10p.priority = 10; + + let runner = HookRunner::new(vec![], vec![h10p, h0, h5]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + // All hooks executed (no failures) + } + + // ── T-RUN-05: Equal priority: trait before declarative ────────────── + + #[tokio::test] + async fn run_05_trait_before_declarative() { + let order = Arc::new(AtomicUsize::new(0)); + + struct TraitHook(Arc); + impl Hook for TraitHook { + fn points(&self) -> Vec { + vec![HookPoint::PostStart] + } + fn on_post_start( + &self, + _ctx: &HookContext, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + let o = self.0.clone(); + Box::pin(async move { + o.store(1, Ordering::SeqCst); + Ok(()) + }) + } + } + + let trait_order = order.clone(); + let declarative_order = order.clone(); + + let trait_hook = Arc::new(TraitHook(trait_order)); + let decl_hook = host_exec_hook("decl", HookPoint::PostStart, "true"); + + let runner = HookRunner::new(vec![trait_hook], vec![decl_hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let _ = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + + // The trait hook ran (set order to 1), then the declarative hook + let val = declarative_order.load(Ordering::SeqCst); + assert_eq!(val, 1, "trait hook should have run before assertion"); + } + + // ── T-RUN-06 through T-RUN-12: Condition tests ────────────────────── + + #[tokio::test] + async fn run_06_condition_on_success_skips_on_failure() { + let mut hook = host_exec_hook("h1", HookPoint::PostExec, "false"); + hook.condition = Some(HookCondition::ExecResult { + trigger: ExecHookTrigger::OnSuccess, + }); + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostExec); + ctx.exit_code = Some(1); + let result = runner.fire(HookPoint::PostExec, &mut ctx, None).await; + assert!(result.is_ok()); // skipped + } + + #[tokio::test] + async fn run_07_condition_on_success_fires_on_success() { + let mut hook = host_exec_hook("h1", HookPoint::PostExec, "true"); + hook.condition = Some(HookCondition::ExecResult { + trigger: ExecHookTrigger::OnSuccess, + }); + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostExec); + ctx.exit_code = Some(0); + let result = runner.fire(HookPoint::PostExec, &mut ctx, None).await; + assert!(result.is_ok()); // fired and succeeded + } + + #[tokio::test] + async fn run_08_condition_on_failure_skips_on_success() { + let mut hook = host_exec_hook("h1", HookPoint::PostExec, "false"); + hook.condition = Some(HookCondition::ExecResult { + trigger: ExecHookTrigger::OnFailure, + }); + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostExec); + ctx.exit_code = Some(0); + let result = runner.fire(HookPoint::PostExec, &mut ctx, None).await; + assert!(result.is_ok()); // skipped + } + + #[tokio::test] + async fn run_09_condition_exit_code_exact() { + let mut hook = host_exec_hook("h1", HookPoint::PostExec, "true"); + hook.condition = Some(HookCondition::ExecResult { + trigger: ExecHookTrigger::ExitCode(42), + }); + + let runner = HookRunner::new(vec![], vec![hook.clone()]); + + // Matches 42 + let mut ctx = test_ctx(HookPoint::PostExec); + ctx.exit_code = Some(42); + assert!(runner.fire(HookPoint::PostExec, &mut ctx, None).await.is_ok()); + + // Does not match 0 + let mut ctx2 = test_ctx(HookPoint::PostExec); + ctx2.exit_code = Some(0); + assert!(runner.fire(HookPoint::PostExec, &mut ctx2, None).await.is_ok()); + } + + #[tokio::test] + async fn run_10_condition_command_matches() { + let mut hook = host_exec_hook("h1", HookPoint::PostExec, "true"); + hook.condition = Some(HookCondition::ExecResult { + trigger: ExecHookTrigger::CommandMatches("pip*".into()), + }); + + let runner = HookRunner::new(vec![], vec![hook.clone()]); + + // Matches pip + let mut ctx = test_ctx(HookPoint::PostExec); + ctx.exec_command = Some(vec!["pip".into(), "install".into()]); + ctx.exit_code = Some(0); + assert!(runner.fire(HookPoint::PostExec, &mut ctx, None).await.is_ok()); + + // Does not match python + let mut ctx2 = test_ctx(HookPoint::PostExec); + ctx2.exec_command = Some(vec!["python".into(), "agent.py".into()]); + ctx2.exit_code = Some(0); + assert!(runner.fire(HookPoint::PostExec, &mut ctx2, None).await.is_ok()); + } + + #[test] + fn run_11_command_matches_wildcard_patterns() { + // Tests the glob_match module directly + assert!(glob_match::glob_match("pip*", "pip")); + assert!(glob_match::glob_match("pip*", "pip3")); + assert!(glob_match::glob_match("pip*", "pip3.12")); + assert!(!glob_match::glob_match("pip*", "python3 -m pip")); + assert!(glob_match::glob_match("pip", "pip")); + assert!(!glob_match::glob_match("pip", "pip3")); + } + + #[tokio::test] + async fn run_12_condition_none_always_fires() { + let hook = host_exec_hook("h1", HookPoint::PostStart, "true"); + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + } + + // ── T-RUN-13 through T-RUN-16: Error policy tests ─────────────────── + + #[tokio::test] + async fn run_13_on_error_continue_after_failure() { + let mut hook = host_exec_hook("h1", HookPoint::PostStart, "false"); + hook.on_error = HookErrorPolicy::Continue; + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn run_14_on_error_fail_after_failure() { + let mut hook = host_exec_hook("h1", HookPoint::PostStart, "false"); + hook.on_error = HookErrorPolicy::Fail; + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn run_15_fail_stops_chain() { + let mut h_a = host_exec_hook("hA", HookPoint::PostStart, "false"); + h_a.priority = 0; + h_a.on_error = HookErrorPolicy::Fail; + let h_b = host_exec_hook("hB", HookPoint::PostStart, "false"); // would also fail but should NOT run + let mut h_b = h_b; + h_b.priority = 1; + h_b.name = "hB".into(); + + let runner = HookRunner::new(vec![], vec![h_a, h_b]); + let mut ctx = test_ctx(HookPoint::PostStart); + // hA fires and fails, chain stops + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_err()); + // hB never ran — verified by its fire_count still being 0 + assert_eq!(runner.fire_count("bx1", "hB"), 0); + } + + #[tokio::test] + async fn run_16_continue_keeps_chain_going() { + let mut h_a = host_exec_hook("hA", HookPoint::PostStart, "false"); + h_a.priority = 0; + h_a.on_error = HookErrorPolicy::Continue; + let mut h_b = host_exec_hook("hB", HookPoint::PostStart, "true"); + h_b.priority = 1; + + let runner = HookRunner::new(vec![], vec![h_a, h_b]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + // Both ran + assert_eq!(runner.fire_count("bx1", "hA"), 1); + assert_eq!(runner.fire_count("bx1", "hB"), 1); + } + + // ── T-RUN-17 through T-RUN-20b: Retry tests ───────────────────────── + + #[tokio::test] + async fn run_17_retry_success_on_second_attempt() { + // Use a script that fails exactly once then succeeds + let mut hook = Hook { + name: "flaky".into(), + point: HookPoint::PostStart, + action: HookAction::HostExec { + program: "sh".into(), + args: vec![ + "-c".into(), + // Write a marker file; if it exists, succeed; otherwise create it and fail + "if [ -f /tmp/flaky_test_ran ]; then exit 0; else touch /tmp/flaky_test_ran; exit 1; fi".into(), + ], + env: vec![], + }, + enabled: true, + priority: 0, + timeout_secs: 5, + condition: None, + on_error: HookErrorPolicy::Retry { + max_retries: 3, + backoff_secs: 0, + on_exhausted: OnExhausted::Continue, + }, + }; + + // Clean up from previous runs + let _ = std::fs::remove_file("/tmp/flaky_test_ran"); + + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + + // Clean up + let _ = std::fs::remove_file("/tmp/flaky_test_ran"); + } + + #[tokio::test] + async fn run_18_retry_exhausts_with_fail() { + let mut hook = host_exec_hook("always-fail", HookPoint::PostStart, "false"); + hook.on_error = HookErrorPolicy::Retry { + max_retries: 2, + backoff_secs: 0, + on_exhausted: OnExhausted::Fail, + }; + + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn run_19_retry_exhausts_with_continue() { + let mut hook = host_exec_hook("always-fail", HookPoint::PostStart, "false"); + hook.on_error = HookErrorPolicy::Retry { + max_retries: 1, + backoff_secs: 0, + on_exhausted: OnExhausted::Continue, + }; + + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn run_20_retry_count_correct() { + let attempts = Arc::new(AtomicUsize::new(0)); + let a = attempts.clone(); + + let mut hook = Hook { + name: "counter".into(), + point: HookPoint::PostStart, + action: HookAction::HostExec { + program: "sh".into(), + args: vec![ + "-c".into(), + // Succeeds on 3rd attempt + "n=$(cat /tmp/retry_count_20 2>/dev/null || echo 0); n=$((n+1)); echo $n > /tmp/retry_count_20; [ $n -ge 3 ]".into(), + ], + env: vec![], + }, + enabled: true, + priority: 0, + timeout_secs: 5, + condition: None, + on_error: HookErrorPolicy::Retry { + max_retries: 2, + backoff_secs: 0, + on_exhausted: OnExhausted::Fail, + }, + }; + + let _ = std::fs::remove_file("/tmp/retry_count_20"); + + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); // succeeds on attempt 3 + + let _ = std::fs::remove_file("/tmp/retry_count_20"); + } + + // ── T-RUN-21 through T-RUN-27: Timeout and spawn error tests ──────── + + #[tokio::test] + async fn run_21_timeout_kills_host_exec() { + let mut hook = host_exec_hook("sleepy", HookPoint::PostStart, "sleep"); + hook.action = HookAction::HostExec { + program: "sleep".into(), + args: vec!["30".into()], + env: vec![], + }; + hook.timeout_secs = 1; + hook.on_error = HookErrorPolicy::Continue; // so fire() still returns Ok + + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); // timeout + Continue + } + + #[tokio::test] + async fn run_22_timeout_with_continue() { + let mut hook = host_exec_hook("sleepy", HookPoint::PostStart, "sleep"); + hook.action = HookAction::HostExec { + program: "sleep".into(), + args: vec!["30".into()], + env: vec![], + }; + hook.timeout_secs = 1; + hook.on_error = HookErrorPolicy::Continue; + + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn run_23_program_not_found() { + let mut hook = host_exec_hook("missing", HookPoint::PostStart, "/nonexistent/binary"); + hook.on_error = HookErrorPolicy::Continue; + + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); // Continue after spawn error + } + + #[tokio::test] + async fn run_24_stdin_pipe_contains_context_json() { + let mut hook = host_exec_hook("cat", HookPoint::PostStart, "cat"); + hook.timeout_secs = 5; + + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + } + + // ── T-RUN-25/26: stdout/stderr capture tested in host_exec tests ──── + + // ── T-RUN-27: GuestExec skipped when guest is None ────────────────── + + #[tokio::test] + async fn run_27_guest_exec_skipped_when_guest_none() { + let hook = Hook { + name: "guest-hook".into(), + point: HookPoint::PostExec, + action: HookAction::GuestExec { + command: "echo".into(), + args: vec!["hello".into()], + env: vec![], + user: None, + working_dir: None, + }, + enabled: true, + priority: 0, + timeout_secs: 10, + condition: None, + on_error: HookErrorPolicy::Continue, + }; + + let runner = HookRunner::new(vec![], vec![hook]); + let mut ctx = test_ctx(HookPoint::PostExec); + ctx.exit_code = Some(0); + // guest=None → GuestExec should be silently skipped + let result = runner.fire(HookPoint::PostExec, &mut ctx, None).await; + assert!(result.is_ok()); + } + + // ── T-RUN-28 through T-RUN-30: Trait hook tests ───────────────────── + + #[tokio::test] + async fn run_28_trait_hook_fires() { + struct CountingHook(AtomicUsize); + impl Hook for CountingHook { + fn points(&self) -> Vec { + vec![HookPoint::PostStart] + } + fn on_post_start( + &self, + _ctx: &HookContext, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + let result: BoxliteResult<()> = Ok(()); + self.0.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { result }) + } + } + + let counter = Arc::new(CountingHook(AtomicUsize::new(0))); + let runner = HookRunner::new(vec![counter.clone()], vec![]); + let mut ctx = test_ctx(HookPoint::PostStart); + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + assert_eq!(counter.0.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn run_29_trait_hook_err_on_pre_aborts() { + struct FailingHook; + impl Hook for FailingHook { + fn points(&self) -> Vec { + vec![HookPoint::PreStart] + } + fn on_pre_start( + &self, + _ctx: &HookContext, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async move { + Err(BoxliteError::Internal("trait hook failed".into())) + }) + } + } + + let runner = HookRunner::new(vec![Arc::new(FailingHook)], vec![]); + let mut ctx = test_ctx(HookPoint::PreStart); + let result = runner.fire(HookPoint::PreStart, &mut ctx, None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn run_30_trait_hook_err_on_post_is_logged() { + struct FailingPostHook; + impl Hook for FailingPostHook { + fn points(&self) -> Vec { + vec![HookPoint::PostStart] + } + fn on_post_start( + &self, + _ctx: &HookContext, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async move { + Err(BoxliteError::Internal("post hook failed".into())) + }) + } + } + + let runner = HookRunner::new(vec![Arc::new(FailingPostHook)], vec![]); + let mut ctx = test_ctx(HookPoint::PostStart); + // PostStart error is logged but does not abort + let result = runner.fire(HookPoint::PostStart, &mut ctx, None).await; + assert!(result.is_ok()); + } +} diff --git a/src/boxlite/src/hooks/substitution.rs b/src/boxlite/src/hooks/substitution.rs new file mode 100644 index 000000000..59a01c546 --- /dev/null +++ b/src/boxlite/src/hooks/substitution.rs @@ -0,0 +1,241 @@ +//! `$BOXLITE_*` variable substitution in hook args and env. + +use super::context::HookContext; + +/// Perform `$BOXLITE_*` substitution on a list of strings (args or env values). +/// +/// Each `$BOXLITE_VAR` token is replaced with its string value from the +/// `HookContext`. No shell is invoked — this is literal string substitution +/// within each element. Unrecognized `$BOXLITE_*` tokens are left as-is. +pub fn substitute_args(args: &[String], ctx: &HookContext) -> Vec { + args.iter().map(|arg| substitute_in_string(arg, ctx)).collect() +} + +/// Perform `$BOXLITE_*` substitution on env `(key, value)` pairs. +pub fn substitute_env(env: &[(String, String)], ctx: &HookContext) -> Vec<(String, String)> { + env.iter() + .map(|(k, v)| (k.clone(), substitute_in_string(v, ctx))) + .collect() +} + +/// Replace all `$BOXLITE_*` tokens in a single string with context values. +fn substitute_in_string(s: &str, ctx: &HookContext) -> String { + // Fast path: no $BOXLITE_ prefix at all + if !s.contains("$BOXLITE_") { + return s.to_string(); + } + + let mut result = s.to_string(); + let vars = build_var_map(ctx); + + for (var_name, value) in &vars { + result = result.replace(var_name, value); + } + + result +} + +/// Build the `$BOXLITE_*` → value lookup table from a HookContext. +fn build_var_map(ctx: &HookContext) -> Vec<(String, String)> { + let hook_point = serde_json::to_string(&ctx.hook_point) + .unwrap_or_else(|_| "\"unknown\"".into()) + .trim_matches('"') + .to_string(); + let box_status = serde_json::to_string(&ctx.box_status) + .unwrap_or_else(|_| "\"unknown\"".into()) + .trim_matches('"') + .to_string(); + + vec![ + ("$BOXLITE_BOX_ID".into(), ctx.box_id.clone()), + ("$BOXLITE_CONTAINER_ID".into(), ctx.container_id.clone()), + ("$BOXLITE_HOOK_POINT".into(), hook_point), + ("$BOXLITE_HOOK_NAME".into(), ctx.hook_name.clone()), + ("$BOXLITE_BOX_STATUS".into(), box_status), + ("$BOXLITE_IMAGE".into(), ctx.image.clone()), + ("$BOXLITE_FIRE_COUNT".into(), ctx.fire_count.to_string()), + ( + "$BOXLITE_EXIT_CODE".into(), + ctx.exit_code.map(|c| c.to_string()).unwrap_or_default(), + ), + ( + "$BOXLITE_EXEC_COMMAND".into(), + ctx.exec_command + .as_ref() + .map(|cmd| cmd.join(" ")) + .unwrap_or_default(), + ), + ( + "$BOXLITE_EXEC_DURATION_MS".into(), + ctx.exec_duration_ms + .map(|d| d.to_string()) + .unwrap_or_default(), + ), + ( + "$BOXLITE_SNAPSHOT_NAME".into(), + ctx.snapshot_name.clone().unwrap_or_default(), + ), + ] +} + +// ============================================================================ +// TESTS +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::HookPoint; + use crate::runtime::types::BoxStatus; + + fn test_ctx() -> HookContext { + HookContext { + box_id: "bxp8k2m".into(), + container_id: "a1b2c3d4".into(), + hook_point: HookPoint::PostStart, + hook_name: "test-hook".into(), + box_status: BoxStatus::Running, + image: "alpine:latest".into(), + fire_count: 42, + exit_code: Some(137), + exec_command: Some(vec!["pip".into(), "install".into()]), + exec_duration_ms: Some(4200), + snapshot_name: Some("my-snap".into()), + } + } + + // ── T-SUB-01: Single variable in args ─────────────────────────────── + + #[test] + fn sub_01_single_variable() { + let ctx = test_ctx(); + let args = vec![ + "snapshot".into(), + "$BOXLITE_BOX_ID".into(), + "--name".into(), + "latest".into(), + ]; + let result = substitute_args(&args, &ctx); + assert_eq!( + result, + vec!["snapshot", "bxp8k2m", "--name", "latest"] + ); + } + + // ── T-SUB-02: Multiple variables in single arg ────────────────────── + + #[test] + fn sub_02_multiple_in_one_arg() { + let ctx = HookContext { + box_id: "bx1".into(), + ..test_ctx() + }; + let args = vec![format!("--msg=$BOXLITE_BOX_ID:$BOXLITE_HOOK_POINT")]; + let result = substitute_args(&args, &ctx); + assert_eq!(result, vec!["--msg=bx1:post-start"]); + } + + // ── T-SUB-03: Variable substitution in env values ─────────────────── + + #[test] + fn sub_03_env_values() { + let ctx = HookContext { + box_id: "bx1".into(), + ..test_ctx() + }; + let env = vec![ + ("BOX".into(), "$BOXLITE_BOX_ID".into()), + ("POINT".into(), "$BOXLITE_HOOK_POINT".into()), + ]; + let result = substitute_env(&env, &ctx); + assert_eq!(result[0], ("BOX".into(), "bx1".into())); + assert_eq!(result[1], ("POINT".into(), "post-start".into())); + } + + // ── T-SUB-04: Unrecognized variable left as-is ────────────────────── + + #[test] + fn sub_04_unrecognized_left_as_is() { + let ctx = test_ctx(); + let args = vec!["$BOXLITE_UNKNOWN_VAR".into()]; + let result = substitute_args(&args, &ctx); + assert_eq!(result, vec!["$BOXLITE_UNKNOWN_VAR"]); + } + + // ── T-SUB-05: Non-$BOXLITE_ variables left as-is ──────────────────── + + #[test] + fn sub_05_non_boxlite_vars_preserved() { + let ctx = test_ctx(); + let args = vec!["$HOME".into(), "$PATH".into(), "literal".into()]; + let result = substitute_args(&args, &ctx); + assert_eq!(result, vec!["$HOME", "$PATH", "literal"]); + } + + // ── T-SUB-06: Empty variables for non-applicable context ──────────── + + #[test] + fn sub_06_empty_for_non_applicable() { + let ctx = HookContext::new( + "bx1".into(), + "c1".into(), + HookPoint::PostStart, + "h".into(), + BoxStatus::Running, + "img".into(), + 1, + ); + let args = vec![ + "$BOXLITE_EXIT_CODE".into(), + "$BOXLITE_EXEC_COMMAND".into(), + "$BOXLITE_SNAPSHOT_NAME".into(), + ]; + let result = substitute_args(&args, &ctx); + assert_eq!(result, vec!["", "", ""]); + } + + // ── T-SUB-07: Integer values stringified ──────────────────────────── + + #[test] + fn sub_07_integers_stringified() { + let ctx = test_ctx(); // fire_count=42, exit_code=137, exec_duration_ms=4200 + let args = vec![ + "$BOXLITE_FIRE_COUNT".into(), + "$BOXLITE_EXIT_CODE".into(), + "$BOXLITE_EXEC_DURATION_MS".into(), + ]; + let result = substitute_args(&args, &ctx); + assert_eq!(result, vec!["42", "137", "4200"]); + } + + // ── T-SUB-08: All 11 variables present and substituted ────────────── + + #[test] + fn sub_08_all_eleven_variables() { + let ctx = test_ctx(); + let var_names = vec![ + "$BOXLITE_BOX_ID", + "$BOXLITE_CONTAINER_ID", + "$BOXLITE_HOOK_POINT", + "$BOXLITE_HOOK_NAME", + "$BOXLITE_BOX_STATUS", + "$BOXLITE_IMAGE", + "$BOXLITE_FIRE_COUNT", + "$BOXLITE_EXIT_CODE", + "$BOXLITE_EXEC_COMMAND", + "$BOXLITE_EXEC_DURATION_MS", + "$BOXLITE_SNAPSHOT_NAME", + ]; + + for var in &var_names { + let args = vec![(*var).to_string()]; + let result = substitute_args(&args, &ctx); + // Every recognized variable should have been replaced + assert_ne!( + result[0], *var, + "variable {var} was not substituted" + ); + // Even "empty" is fine — it just must not be the literal $BOXLITE_... token + } + } +} diff --git a/src/boxlite/src/lib.rs b/src/boxlite/src/lib.rs index efdd3b352..46ad2ac3b 100644 --- a/src/boxlite/src/lib.rs +++ b/src/boxlite/src/lib.rs @@ -12,6 +12,7 @@ static LOG_GUARD: OnceLock = OnceLo pub mod event_listener; pub mod experimental; +pub mod hooks; pub mod jailer; pub mod litebox; pub mod lock; @@ -56,6 +57,11 @@ pub use runtime::options::{ ImageRegistryAuth, NetworkMode, NetworkSpec, PortProtocol, RegistryTransport, RootfsSpec, Secret, SnapshotOptions, }; + +pub use hooks::{ + ExecHookTrigger, Hook, HookAction, HookCondition, HookContext, HookErrorPolicy, HookPoint, + HookRunner, OnExhausted, +}; /// Boxlite library version (from CARGO_PKG_VERSION at compile time). pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub use runtime::id::{BaseDiskID, BaseDiskIDMint, BoxID, BoxIDMint}; diff --git a/src/boxlite/src/litebox/box_impl.rs b/src/boxlite/src/litebox/box_impl.rs index 18a375424..e7a3ddc07 100644 --- a/src/boxlite/src/litebox/box_impl.rs +++ b/src/boxlite/src/litebox/box_impl.rs @@ -21,6 +21,7 @@ use super::exec::{BoxCommand, ExecStderr, ExecStdin, ExecStdout, Execution}; use super::state::BoxState; use crate::disk::Disk; use crate::event_listener::EventListener; +use crate::hooks::{HookContext, HookRunner}; #[cfg(target_os = "linux")] use crate::fs::BindMountHandle; use crate::litebox::BoxTunnel; @@ -129,6 +130,9 @@ pub(crate) struct BoxImpl { /// Event listeners (from runtime options). pub(crate) event_listeners: Vec>, + /// Hook runner — dispatches lifecycle hooks at each hook point. + pub(crate) hook_runner: HookRunner, + // --- Lazily initialized --- live: OnceCell, @@ -180,6 +184,7 @@ impl BoxImpl { shutdown_token, disk_ops: tokio::sync::Mutex::new(()), event_listeners: Vec::new(), // populated from runtime options + hook_runner: HookRunner::new(vec![], config.options.hooks.clone()), live: OnceCell::new(), watcher: std::sync::OnceLock::new(), container_start: OnceCell::new(), @@ -232,6 +237,15 @@ impl BoxImpl { self.config.container.id.as_str() } + /// Resolve the image reference string for hook context. + fn resolve_image_string(&self) -> String { + use crate::runtime::options::RootfsSpec; + match &self.config.options.rootfs { + RootfsSpec::Image(r) => r.clone(), + RootfsSpec::RootfsPath(p) => format!("rootfs:{p}"), + } + } + /// Metadata for this box. Local reads are synchronous: everything comes /// from persisted state plus the bindings this handle itself published. pub(crate) fn info(&self) -> BoxInfo { @@ -293,11 +307,49 @@ impl BoxImpl { // makes `run` docker-shaped — create, attach, then start. `run` slips the // attach between these two calls; every other caller just wants both. let live = self.ensure_booted().await?; + + // ── Fire PreStart hooks (after ContainerInit, before ContainerStart) ── + let image = self.resolve_image_string(); + let box_status = self.state.read().status; + let hook_ctx = HookContext::new( + self.config.id.to_string(), + self.config.container.id.clone(), + crate::hooks::HookPoint::PreStart, + String::new(), // filled per-hook by the runner + box_status, + image, + 0, // fire_count filled by runner + ); + // Note: fire() fills in hook_name on a per-hook basis internally. + // We pass a template ctx; the runner updates fire_count + hook_name. + { + let mut ctx = hook_ctx.clone(); + self.hook_runner + .fire(crate::hooks::HookPoint::PreStart, &mut ctx, None) + .await?; + } + let started_now = self.ensure_container_started(live).await?; - // Announce the start only when *this* call actually ran init — not on an - // idempotent re-`start()` or a reattach to an already-running box. + // ── Fire PostStart hooks (after init starts) ── if started_now { + let box_status = self.state.read().status; + let image = self.resolve_image_string(); + let mut ctx = HookContext::new( + self.config.id.to_string(), + self.config.container.id.clone(), + crate::hooks::HookPoint::PostStart, + String::new(), + box_status, + image, + 0, + ); + // PostStart errors do not abort start (Continue policy by default) + let _ = self + .hook_runner + .fire(crate::hooks::HookPoint::PostStart, &mut ctx, Some(&live.guest_session)) + .await; + for listener in &self.event_listeners { listener.on_box_started(&self.config.id); } @@ -428,10 +480,32 @@ impl BoxImpl { _ => command, }; + // ── Fire PreExec hooks (before Exec RPC) ── + { + let image = self.resolve_image_string(); + let box_status = self.state.read().status; + let exec_cmd: Vec = std::iter::once(command.command.clone()) + .chain(command.args.clone()) + .collect(); + let mut ctx = crate::hooks::HookContext::for_pre_exec( + self.config.id.to_string(), + self.config.container.id.clone(), + String::new(), + box_status, + image, + 0, + exec_cmd, + ); + self.hook_runner + .fire(crate::hooks::HookPoint::PreExec, &mut ctx, None) + .await?; // Fail aborts exec + } + for listener in &self.event_listeners { listener.on_exec_started(&self.config.id, &command.command, &command.args); } + let t_exec_start = Instant::now(); let mut exec_interface = live.guest_session.execution().await?; let result = exec_interface .exec(command, self.shutdown_token.clone()) @@ -453,10 +527,58 @@ impl BoxImpl { } let components = result?; + + // ── Spawn PostExec hook task ── + // PostExec fires when the exec result arrives (after Wait). + // We tee the result_rx channel so the hook can read the exit code + // without consuming the channel the Execution handle needs. + let post_exec_hooks = self.hook_runner.clone(); + let post_exec_box_id = self.config.id.to_string(); + let post_exec_container_id = self.config.container.id.clone(); + let post_exec_image = self.resolve_image_string(); + let post_exec_command: Vec = + std::iter::once(command.command.clone()) + .chain(command.args.clone()) + .collect(); + let (tee_tx, tee_rx) = tokio::sync::mpsc::unbounded_channel(); + let original_rx = components.result_rx; + tokio::spawn(async move { + let mut rx = original_rx; + while let Some(result) = rx.recv().await { + let exit_code = result.exit_code; + let duration_ms = result + .duration + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let tx_closed = tee_tx.send(result).is_err(); + + // Fire PostExec hooks + let box_status = BoxStatus::Running; // box is still running at this point + let mut ctx = crate::hooks::HookContext::for_post_exec( + post_exec_box_id, + post_exec_container_id, + String::new(), + box_status, + post_exec_image, + 0, + post_exec_command, + exit_code, + duration_ms, + ); + let _ = post_exec_hooks + .fire(crate::hooks::HookPoint::PostExec, &mut ctx, None) + .await; + + if tx_closed { + break; + } + } + }); + Ok(Execution::new( components.execution_id, Box::new(exec_interface), - components.result_rx, + tee_rx, Some(ExecStdin::new(components.stdin_tx)), Some(ExecStdout::new(components.stdout_rx)), Some(ExecStderr::new(components.stderr_rx)), @@ -658,6 +780,26 @@ impl BoxImpl { // stop() must NOT do. let should_attach = self.state.read().status == BoxStatus::Running; if should_attach && let Ok(live) = self.live_state().await { + // ── Fire PreStop hooks (before guest shutdown) ── + { + let image = self.resolve_image_string(); + let box_status = self.state.read().status; + let mut ctx = HookContext::new( + self.config.id.to_string(), + self.config.container.id.clone(), + crate::hooks::HookPoint::PreStop, + String::new(), + box_status, + image, + 0, + ); + // PreStop errors do not abort stop (Continue policy by default) + let _ = self + .hook_runner + .fire(crate::hooks::HookPoint::PreStop, &mut ctx, Some(&live.guest_session)) + .await; + } + // Recovered boxes lazy-attach here via vmm_attach (now // ProcessIdentity-gated). Live boxes hit the cached LiveState. // Either way the teardown is identical: @@ -745,6 +887,26 @@ impl BoxImpl { self.runtime .invalidate_box_impl(self.id(), self.config.name.as_deref()); + // ── Fire PostStop hooks (guest is gone, no guest session) ── + { + let image = self.resolve_image_string(); + let box_status = self.state.read().status; + let mut ctx = HookContext::new( + self.config.id.to_string(), + self.config.container.id.clone(), + crate::hooks::HookPoint::PostStop, + String::new(), + box_status, + image, + 0, + ); + // PostStop errors never abort stop + let _ = self + .hook_runner + .fire(crate::hooks::HookPoint::PostStop, &mut ctx, None) + .await; + } + for listener in &self.event_listeners { listener.on_box_stopped(&self.config.id, None); } diff --git a/src/boxlite/src/runtime/options.rs b/src/boxlite/src/runtime/options.rs index 4872af189..c92c8fed6 100644 --- a/src/boxlite/src/runtime/options.rs +++ b/src/boxlite/src/runtime/options.rs @@ -431,6 +431,10 @@ pub struct BoxOptions { /// guest; the real value never enters the VM. #[serde(default)] pub secrets: Vec, + + /// Container lifecycle hooks. + #[serde(default)] + pub hooks: Vec, } /// A secret for MITM proxy injection. @@ -537,6 +541,7 @@ impl Default for BoxOptions { user: None, tty: false, secrets: Vec::new(), + hooks: Vec::new(), } } } diff --git a/src/boxlite/src/runtime/rt_impl.rs b/src/boxlite/src/runtime/rt_impl.rs index ac7b8bc50..2f9f62513 100644 --- a/src/boxlite/src/runtime/rt_impl.rs +++ b/src/boxlite/src/runtime/rt_impl.rs @@ -465,6 +465,28 @@ impl RuntimeImpl { )); } + // ── Fire PostCreate hooks (fires once, not on restart) ── + { + let image = match &box_impl.config.options.rootfs { + crate::runtime::options::RootfsSpec::Image(r) => r.clone(), + crate::runtime::options::RootfsSpec::RootfsPath(p) => format!("rootfs:{p}"), + }; + let mut ctx = crate::hooks::HookContext::new( + box_impl.config.id.to_string(), + box_impl.config.container.id.clone(), + crate::hooks::HookPoint::PostCreate, + String::new(), + crate::runtime::types::BoxStatus::Configured, + image, + 0, + ); + // PostCreate errors do not abort create (Continue policy by default) + let _ = box_impl + .hook_runner + .fire(crate::hooks::HookPoint::PostCreate, &mut ctx, None) + .await; + } + // Increment boxes_created counter (lock-free!) self.runtime_metrics .boxes_created diff --git a/src/cli/src/cli.rs b/src/cli/src/cli.rs index 5a995fc21..c30fe6749 100644 --- a/src/cli/src/cli.rs +++ b/src/cli/src/cli.rs @@ -980,6 +980,269 @@ impl ManagementFlags { } } +// ============================================================================ +// HOOK FLAGS +// ============================================================================ + +/// Container lifecycle hooks. +/// +/// Each `--hook` flag defines one hook. The simple syntax is: +/// `:::` with args via `--hook-arg`. +/// +/// Modifiers like `--hook-timeout` and `--hook-on-error` reference the hook +/// by name and must appear after the corresponding `--hook` flag. +#[derive(clap::Args, Debug, Clone, Default)] +pub struct HookFlags { + /// Register a hook (simple syntax: name:point:type:program). + #[arg(long = "hook", value_name = "SPEC")] + pub hooks: Vec, + + /// Register a hook via inline JSON. + #[arg(long = "hook-json", value_name = "JSON")] + pub hooks_json: Vec, + + /// Add an argument to the most recent --hook. + #[arg(long = "hook-arg", value_name = "ARG")] + pub hook_args: Vec, + + /// Enable or disable a hook by name. + #[arg(long = "hook-enabled", value_name = "NAME=BOOL", value_parser = parse_key_value)] + pub hook_enabled: Vec<(String, String)>, + + /// Set hook priority (lower = runs first). + #[arg(long = "hook-priority", value_name = "NAME=INT", value_parser = parse_key_value)] + pub hook_priority: Vec<(String, String)>, + + /// Set per-hook timeout in seconds. + #[arg(long = "hook-timeout", value_name = "NAME=SECS", value_parser = parse_key_value)] + pub hook_timeout: Vec<(String, String)>, + + /// Set error policy: fail, continue, or retry:N,backoff_s. + #[arg(long = "hook-on-error", value_name = "NAME=POLICY", value_parser = parse_key_value)] + pub hook_on_error: Vec<(String, String)>, + + /// PostExec condition filter. + #[arg(long = "hook-condition-exec-result", value_name = "NAME=COND", value_parser = parse_key_value)] + pub hook_condition_exec_result: Vec<(String, String)>, + + /// Add environment variable to a hook. + #[arg(long = "hook-env", value_name = "NAME=KEY=VALUE", value_parser = parse_key_value)] + pub hook_env: Vec<(String, String)>, + + /// GuestExec user. + #[arg(long = "hook-user", value_name = "NAME=USER", value_parser = parse_key_value)] + pub hook_user: Vec<(String, String)>, + + /// GuestExec working directory. + #[arg(long = "hook-workdir", value_name = "NAME=PATH", value_parser = parse_key_value)] + pub hook_workdir: Vec<(String, String)>, +} + +impl HookFlags { + /// Apply hook flags to BoxOptions. + pub fn apply_to(&self, opts: &mut BoxOptions) -> anyhow::Result<()> { + use boxlite::hooks::{ + ExecHookTrigger, Hook, HookAction, HookCondition, HookErrorPolicy, OnExhausted, + }; + + // Parse --hook flags (simple syntax) + for spec in &self.hooks { + let parts: Vec<&str> = spec.splitn(4, ':').collect(); + if parts.len() < 4 { + anyhow::bail!( + "Invalid --hook syntax '{}': expected :::", + spec + ); + } + let name = parts[0].to_string(); + let point = parse_hook_point(parts[1])?; + let hook_type = parts[2]; + let program = parts[3].to_string(); + + let action = match hook_type { + "host" => HookAction::HostExec { + program, + args: vec![], // filled by --hook-arg + env: vec![], + }, + "guest" => HookAction::GuestExec { + command: program, + args: vec![], + env: vec![], + user: None, + working_dir: None, + }, + _ => anyhow::bail!("Unknown hook type '{}': expected 'host' or 'guest'", hook_type), + }; + + opts.hooks.push(Hook { + name, + point, + action, + enabled: true, + priority: 0, + timeout_secs: 30, + condition: None, + on_error: point.default_on_error(), + }); + } + + // Parse --hook-json flags + for json_str in &self.hooks_json { + let hook: Hook = serde_json::from_str(json_str) + .map_err(|e| anyhow::anyhow!("Invalid --hook-json: {e}"))?; + opts.hooks.push(hook); + } + + // Apply --hook-arg to the most recent hook (from --hook, not --hook-json) + if !self.hook_args.is_empty() { + if let Some(last) = opts.hooks.last_mut() { + for arg in &self.hook_args { + match &mut last.action { + HookAction::HostExec { args, .. } => args.push(arg.clone()), + HookAction::GuestExec { args, .. } => args.push(arg.clone()), + } + } + } + } + + // Apply modifiers + for (name, val) in &self.hook_enabled { + let hook = find_hook_mut(opts, name)?; + hook.enabled = val.parse::().unwrap_or(true); + } + for (name, val) in &self.hook_priority { + let hook = find_hook_mut(opts, name)?; + hook.priority = val.parse().unwrap_or(0); + } + for (name, val) in &self.hook_timeout { + let hook = find_hook_mut(opts, name)?; + hook.timeout_secs = val.parse().unwrap_or(30); + } + for (name, val) in &self.hook_on_error { + let hook = find_hook_mut(opts, name)?; + hook.on_error = parse_error_policy(val)?; + } + for (name, val) in &self.hook_condition_exec_result { + let hook = find_hook_mut(opts, name)?; + hook.condition = Some(HookCondition::ExecResult { + trigger: parse_exec_trigger(val)?, + }); + } + for (name, val) in &self.hook_env { + let hook = find_hook_mut(opts, name)?; + let (k, v) = val.split_once('=') + .map(|(k, v)| (k.to_string(), v.to_string())) + .unwrap_or_else(|| (val.clone(), String::new())); + match &mut hook.action { + HookAction::HostExec { env, .. } | HookAction::GuestExec { env, .. } => { + env.push((k, v)); + } + } + } + for (name, val) in &self.hook_user { + let hook = find_hook_mut(opts, name)?; + if let HookAction::GuestExec { user, .. } = &mut hook.action { + *user = Some(val.clone()); + } + } + for (name, val) in &self.hook_workdir { + let hook = find_hook_mut(opts, name)?; + if let HookAction::GuestExec { working_dir, .. } = &mut hook.action { + *working_dir = Some(val.clone()); + } + } + + // Validate no duplicate hook names + let mut seen = std::collections::HashSet::new(); + for hook in &opts.hooks { + if !seen.insert(&hook.name) { + anyhow::bail!("Duplicate hook name '{}'", hook.name); + } + } + + Ok(()) + } +} + +/// Parse `key=value` for hook modifier flags. +fn parse_key_value(s: &str) -> Result<(String, String), String> { + let (k, v) = s.split_once('=') + .ok_or_else(|| format!("expected KEY=VALUE, got '{s}'"))?; + Ok((k.to_string(), v.to_string())) +} + +fn parse_hook_point(s: &str) -> anyhow::Result { + use boxlite::hooks::HookPoint; + match s { + "post-create" => Ok(HookPoint::PostCreate), + "pre-start" => Ok(HookPoint::PreStart), + "post-start" => Ok(HookPoint::PostStart), + "pre-stop" => Ok(HookPoint::PreStop), + "post-stop" => Ok(HookPoint::PostStop), + "pre-exec" => Ok(HookPoint::PreExec), + "post-exec" => Ok(HookPoint::PostExec), + "pre-snapshot" => Ok(HookPoint::PreSnapshot), + "post-snapshot" => Ok(HookPoint::PostSnapshot), + "pre-restore" => Ok(HookPoint::PreRestore), + "post-restore" => Ok(HookPoint::PostRestore), + _ => anyhow::bail!("Unknown hook point '{}'", s), + } +} + +fn parse_error_policy(s: &str) -> anyhow::Result { + use boxlite::hooks::{HookErrorPolicy, OnExhausted}; + match s { + "fail" => Ok(HookErrorPolicy::Fail), + "continue" => Ok(HookErrorPolicy::Continue), + _ if s.starts_with("retry:") => { + let parts: Vec<&str> = s[6..].split(',').collect(); + if parts.len() < 2 { + anyhow::bail!("retry policy needs max_retries,backoff_secs: got '{s}'"); + } + let max_retries: u32 = parts[0].parse()?; + let backoff_secs: u64 = parts[1].parse()?; + let on_exhausted = parts.get(2).map_or(OnExhausted::Continue, |oe| match *oe { + "fail" => OnExhausted::Fail, + _ => OnExhausted::Continue, + }); + Ok(HookErrorPolicy::Retry { + max_retries, + backoff_secs, + on_exhausted, + }) + } + _ => anyhow::bail!("Unknown error policy '{}'", s), + } +} + +fn parse_exec_trigger(s: &str) -> anyhow::Result { + use boxlite::hooks::ExecHookTrigger; + match s { + "always" => Ok(ExecHookTrigger::Always), + "success" => Ok(ExecHookTrigger::OnSuccess), + "failure" => Ok(ExecHookTrigger::OnFailure), + _ if s.starts_with("exit:") => { + let code: i32 = s[5..].parse()?; + Ok(ExecHookTrigger::ExitCode(code)) + } + _ if s.starts_with("cmd:") => { + Ok(ExecHookTrigger::CommandMatches(s[4..].to_string())) + } + _ => anyhow::bail!("Unknown exec trigger '{}'", s), + } +} + +fn find_hook_mut<'a>( + opts: &'a mut BoxOptions, + name: &str, +) -> anyhow::Result<&'a mut boxlite::hooks::Hook> { + opts.hooks + .iter_mut() + .find(|h| h.name == name) + .ok_or_else(|| anyhow::anyhow!("No hook named '{}' found", name)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/src/commands/create.rs b/src/cli/src/commands/create.rs index 43e795eef..755281045 100644 --- a/src/cli/src/commands/create.rs +++ b/src/cli/src/commands/create.rs @@ -1,6 +1,6 @@ use crate::cli::{ - CapabilityFlags, GlobalFlags, KernelFlags, NetworkFlags, PublishFlags, ResourceFlags, - VolumeFlags, + CapabilityFlags, GlobalFlags, HookFlags, KernelFlags, NetworkFlags, PublishFlags, + ResourceFlags, VolumeFlags, }; use boxlite::{BoxOptions, RootfsSpec}; use clap::Args; @@ -19,6 +19,9 @@ pub struct CreateArgs { #[command(flatten)] pub management: crate::cli::ManagementFlags, + #[command(flatten)] + pub hook: HookFlags, + /// Set environment variables #[arg(short = 'e', long = "env")] pub env: Vec, @@ -76,6 +79,7 @@ impl CreateArgs { self.capability.apply_to(&mut options); self.boot.apply_to(&mut options); self.management.apply_to(&mut options)?; + self.hook.apply_to(&mut options)?; self.publish.apply_to(&mut options)?; self.volume.apply_to(&mut options, global.home.as_deref())?; self.network.apply_to(&mut options)?; diff --git a/src/cli/src/commands/run.rs b/src/cli/src/commands/run.rs index 76ec4dc76..c4a05a583 100644 --- a/src/cli/src/commands/run.rs +++ b/src/cli/src/commands/run.rs @@ -1,5 +1,5 @@ use crate::cli::{ - CapabilityFlags, GlobalFlags, KernelFlags, ManagementFlags, NetworkFlags, ProcessFlags, + CapabilityFlags, GlobalFlags, HookFlags, KernelFlags, ManagementFlags, NetworkFlags, ProcessFlags, PublishFlags, ResourceFlags, VolumeFlags, }; use crate::terminal::StreamManager; @@ -31,6 +31,9 @@ pub struct RunArgs { #[command(flatten)] pub management: ManagementFlags, + #[command(flatten)] + pub hook: HookFlags, + /// Path to an already prepared rootfs #[arg(long = "rootfs", value_name = "PATH")] pub rootfs: Option, @@ -133,6 +136,7 @@ impl BoxRunner { self.args.capability.apply_to(&mut options); self.args.boot.apply_to(&mut options); self.args.management.apply_to(&mut options)?; + self.args.hook.apply_to(&mut options)?; self.args.publish.apply_to(&mut options)?; self.args .volume