Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions components/execd/tests/init_container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "========================================="
Expand Down Expand Up @@ -258,11 +260,75 @@ 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 across at least one @every 1s tick.
Comment thread
Pangjiping marked this conversation as resolved.
Outdated
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 "========================================="
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"
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
90 changes: 90 additions & 0 deletions docs/guides/lifecycle-hooks.md
Original file line number Diff line number Diff line change
@@ -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", "/opt/hooks/restore.sh"],
Comment thread
Pangjiping marked this conversation as resolved.
Outdated
"timeoutSeconds": 120
},
"periodic": [
{
"name": "checkpoint",
"schedule": "@every 5m",
"command": ["sh", "-c", "/opt/hooks/checkpoint.sh"],
"timeoutSeconds": 120
}
]
}
}
```

### `preStart`

| Field | Required | Description |
|---|---:|---|
| `command` | Yes | Non-empty command and argument array |
| `timeoutSeconds` | No | Server-validated timeout from 1 through 300 seconds; defaults to 60 seconds |
Comment thread
Pangjiping marked this conversation as resolved.
Outdated

`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 | Server-validated timeout from 1 through 300 seconds; defaults to 60 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.
121 changes: 121 additions & 0 deletions docs/public/images/lifecycle-hooks-startup.drawio
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="claude-code" agent="claude-code-drawio-skill" version="24.7.17" type="device">
<diagram name="Lifecycle hook timing" id="lifecycle-hook-timing">
<mxGraphModel dx="1200" dy="640" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="640" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>

<mxCell id="title" value="Lifecycle hook timing" style="text;html=1;align=left;verticalAlign=middle;fontFamily=Helvetica;fontSize=24;fontStyle=1;fontColor=#0F172A" vertex="1" parent="1">
<mxGeometry x="60" y="32" width="500" height="34" as="geometry"/>
</mxCell>
<mxCell id="subtitle" value="preStart is a startup gate; periodic hooks begin only after startup succeeds." style="text;html=1;align=left;verticalAlign=middle;fontFamily=Helvetica;fontSize=13;fontColor=#64748B" vertex="1" parent="1">
<mxGeometry x="60" y="68" width="760" height="24" as="geometry"/>
</mxCell>

<mxCell id="startup-dot" value="" style="ellipse;html=1;aspect=fixed;fillColor=#2563EB;strokeColor=none" vertex="1" parent="1">
<mxGeometry x="60" y="113" width="10" height="10" as="geometry"/>
</mxCell>
<mxCell id="startup-label" value="Sandbox startup" style="text;html=1;align=left;verticalAlign=middle;fontFamily=Helvetica;fontSize=14;fontStyle=1;fontColor=#334155" vertex="1" parent="1">
<mxGeometry x="82" y="105" width="240" height="26" as="geometry"/>
</mxCell>

<mxCell id="container-starts" value="Container starts" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#FFFFFF;strokeColor=#94A3B8;strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontColor=#0F172A" vertex="1" parent="1">
<mxGeometry x="60" y="150" width="150" height="72" as="geometry"/>
</mxCell>
<mxCell id="execd-ready" value="execd ready" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#FFFFFF;strokeColor=#64748B;strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontColor=#0F172A" vertex="1" parent="1">
<mxGeometry x="270" y="150" width="150" height="72" as="geometry"/>
</mxCell>
<mxCell id="prestart" value="&lt;b&gt;preStart&lt;/b&gt;&lt;br&gt;&lt;font color=&quot;#92400E&quot; size=&quot;2&quot;&gt;startup gate&lt;/font&gt;" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#FFF7ED;strokeColor=#F59E0B;strokeWidth=1.5;fontFamily=Helvetica;fontSize=15;fontColor=#78350F" vertex="1" parent="1">
<mxGeometry x="480" y="150" width="150" height="72" as="geometry"/>
</mxCell>
<mxCell id="entrypoint-starts" value="User entrypoint starts" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#FFFFFF;strokeColor=#64748B;strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontColor=#0F172A" vertex="1" parent="1">
<mxGeometry x="690" y="150" width="180" height="72" as="geometry"/>
</mxCell>
<mxCell id="workload-running" value="User workload running" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#F0FDF4;strokeColor=#22C55E;strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontStyle=1;fontColor=#166534" vertex="1" parent="1">
<mxGeometry x="930" y="150" width="210" height="72" as="geometry"/>
</mxCell>

<mxCell id="startup-failed" value="&lt;b&gt;Failure or timeout&lt;/b&gt;&lt;br&gt;&lt;font color=&quot;#991B1B&quot; size=&quot;2&quot;&gt;entrypoint is not started&lt;/font&gt;" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#FEF2F2;strokeColor=#EF4444;strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontColor=#991B1B" vertex="1" parent="1">
<mxGeometry x="455" y="280" width="200" height="72" as="geometry"/>
</mxCell>

