diff --git a/components/execd/tests/init_container.sh b/components/execd/tests/init_container.sh index 626fd2d54..3b83405d3 100755 --- a/components/execd/tests/init_container.sh +++ b/components/execd/tests/init_container.sh @@ -24,6 +24,8 @@ # (JUPYTER_TOKEN) while execd's credential (EXECD_ACCESS_TOKEN) is # stripped by the launcher # - on a non-PID-1 topology execd degrades to subreaper and says so +# - while preStart is blocked, /ping stays ready and both the entrypoint +# and periodic hooks wait; lifecycle transport is stripped from user code # # Exit-code propagation, runtime SIGTERM forwarding and the rest of the # hardening floor are covered by the Python e2e suites @@ -87,7 +89,7 @@ wait_file() { dump_container_logs() { local c="$1" echo ">> Container logs for ${c}:" - docker logs "$c" 2>&1 | grep -E "launcher|landlock|FAIL|init:|hardening|exited" | tail -30 || true + docker logs "$c" 2>&1 | grep -E "launcher|landlock|FAIL|init:|hardening|exited|lifecycle|error:" | tail -30 || true } echo "=========================================" @@ -258,6 +260,70 @@ grep -q "subreaper_ok=yes" "${TESTDIR}/subreaper.out" || fail "test 3: subreaper docker rm -f "$C3" >/dev/null echo "PASS: subreaper degradation reported" +# ------------------------------------------------------------------- +# Test 4: init mode runs lifecycle hooks before and alongside workload. +# ------------------------------------------------------------------- +echo "" +echo ">> Test 4: init-mode lifecycle hooks" + +cat > "${TESTDIR}/lifecycle.sh" <<'SCRIPT' +#!/bin/sh +set -eu +out=/mnt/test/lifecycle.out +touch /mnt/test/entrypoint.started +[ "$(cat /proc/1/comm)" = "execd" ] || { echo "FAIL: execd is not PID 1" > "$out"; exit 98; } +[ -f /mnt/test/prestart.done ] || { echo "FAIL: preStart did not finish before entrypoint" > "$out"; exit 99; } +[ -z "${OPENSANDBOX_LIFECYCLE:-}" ] || { echo "FAIL: lifecycle transport leaked" > "$out"; exit 100; } + +i=0 +while [ ! -f /mnt/test/periodic.twice ] && [ "$i" -lt 100 ]; do + sleep 0.2 + i=$((i+1)) +done +[ -f /mnt/test/periodic.twice ] \ + || { echo "FAIL: periodic hook did not run twice" > "$out"; exit 101; } +printf 'lifecycle_hooks_ok=yes\n' > "$out" +exit 0 +SCRIPT +chmod +x "${TESTDIR}/lifecycle.sh" + +C4="${PREFIX}-t4" +RUNNERS+=("$C4") +docker run -d --name "$C4" \ + --entrypoint /bootstrap.sh \ + -e EXECD=/execd \ + -e EXECD_INIT=1 \ + -e 'OPENSANDBOX_LIFECYCLE={"preStart":{"command":["/bin/sh","-c","touch /mnt/test/prestart.started; while [ ! -f /mnt/test/prestart.release ]; do sleep 0.1; done; touch /mnt/test/prestart.done"],"timeoutSeconds":300},"periodic":[{"name":"checkpoint","schedule":"@every 1s","command":["/bin/sh","-c","touch /mnt/test/periodic.started; echo periodic >> /mnt/test/periodic.sequence; test $(grep -c periodic /mnt/test/periodic.sequence) -ge 2 && touch /mnt/test/periodic.twice || true"]}]}' \ + -v "${TESTDIR}:/mnt/test" \ + "${IMAGE}" \ + /mnt/test/lifecycle.sh >/dev/null +if ! wait_file "${TESTDIR}/prestart.started"; then + dump_container_logs "$C4" + fail "test 4: preStart did not reach the startup barrier" +fi +if ! docker exec "$C4" /bin/sh -c \ + 'i=0; while [ "$i" -lt 30 ]; do wget -qO- http://127.0.0.1:44772/ping >/dev/null && exit 0; sleep 0.5; i=$((i+1)); done; exit 1'; then + dump_container_logs "$C4" + fail "test 4: execd /ping was unavailable while preStart was blocked" +fi +# Keep the barrier closed long enough to observe an incorrectly started entrypoint or periodic hook. +sleep 2 +[ ! -f "${TESTDIR}/entrypoint.started" ] \ + || fail "test 4: entrypoint started before preStart was released" +[ ! -f "${TESTDIR}/periodic.started" ] \ + || fail "test 4: periodic hook started before preStart was released" +touch "${TESTDIR}/prestart.release" +if ! wait_file "${TESTDIR}/lifecycle.out"; then + dump_container_logs "$C4" + fail "test 4: container did not produce lifecycle.out" +fi +RC=$(docker wait "$C4") +[ "$RC" = "0" ] || fail "test 4: container exited $RC: $(cat "${TESTDIR}/lifecycle.out")" +grep -q "lifecycle_hooks_ok=yes" "${TESTDIR}/lifecycle.out" \ + || fail "test 4: lifecycle assertions failed: $(cat "${TESTDIR}/lifecycle.out")" +docker rm -f "$C4" >/dev/null +echo "PASS: init-mode health availability, preStart ordering, periodic execution, and environment isolation" + # ------------------------------------------------------------------- echo "" echo "=========================================" @@ -265,4 +331,4 @@ echo " Init-mode container regression PASSED" echo "=========================================" echo " image: ${IMAGE}" echo " cases: pid1 handoff / reaping / signal shield /" -echo " env inheritance / subreaper" +echo " env inheritance / subreaper / lifecycle hooks" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 86095b8c9..6f89e6def 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -96,6 +96,7 @@ export default defineConfig({ { text: "Multi-Tenancy", link: "/guides/multi-tenancy" }, { text: "Isolation Sessions", link: "/guides/isolation-sessions" }, { text: "Pause & Resume", link: "/guides/pause-resume" }, + { text: "Lifecycle Hooks", link: "/guides/lifecycle-hooks" }, { text: "Windows Sandbox", link: "/guides/windows-sandbox" }, { text: "Client Pool", link: "/guides/client-pool" }, { text: "SDK Telemetry", link: "/guides/sdk-telemetry" }, diff --git a/docs/guides/lifecycle-hooks.md b/docs/guides/lifecycle-hooks.md new file mode 100644 index 000000000..f5614f0db --- /dev/null +++ b/docs/guides/lifecycle-hooks.md @@ -0,0 +1,90 @@ +--- +title: Lifecycle Hooks +description: Run setup work before a sandbox entrypoint and schedule recurring work while it is running. +--- + +# Lifecycle Hooks + +Lifecycle hooks let a sandbox run declarative commands at defined points in its runtime. OpenSandbox currently supports a blocking `preStart` hook and one or more non-blocking `periodic` hooks. + +## Supported hooks + +| Hook | Cardinality | When it runs | Failure behavior | +|---|---:|---|---| +| `preStart` | Zero or one | After execd starts listening, before the user entrypoint starts | A failure or timeout prevents the user entrypoint from starting | +| `periodic` | Zero or more | The scheduler starts after `preStart` succeeds and runs each hook on its schedule | A failed or timed-out run is logged and later runs normally continue; if a timed-out process cannot be terminated, that hook is disabled | + +Both hooks run inside the sandbox with the sandbox environment. Commands are argument arrays and are executed directly. To use shell syntax such as pipes, redirects, or variable expansion, invoke a shell explicitly, for example `['sh', '-c', 'command > file']`. + +![Lifecycle hook timing in bootstrap and execd-as-init modes](/images/lifecycle-hooks-startup.png) + +The ordering is the same in both startup modes: + +1. execd starts and its HTTP server begins listening. +2. `preStart` runs to completion, if configured. +3. The periodic scheduler starts, if configured. +4. The user entrypoint starts. + +In execd-as-init mode, execd remains PID 1 and starts the entrypoint as its supervised child. In bootstrap mode, the bootstrap process starts the entrypoint after execd reports that lifecycle startup completed. + +## Configuration + +Lifecycle hooks are part of the sandbox creation request: + +```json +{ + "image": { "uri": "ubuntu:24.04" }, + "entrypoint": ["tail", "-f", "/dev/null"], + "resourceLimits": { + "cpu": "1", + "memory": "1Gi" + }, + "lifecycle": { + "preStart": { + "command": ["sh", "-c", "echo ready > /tmp/prestart.done"], + "timeoutSeconds": 120 + }, + "periodic": [ + { + "name": "checkpoint", + "schedule": "@every 5m", + "command": ["sh", "-c", "date -u >> /tmp/checkpoints.log"], + "timeoutSeconds": 120 + } + ] + } +} +``` + +### `preStart` + +| Field | Required | Description | +|---|---:|---| +| `command` | Yes | Non-empty command and argument array | +| `timeoutSeconds` | No | When omitted, defaults to 60 seconds; explicit values must be from 1 through 300 seconds | + +`preStart` runs on each container start. Make the command idempotent so retrying or restarting a sandbox does not corrupt its state. + +### `periodic` + +| Field | Required | Description | +|---|---:|---| +| `name` | Yes | Non-blank name, unique within the sandbox | +| `schedule` | Yes | Standard five-field cron expression or descriptor such as `@hourly` or `@every 30s` | +| `command` | Yes | Non-empty command and argument array | +| `timeoutSeconds` | No | When omitted, defaults to 60 seconds; explicit values must be from 1 through 300 seconds | + +An `@every` interval must be a whole number of seconds and at least one second. Runs of the same named hook never overlap: if the previous run is still active, the next scheduled run is skipped. + +## Current availability + +Lifecycle hooks currently require the Kubernetes provider. They are rejected by the Docker provider and Fleets backend. A request cannot combine `lifecycle` with `poolRef`. + +The SDKs expose lifecycle fields on their sandbox creation APIs but do not enforce the timeout range. The Server is the authority for request validation. See the language-specific examples in the [SDK documentation](/sdks/). + +## Operational guidance + +- Keep `preStart` bounded and deterministic because the entrypoint waits for it. +- Make periodic work idempotent and safe to retry. +- Store credentials through the platform's supported secret mechanism rather than embedding them in commands. +- Write data that must survive node replacement to durable storage. Lifecycle hooks schedule recovery and synchronization work; they do not make the sandbox filesystem durable by themselves. diff --git a/docs/public/images/lifecycle-hooks-startup.drawio b/docs/public/images/lifecycle-hooks-startup.drawio new file mode 100644 index 000000000..4cd6c6f15 --- /dev/null +++ b/docs/public/images/lifecycle-hooks-startup.drawio @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/images/lifecycle-hooks-startup.png b/docs/public/images/lifecycle-hooks-startup.png new file mode 100644 index 000000000..bc07b4e84 Binary files /dev/null and b/docs/public/images/lifecycle-hooks-startup.png differ diff --git a/docs/sdks/csharp.md b/docs/sdks/csharp.md index e02b49407..e54c75e60 100644 --- a/docs/sdks/csharp.md +++ b/docs/sdks/csharp.md @@ -64,6 +64,40 @@ catch (SandboxException ex) } ``` +## Lifecycle Hooks + +Set `Lifecycle` in `SandboxCreateOptions`. `PreStart` completes before the entrypoint starts, while `Periodic` hooks run on their schedules after startup. + +```csharp +using OpenSandbox.Models; + +await using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions +{ + ConnectionConfig = config, + Image = "ubuntu:24.04", + Lifecycle = new SandboxLifecycle + { + PreStart = new LifecycleHook + { + Command = new[] { "sh", "-c", "echo ready > /tmp/prestart.done" }, + TimeoutSeconds = 120, + }, + Periodic = new[] + { + new PeriodicLifecycleHook + { + Name = "checkpoint", + Schedule = "@every 5m", + Command = new[] { "sh", "-c", "date -u >> /tmp/checkpoints.log" }, + TimeoutSeconds = 120, + }, + }, + }, +}); +``` + +The Server validates `TimeoutSeconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations. + ## Usage Examples ### 1. Lifecycle Management diff --git a/docs/sdks/go.md b/docs/sdks/go.md index 884b0d8c3..bcf91cca3 100644 --- a/docs/sdks/go.md +++ b/docs/sdks/go.md @@ -303,6 +303,33 @@ pool, err := opensandbox.NewSandboxPoolBuilder(). - Configure `PrimaryLockTTL` greater than `WarmupReadyTimeout` plus expected warmup preparer time. ::: +## Lifecycle Hooks + +Set `Lifecycle` in `SandboxCreateOptions`. `PreStart` completes before the entrypoint starts, while `Periodic` hooks run on their schedules after startup. + +```go +hookTimeout := 120 +sandbox, err := opensandbox.CreateSandbox(ctx, config, opensandbox.SandboxCreateOptions{ + Image: "ubuntu:24.04", + Lifecycle: &opensandbox.SandboxLifecycle{ + PreStart: &opensandbox.LifecycleHook{ + Command: []string{"sh", "-c", "echo ready > /tmp/prestart.done"}, + TimeoutSeconds: &hookTimeout, + }, + Periodic: []opensandbox.PeriodicLifecycleHook{ + { + Name: "checkpoint", + Schedule: "@every 5m", + Command: []string{"sh", "-c", "date -u >> /tmp/checkpoints.log"}, + TimeoutSeconds: &hookTimeout, + }, + }, + }, +}) +``` + +The Server validates `TimeoutSeconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations. + ## API Reference ### LifecycleClient diff --git a/docs/sdks/javascript.md b/docs/sdks/javascript.md index 567278326..bdcdd824e 100644 --- a/docs/sdks/javascript.md +++ b/docs/sdks/javascript.md @@ -70,6 +70,33 @@ try { } ``` +## Lifecycle Hooks + +Set `lifecycle` in `Sandbox.create`. `preStart` completes before the entrypoint starts, while `periodic` hooks run on their schedules after startup. + +```ts +const sandbox = await Sandbox.create({ + connectionConfig: config, + image: "ubuntu:24.04", + lifecycle: { + preStart: { + command: ["sh", "-c", "echo ready > /tmp/prestart.done"], + timeoutSeconds: 120, + }, + periodic: [ + { + name: "checkpoint", + schedule: "@every 5m", + command: ["sh", "-c", "date -u >> /tmp/checkpoints.log"], + timeoutSeconds: 120, + }, + ], + }, +}); +``` + +The Server validates `timeoutSeconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations. + ## Usage Examples ### 1. Lifecycle Management diff --git a/docs/sdks/kotlin.md b/docs/sdks/kotlin.md index 1e8cb6349..1faea4a8e 100644 --- a/docs/sdks/kotlin.md +++ b/docs/sdks/kotlin.md @@ -77,6 +77,37 @@ public class QuickStart { } ``` +## Lifecycle Hooks + +Configure lifecycle hooks on `Sandbox.Builder`. `preStart` completes before the entrypoint starts, while `periodic` hooks run on their schedules after startup. + +```java +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.LifecycleHook; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PeriodicLifecycleHook; +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxLifecycle; + +SandboxLifecycle lifecycle = SandboxLifecycle.builder() + .preStart(LifecycleHook.builder() + .command("sh", "-c", "echo ready > /tmp/prestart.done") + .timeoutSeconds(120) + .build()) + .periodic(PeriodicLifecycleHook.builder() + .name("checkpoint") + .schedule("@every 5m") + .command("sh", "-c", "date -u >> /tmp/checkpoints.log") + .timeoutSeconds(120) + .build()) + .build(); + +Sandbox sandbox = Sandbox.builder() + .connectionConfig(config) + .image("ubuntu:24.04") + .lifecycle(lifecycle) + .build(); +``` + +The Server validates `timeoutSeconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations. + ## Usage Examples ### 1. Lifecycle Management diff --git a/docs/sdks/python.md b/docs/sdks/python.md index 73b45e352..64d328e33 100644 --- a/docs/sdks/python.md +++ b/docs/sdks/python.md @@ -257,6 +257,39 @@ For async pools, pass a `redis.asyncio` client to `AsyncRedisPoolStateStore`. does not bypass shared state. ::: +## Lifecycle Hooks + +Pass a `SandboxLifecycle` when creating a sandbox. `pre_start` completes before the entrypoint starts, while `periodic` hooks run on their schedules after startup. + +```python +from opensandbox.models.sandboxes import ( + LifecycleHook, + PeriodicLifecycleHook, + SandboxLifecycle, +) + +sandbox = await Sandbox.create( + "ubuntu:24.04", + connection_config=config, + lifecycle=SandboxLifecycle( + pre_start=LifecycleHook( + command=["sh", "-c", "echo ready > /tmp/prestart.done"], + timeout_seconds=120, + ), + periodic=[ + PeriodicLifecycleHook( + name="checkpoint", + schedule="@every 5m", + command=["sh", "-c", "date -u >> /tmp/checkpoints.log"], + timeout_seconds=120, + ) + ], + ), +) +``` + +The Server validates `timeout_seconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations. + ## Usage Examples ### 1. Lifecycle Management diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/LifecycleModels.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/LifecycleModels.kt index 04d89f9d9..80dfae71b 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/LifecycleModels.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/LifecycleModels.kt @@ -16,8 +16,6 @@ package com.alibaba.opensandbox.sandbox.domain.models.sandboxes -private const val MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS = 300 - /** Command executed by execd before the user entrypoint starts. */ class LifecycleHook private constructor( val command: List, @@ -43,9 +41,6 @@ class LifecycleHook private constructor( fun command(vararg command: String): Builder = command(command.toList()) fun timeoutSeconds(timeoutSeconds: Int): Builder { - require(timeoutSeconds in 1..MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS) { - "Lifecycle hook timeoutSeconds must be between 1 and $MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS" - } this.timeoutSeconds = timeoutSeconds return this } @@ -98,9 +93,6 @@ class PeriodicLifecycleHook private constructor( fun command(vararg command: String): Builder = command(command.toList()) fun timeoutSeconds(timeoutSeconds: Int): Builder { - require(timeoutSeconds in 1..MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS) { - "Periodic lifecycle hook timeoutSeconds must be between 1 and $MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS" - } this.timeoutSeconds = timeoutSeconds return this } diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxLifecycleModelsTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxLifecycleModelsTest.kt index 7d8a0e72b..037185f81 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxLifecycleModelsTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxLifecycleModelsTest.kt @@ -33,19 +33,18 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxLifecycle class SandboxLifecycleModelsTest { @Test - fun `stable lifecycle builders reject timeout above maximum`() { - assertEquals(300, DomainLifecycleHook.builder().command("true").timeoutSeconds(300).build().timeoutSeconds) - - assertThrows(IllegalArgumentException::class.java) { - DomainLifecycleHook.builder().command("true").timeoutSeconds(301) - } - assertThrows(IllegalArgumentException::class.java) { + fun `stable lifecycle builders preserve timeout for server validation`() { + assertEquals(0, DomainLifecycleHook.builder().command("true").timeoutSeconds(0).build().timeoutSeconds) + assertEquals( + 301, DomainPeriodicLifecycleHook.builder() .name("sync") .schedule("@hourly") .command("true") .timeoutSeconds(301) - } + .build() + .timeoutSeconds, + ) } @Test diff --git a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py index 801d1bf5c..975d457c6 100644 --- a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py +++ b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py @@ -25,8 +25,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -_MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS = 300 - class SandboxImageAuth(BaseModel): """ @@ -168,9 +166,7 @@ class LifecycleHook(BaseModel): timeout_seconds: int | None = Field( default=None, alias="timeoutSeconds", - ge=1, - le=_MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS, - description="Maximum execution time in seconds, up to 300. The server defaults to 60.", + description="Maximum execution time in seconds. The server validates the value and defaults to 60.", ) @field_validator("command") @@ -192,9 +188,7 @@ class PeriodicLifecycleHook(BaseModel): timeout_seconds: int | None = Field( default=None, alias="timeoutSeconds", - ge=1, - le=_MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS, - description="Maximum execution time in seconds, up to 300. The server defaults to 60.", + description="Maximum execution time in seconds. The server validates the value and defaults to 60.", ) @field_validator("name", "schedule") diff --git a/sdks/sandbox/python/tests/test_models_stability.py b/sdks/sandbox/python/tests/test_models_stability.py index 73c0d2b78..f9ba2114c 100644 --- a/sdks/sandbox/python/tests/test_models_stability.py +++ b/sdks/sandbox/python/tests/test_models_stability.py @@ -69,17 +69,21 @@ ) -@pytest.mark.parametrize("hook_type", [DomainLifecycleHook, DomainPeriodicLifecycleHook]) -def test_lifecycle_hooks_reject_timeout_above_maximum(hook_type: type) -> None: - kwargs: dict[str, object] = {"command": ["true"], "timeoutSeconds": 300} +@pytest.mark.parametrize( + "hook_type", [DomainLifecycleHook, DomainPeriodicLifecycleHook] +) +@pytest.mark.parametrize("timeout_seconds", [0, 301]) +def test_lifecycle_hooks_preserve_timeout_for_server_validation( + hook_type: type, timeout_seconds: int +) -> None: + kwargs: dict[str, object] = { + "command": ["true"], + "timeoutSeconds": timeout_seconds, + } if hook_type is DomainPeriodicLifecycleHook: kwargs.update(name="sync", schedule="@hourly") - assert hook_type.model_validate(kwargs).timeout_seconds == 300 - - kwargs["timeoutSeconds"] = 301 - with pytest.raises(ValueError, match="less than or equal to 300"): - hook_type.model_validate(kwargs) + assert hook_type.model_validate(kwargs).timeout_seconds == timeout_seconds def test_lifecycle_hooks_reject_blank_commands_and_normalize_periodic_text() -> None: