Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
32 changes: 32 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Runnable examples live in [`examples/`](./examples).
- [Blueprint with Build Context](#blueprint-with-build-context)
- [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle)
- [Devbox Snapshot and Resume](#devbox-snapshot-resume)
- [Devbox Snapshots (Suspend, Resume, Restore, Delete)](#devbox-snapshots)
- [Devbox Tunnel (HTTP Server Access)](#devbox-tunnel)
- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
- [Secrets with Devbox (Create, Inject, Verify, Delete)](#secrets-with-devbox)
Expand Down Expand Up @@ -106,6 +107,37 @@ uv run pytest -m smoketest tests/smoketests/examples/

**Source:** [`examples/devbox_snapshot_resume.py`](./examples/devbox_snapshot_resume.py)

<a id="devbox-snapshots"></a>
## Devbox Snapshots (Suspend, Resume, Restore, Delete)

**Use case:** Upload a file to a devbox, preserve it across suspend and resume, create a disk snapshot, restore multiple devboxes from that snapshot, mutate each copy independently, and delete the snapshot when finished.

**Tags:** `devbox`, `snapshot`, `suspend`, `resume`, `files`, `cleanup`

### Workflow
- Create a source devbox
- Upload a file and mutate it into a shared baseline
- Suspend and resume the source devbox
- Create a disk snapshot from the resumed devbox
- Restore two additional devboxes from the same snapshot baseline
- Mutate the same file differently in each devbox to prove isolation
- Shutdown the devboxes and delete the snapshot

### Prerequisites
- `RUNLOOP_API_KEY`

### Run
```sh
uv run python -m examples.devbox_snapshots
```

### Test
```sh
uv run pytest -m smoketest tests/smoketests/examples/
```

**Source:** [`examples/devbox_snapshots.py`](./examples/devbox_snapshots.py)

<a id="devbox-tunnel"></a>
## Devbox Tunnel (HTTP Server Access)

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Functionality between the synchronous and asynchronous clients is otherwise iden
## Examples

Workflow-oriented runnable examples are documented in [`EXAMPLES.md`](./EXAMPLES.md).
For a suspend/resume plus shared snapshot restore workflow, see [`examples/devbox_snapshots.py`](./examples/devbox_snapshots.py).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the only example that gets a dedicated callout in the main README. why?


`EXAMPLES.md` is generated from metadata in `examples/*.py` and should not be edited manually.
Regenerate it with:
Expand Down
245 changes: 245 additions & 0 deletions examples/devbox_snapshots.py
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall looks good, but seems very similar to devbox_snapshot_resume.py. could these two be consolidated into one example?

Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
#!/usr/bin/env -S uv run python
"""
---
title: Devbox Snapshots (Suspend, Resume, Restore, Delete)
slug: devbox-snapshots
use_case: Upload a file to a devbox, preserve it across suspend and resume, create a disk snapshot, restore multiple devboxes from that snapshot, mutate each copy independently, and delete the snapshot when finished.
workflow:
- Create a source devbox
- Upload a file and mutate it into a shared baseline
- Suspend and resume the source devbox
- Create a disk snapshot from the resumed devbox
- Restore two additional devboxes from the same snapshot baseline
- Mutate the same file differently in each devbox to prove isolation
- Shutdown the devboxes and delete the snapshot
tags:
- devbox
- snapshot
- suspend
- resume
- files
- cleanup
prerequisites:
- RUNLOOP_API_KEY
run: uv run python -m examples.devbox_snapshots
test: uv run pytest -m smoketest tests/smoketests/examples/
---
"""

from __future__ import annotations

import tempfile
from pathlib import Path

from runloop_api_client import AsyncRunloopSDK
from runloop_api_client.lib.polling import PollingConfig
from runloop_api_client.sdk.async_devbox import AsyncDevbox
from runloop_api_client.sdk.async_snapshot import AsyncSnapshot

from ._harness import run_as_cli, unique_name, wrap_recipe
from .example_types import ExampleCheck, RecipeOutput, RecipeContext

FILE_PATH = "/tmp/snapshot-demo.txt"
POLLING_CONFIG = PollingConfig(timeout_seconds=120.0, interval_seconds=5.0)


async def read_file_contents(devbox: AsyncDevbox) -> str:
"""Read the shared demo file from a devbox."""
return await devbox.file.read(file_path=FILE_PATH)


async def recipe(ctx: RecipeContext) -> RecipeOutput:
"""Demonstrate suspend/resume and shared snapshot restoration with isolated mutations."""
cleanup = ctx.cleanup
sdk = AsyncRunloopSDK()

resources_created: list[str] = []

source_devbox: AsyncDevbox | None = None
clone_a: AsyncDevbox | None = None
clone_b: AsyncDevbox | None = None
snapshot: AsyncSnapshot | None = None
local_file_path: Path | None = None

source_needs_cleanup = False
clone_a_needs_cleanup = False
clone_b_needs_cleanup = False
snapshot_needs_cleanup = False

async def cleanup_source() -> None:
if source_needs_cleanup and source_devbox is not None:
await source_devbox.shutdown()

async def cleanup_clone_a() -> None:
if clone_a_needs_cleanup and clone_a is not None:
await clone_a.shutdown()

async def cleanup_clone_b() -> None:
if clone_b_needs_cleanup and clone_b is not None:
await clone_b.shutdown()

async def cleanup_snapshot() -> None:
if snapshot_needs_cleanup and snapshot is not None:
await snapshot.delete()

def cleanup_local_file() -> None:
if local_file_path is not None:
local_file_path.unlink(missing_ok=True)

# Cleanup runs in LIFO order, so register these handlers up front in reverse
# dependency order: clones, then source devbox, then snapshot, then local file.
cleanup.add("local-file:snapshot-demo", cleanup_local_file)
cleanup.add("snapshot:baseline", cleanup_snapshot)
cleanup.add("devbox:source", cleanup_source)
cleanup.add("devbox:clone-a", cleanup_clone_a)
cleanup.add("devbox:clone-b", cleanup_clone_b)
Comment on lines +58 to +95
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this really needed? devbox_snapshot_resume.py does this in one line: cleanup.add(f"devbox:{dbx_original.id}", dbx_original.shutdown). we could probably use the same pattern here after creation for each resource that needs cleanup


uploaded_contents = "uploaded-from-local-file"
baseline_contents = "baseline-after-upload-and-mutation"
source_contents = "source-devbox-after-isolated-mutation"
clone_a_contents = "clone-a-after-isolated-mutation"
clone_b_contents = "clone-b-after-isolated-mutation"

with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as tmp_file:
tmp_file.write(uploaded_contents)
local_file_path = Path(tmp_file.name)

source_devbox = await sdk.devbox.create(
name=unique_name("snapshot-source"),
launch_parameters={
"resource_size_request": "X_SMALL",
},
)
source_needs_cleanup = True
resources_created.append(f"devbox:{source_devbox.id}")

await source_devbox.file.upload(path=FILE_PATH, file=local_file_path)
uploaded_readback = await read_file_contents(source_devbox)

await source_devbox.file.write(file_path=FILE_PATH, contents=baseline_contents)

suspend_response = await source_devbox.suspend()
suspended_info = suspend_response
Comment on lines +121 to +122
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the only time suspend_response is referenced. just replace with suspended_info

if suspended_info.status != "suspended":
suspended_info = await source_devbox.await_suspended(polling_config=POLLING_CONFIG)

resumed_info = await source_devbox.resume(polling_config=POLLING_CONFIG)
resumed_readback = await read_file_contents(source_devbox)

snapshot = await source_devbox.snapshot_disk(
name=unique_name("snapshot-baseline"),
commit_message="Capture the shared baseline after suspend and resume.",
polling_config=POLLING_CONFIG,
)
snapshot_needs_cleanup = True
resources_created.append(f"snapshot:{snapshot.id}")

clone_a = await snapshot.create_devbox(
name=unique_name("snapshot-clone-a"),
launch_parameters={
"resource_size_request": "X_SMALL",
},
)
clone_a_needs_cleanup = True
resources_created.append(f"devbox:{clone_a.id}")

clone_b = await sdk.devbox.create_from_snapshot(
snapshot.id,
name=unique_name("snapshot-clone-b"),
launch_parameters={
"resource_size_request": "X_SMALL",
},
)
Comment on lines +137 to +152
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add comment about demonstrating two different ways of starting a devbox from snapshot, can be confusing otherwise

clone_b_needs_cleanup = True
resources_created.append(f"devbox:{clone_b.id}")

clone_a_baseline_readback = await read_file_contents(clone_a)
clone_b_baseline_readback = await read_file_contents(clone_b)

await source_devbox.file.write(file_path=FILE_PATH, contents=source_contents)
await clone_a.file.write(file_path=FILE_PATH, contents=clone_a_contents)
await clone_b.file.write(file_path=FILE_PATH, contents=clone_b_contents)

source_isolated_readback = await read_file_contents(source_devbox)
clone_a_isolated_readback = await read_file_contents(clone_a)
clone_b_isolated_readback = await read_file_contents(clone_b)

await clone_b.shutdown()
clone_b_needs_cleanup = False

await clone_a.shutdown()
clone_a_needs_cleanup = False

await source_devbox.shutdown()
source_needs_cleanup = False

await snapshot.delete()
snapshot_needs_cleanup = False

return RecipeOutput(
resources_created=resources_created,
checks=[
ExampleCheck(
name="uploaded file is readable on the source devbox",
passed=uploaded_readback == uploaded_contents,
details=uploaded_readback,
),
ExampleCheck(
name="suspend reaches the suspended state",
passed=suspended_info.status == "suspended",
details=f"status={suspended_info.status}",
),
ExampleCheck(
name="resume preserves the baseline file contents",
passed=resumed_info.status == "running" and resumed_readback == baseline_contents,
details=f"status={resumed_info.status}, contents={resumed_readback}",
),
ExampleCheck(
name="multiple devboxes can use the same snapshot baseline",
passed=(
clone_a_baseline_readback == baseline_contents and clone_b_baseline_readback == baseline_contents
),
details=(f"clone_a={clone_a_baseline_readback}, clone_b={clone_b_baseline_readback}"),
),
ExampleCheck(
name="devboxes diverge after isolated mutations",
passed=(
source_isolated_readback == source_contents
and clone_a_isolated_readback == clone_a_contents
and clone_b_isolated_readback == clone_b_contents
),
details=(
"source="
f"{source_isolated_readback}, "
f"clone_a={clone_a_isolated_readback}, "
f"clone_b={clone_b_isolated_readback}"
),
),
ExampleCheck(
name="snapshot-backed devboxes stay isolated from one another",
passed=(
len(
{
source_isolated_readback,
clone_a_isolated_readback,
clone_b_isolated_readback,
}
)
== 3
),
details=(f"values={[source_isolated_readback, clone_a_isolated_readback, clone_b_isolated_readback]}"),
),
ExampleCheck(
name="snapshot can be deleted after the demo finishes",
passed=not snapshot_needs_cleanup,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should always pass if we reach this point, right? we should either remove this check or replace it with something that actually queries the api to verify the snapshot was deleted/is no longer listed

details=f"deleted={not snapshot_needs_cleanup}",
),
],
)


run_devbox_snapshots_example = wrap_recipe(recipe)


if __name__ == "__main__":
run_as_cli(run_devbox_snapshots_example)
8 changes: 8 additions & 0 deletions examples/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from .devbox_tunnel import run_devbox_tunnel_example
from .example_types import ExampleResult
from .devbox_snapshots import run_devbox_snapshots_example
from .mcp_github_tools import run_mcp_github_tools_example
from .secrets_with_devbox import run_secrets_with_devbox_example
from .devbox_snapshot_resume import run_devbox_snapshot_resume_example
Expand Down Expand Up @@ -39,6 +40,13 @@
"required_env": ["RUNLOOP_API_KEY"],
"run": run_devbox_snapshot_resume_example,
},
{
"slug": "devbox-snapshots",
"title": "Devbox Snapshots (Suspend, Resume, Restore, Delete)",
"file_name": "devbox_snapshots.py",
"required_env": ["RUNLOOP_API_KEY"],
"run": run_devbox_snapshots_example,
},
{
"slug": "devbox-tunnel",
"title": "Devbox Tunnel (HTTP Server Access)",
Expand Down
1 change: 1 addition & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
## Core Patterns

- [Devbox lifecycle example](examples/devbox_from_blueprint_lifecycle.py): Create blueprint, launch devbox, run commands, cleanup
- [Devbox snapshots example](examples/devbox_snapshots.py): Suspend and resume a devbox, create a shared snapshot baseline, restore multiple devboxes, verify isolation, cleanup
- [Devbox snapshot and resume example](examples/devbox_snapshot_resume.py): Snapshot disk, resume from snapshot, verify state isolation
- [MCP GitHub example](examples/mcp_github_tools.py): MCP Hub integration with Claude Code
- [Secrets with Devbox example](examples/secrets_with_devbox.py): Create secret, inject into devbox, verify, cleanup
Expand Down