<mxCell id="divider" value="" style="rounded=0;html=1;fillColor=#E2E8F0;strokeColor=none" vertex="1" parent="1">
<mxGeometry x="60" y="390" width="1080" height="1" as="geometry"/>
</mxCell>
<mxCell id="running-dot" value="" style="ellipse;html=1;aspect=fixed;fillColor=#16A34A;strokeColor=none" vertex="1" parent="1">
<mxGeometry x="60" y="427" width="10" height="10" as="geometry"/>
</mxCell>
<mxCell id="running-label" value="While the sandbox is running" style="text;html=1;align=left;verticalAlign=middle;fontFamily=Helvetica;fontSize=14;fontStyle=1;fontColor=#334155" vertex="1" parent="1">
<mxGeometry x="82" y="419" width="320" height="26" as="geometry"/>
</mxCell>

<mxCell id="scheduler" value="Periodic scheduler" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#FFFFFF;strokeColor=#64748B;strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontColor=#0F172A" vertex="1" parent="1">
<mxGeometry x="60" y="475" width="190" height="64" as="geometry"/>
</mxCell>
<mxCell id="periodic-1" value="periodic" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#F0FDF4;strokeColor=#16A34A;strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontStyle=1;fontColor=#166534" vertex="1" parent="1">
<mxGeometry x="330" y="475" width="140" height="64" as="geometry"/>
</mxCell>
<mxCell id="periodic-2" value="periodic" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#F0FDF4;strokeColor=#16A34A;strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontStyle=1;fontColor=#166534" vertex="1" parent="1">
<mxGeometry x="550" y="475" width="140" height="64" as="geometry"/>
</mxCell>
<mxCell id="periodic-3" value="periodic" style="rounded=1;arcSize=12;html=1;whiteSpace=wrap;fillColor=#F0FDF4;strokeColor=#16A34A;strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontStyle=1;fontColor=#166534" vertex="1" parent="1">
<mxGeometry x="770" y="475" width="140" height="64" as="geometry"/>
</mxCell>
<mxCell id="continuation" value="…" style="ellipse;html=1;aspect=fixed;fillColor=#F8FAFC;strokeColor=#94A3B8;strokeWidth=1.5;fontFamily=Helvetica;fontSize=22;fontColor=#475569" vertex="1" parent="1">
<mxGeometry x="990" y="475" width="64" height="64" as="geometry"/>
</mxCell>

<mxCell id="mode-note" value="Bootstrap mode and execd-as-init mode expose the same hook timing." style="text;html=1;align=center;verticalAlign=middle;fontFamily=Helvetica;fontSize=12;fontColor=#64748B" vertex="1" parent="1">
<mxGeometry x="60" y="580" width="1080" height="24" as="geometry"/>
</mxCell>

<mxCell id="e-container-execd" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#64748B;strokeWidth=2;endArrow=block;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="container-starts" target="execd-ready">
<mxGeometry relative="1" as="geometry">
<Array as="points"><mxPoint x="240" y="186"/></Array>
</mxGeometry>
</mxCell>
<mxCell id="e-execd-prestart" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#64748B;strokeWidth=2;endArrow=block;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="execd-ready" target="prestart">
<mxGeometry relative="1" as="geometry">
<Array as="points"><mxPoint x="450" y="186"/></Array>
</mxGeometry>
</mxCell>
<mxCell id="e-prestart-entrypoint" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#64748B;strokeWidth=2;endArrow=block;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="prestart" target="entrypoint-starts">
<mxGeometry relative="1" as="geometry">
<Array as="points"><mxPoint x="660" y="186"/></Array>
</mxGeometry>
</mxCell>
<mxCell id="e-entrypoint-workload" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#64748B;strokeWidth=2;endArrow=block;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="entrypoint-starts" target="workload-running">
<mxGeometry relative="1" as="geometry">
<Array as="points"><mxPoint x="900" y="186"/></Array>
</mxGeometry>
</mxCell>
<mxCell id="e-prestart-failure" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#DC2626;strokeWidth=2;endArrow=block;endFill=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0" edge="1" parent="1" source="prestart" target="startup-failed">
<mxGeometry relative="1" as="geometry">
<Array as="points"><mxPoint x="555" y="250"/></Array>
</mxGeometry>
</mxCell>
<mxCell id="e-scheduler-periodic-1" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#16A34A;strokeWidth=2;endArrow=block;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="scheduler" target="periodic-1">
<mxGeometry relative="1" as="geometry">
<Array as="points"><mxPoint x="290" y="507"/></Array>
</mxGeometry>
</mxCell>
<mxCell id="e-periodic-1-2" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#16A34A;strokeWidth=2;endArrow=block;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="periodic-1" target="periodic-2">
<mxGeometry relative="1" as="geometry">
<Array as="points"><mxPoint x="510" y="507"/></Array>
</mxGeometry>
</mxCell>
<mxCell id="e-periodic-2-3" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#16A34A;strokeWidth=2;endArrow=block;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="periodic-2" target="periodic-3">
<mxGeometry relative="1" as="geometry">
<Array as="points"><mxPoint x="730" y="507"/></Array>
</mxGeometry>
</mxCell>
<mxCell id="e-periodic-3-continuation" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#16A34A;strokeWidth=2;endArrow=block;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0" edge="1" parent="1" source="periodic-3" target="continuation">
<mxGeometry relative="1" as="geometry">
<Array as="points"><mxPoint x="950" y="507"/></Array>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
Binary file added docs/public/images/lifecycle-hooks-startup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading