Skip to content

Commit e2ea0b8

Browse files
committed
add example -- you know, the point of this branch
1 parent a957c8b commit e2ea0b8

1 file changed

Lines changed: 245 additions & 0 deletions

File tree

examples/devbox_snapshots.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
#!/usr/bin/env -S uv run python
2+
"""
3+
---
4+
title: Devbox Snapshots (Suspend, Resume, Restore, Delete)
5+
slug: devbox-snapshots
6+
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.
7+
workflow:
8+
- Create a source devbox
9+
- Upload a file and mutate it into a shared baseline
10+
- Suspend and resume the source devbox
11+
- Create a disk snapshot from the resumed devbox
12+
- Restore two additional devboxes from the same snapshot baseline
13+
- Mutate the same file differently in each devbox to prove isolation
14+
- Shutdown the devboxes and delete the snapshot
15+
tags:
16+
- devbox
17+
- snapshot
18+
- suspend
19+
- resume
20+
- files
21+
- cleanup
22+
prerequisites:
23+
- RUNLOOP_API_KEY
24+
run: uv run python -m examples.devbox_snapshots
25+
test: uv run pytest -m smoketest tests/smoketests/examples/
26+
---
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import tempfile
32+
from pathlib import Path
33+
34+
from runloop_api_client import AsyncRunloopSDK
35+
from runloop_api_client.lib.polling import PollingConfig
36+
from runloop_api_client.sdk.async_devbox import AsyncDevbox
37+
from runloop_api_client.sdk.async_snapshot import AsyncSnapshot
38+
39+
from ._harness import run_as_cli, unique_name, wrap_recipe
40+
from .example_types import ExampleCheck, RecipeOutput, RecipeContext
41+
42+
FILE_PATH = "/tmp/snapshot-demo.txt"
43+
POLLING_CONFIG = PollingConfig(timeout_seconds=120.0, interval_seconds=5.0)
44+
45+
46+
async def read_file_contents(devbox: AsyncDevbox) -> str:
47+
"""Read the shared demo file from a devbox."""
48+
return await devbox.file.read(file_path=FILE_PATH)
49+
50+
51+
async def recipe(ctx: RecipeContext) -> RecipeOutput:
52+
"""Demonstrate suspend/resume and shared snapshot restoration with isolated mutations."""
53+
cleanup = ctx.cleanup
54+
sdk = AsyncRunloopSDK()
55+
56+
resources_created: list[str] = []
57+
58+
source_devbox: AsyncDevbox | None = None
59+
clone_a: AsyncDevbox | None = None
60+
clone_b: AsyncDevbox | None = None
61+
snapshot: AsyncSnapshot | None = None
62+
local_file_path: Path | None = None
63+
64+
source_needs_cleanup = False
65+
clone_a_needs_cleanup = False
66+
clone_b_needs_cleanup = False
67+
snapshot_needs_cleanup = False
68+
69+
async def cleanup_source() -> None:
70+
if source_needs_cleanup and source_devbox is not None:
71+
await source_devbox.shutdown()
72+
73+
async def cleanup_clone_a() -> None:
74+
if clone_a_needs_cleanup and clone_a is not None:
75+
await clone_a.shutdown()
76+
77+
async def cleanup_clone_b() -> None:
78+
if clone_b_needs_cleanup and clone_b is not None:
79+
await clone_b.shutdown()
80+
81+
async def cleanup_snapshot() -> None:
82+
if snapshot_needs_cleanup and snapshot is not None:
83+
await snapshot.delete()
84+
85+
def cleanup_local_file() -> None:
86+
if local_file_path is not None:
87+
local_file_path.unlink(missing_ok=True)
88+
89+
# Cleanup runs in LIFO order, so register these handlers up front in reverse
90+
# dependency order: clones, then source devbox, then snapshot, then local file.
91+
cleanup.add("local-file:snapshot-demo", cleanup_local_file)
92+
cleanup.add("snapshot:baseline", cleanup_snapshot)
93+
cleanup.add("devbox:source", cleanup_source)
94+
cleanup.add("devbox:clone-a", cleanup_clone_a)
95+
cleanup.add("devbox:clone-b", cleanup_clone_b)
96+
97+
uploaded_contents = "uploaded-from-local-file"
98+
baseline_contents = "baseline-after-upload-and-mutation"
99+
source_contents = "source-devbox-after-isolated-mutation"
100+
clone_a_contents = "clone-a-after-isolated-mutation"
101+
clone_b_contents = "clone-b-after-isolated-mutation"
102+
103+
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as tmp_file:
104+
tmp_file.write(uploaded_contents)
105+
local_file_path = Path(tmp_file.name)
106+
107+
source_devbox = await sdk.devbox.create(
108+
name=unique_name("snapshot-source"),
109+
launch_parameters={
110+
"resource_size_request": "X_SMALL",
111+
},
112+
)
113+
source_needs_cleanup = True
114+
resources_created.append(f"devbox:{source_devbox.id}")
115+
116+
await source_devbox.file.upload(path=FILE_PATH, file=local_file_path)
117+
uploaded_readback = await read_file_contents(source_devbox)
118+
119+
await source_devbox.file.write(file_path=FILE_PATH, contents=baseline_contents)
120+
121+
suspend_response = await source_devbox.suspend()
122+
suspended_info = suspend_response
123+
if suspended_info.status != "suspended":
124+
suspended_info = await source_devbox.await_suspended(polling_config=POLLING_CONFIG)
125+
126+
resumed_info = await source_devbox.resume(polling_config=POLLING_CONFIG)
127+
resumed_readback = await read_file_contents(source_devbox)
128+
129+
snapshot = await source_devbox.snapshot_disk(
130+
name=unique_name("snapshot-baseline"),
131+
commit_message="Capture the shared baseline after suspend and resume.",
132+
polling_config=POLLING_CONFIG,
133+
)
134+
snapshot_needs_cleanup = True
135+
resources_created.append(f"snapshot:{snapshot.id}")
136+
137+
clone_a = await snapshot.create_devbox(
138+
name=unique_name("snapshot-clone-a"),
139+
launch_parameters={
140+
"resource_size_request": "X_SMALL",
141+
},
142+
)
143+
clone_a_needs_cleanup = True
144+
resources_created.append(f"devbox:{clone_a.id}")
145+
146+
clone_b = await sdk.devbox.create_from_snapshot(
147+
snapshot.id,
148+
name=unique_name("snapshot-clone-b"),
149+
launch_parameters={
150+
"resource_size_request": "X_SMALL",
151+
},
152+
)
153+
clone_b_needs_cleanup = True
154+
resources_created.append(f"devbox:{clone_b.id}")
155+
156+
clone_a_baseline_readback = await read_file_contents(clone_a)
157+
clone_b_baseline_readback = await read_file_contents(clone_b)
158+
159+
await source_devbox.file.write(file_path=FILE_PATH, contents=source_contents)
160+
await clone_a.file.write(file_path=FILE_PATH, contents=clone_a_contents)
161+
await clone_b.file.write(file_path=FILE_PATH, contents=clone_b_contents)
162+
163+
source_isolated_readback = await read_file_contents(source_devbox)
164+
clone_a_isolated_readback = await read_file_contents(clone_a)
165+
clone_b_isolated_readback = await read_file_contents(clone_b)
166+
167+
await clone_b.shutdown()
168+
clone_b_needs_cleanup = False
169+
170+
await clone_a.shutdown()
171+
clone_a_needs_cleanup = False
172+
173+
await source_devbox.shutdown()
174+
source_needs_cleanup = False
175+
176+
await snapshot.delete()
177+
snapshot_needs_cleanup = False
178+
179+
return RecipeOutput(
180+
resources_created=resources_created,
181+
checks=[
182+
ExampleCheck(
183+
name="uploaded file is readable on the source devbox",
184+
passed=uploaded_readback == uploaded_contents,
185+
details=uploaded_readback,
186+
),
187+
ExampleCheck(
188+
name="suspend reaches the suspended state",
189+
passed=suspended_info.status == "suspended",
190+
details=f"status={suspended_info.status}",
191+
),
192+
ExampleCheck(
193+
name="resume preserves the baseline file contents",
194+
passed=resumed_info.status == "running" and resumed_readback == baseline_contents,
195+
details=f"status={resumed_info.status}, contents={resumed_readback}",
196+
),
197+
ExampleCheck(
198+
name="multiple devboxes can use the same snapshot baseline",
199+
passed=(
200+
clone_a_baseline_readback == baseline_contents and clone_b_baseline_readback == baseline_contents
201+
),
202+
details=(f"clone_a={clone_a_baseline_readback}, clone_b={clone_b_baseline_readback}"),
203+
),
204+
ExampleCheck(
205+
name="devboxes diverge after isolated mutations",
206+
passed=(
207+
source_isolated_readback == source_contents
208+
and clone_a_isolated_readback == clone_a_contents
209+
and clone_b_isolated_readback == clone_b_contents
210+
),
211+
details=(
212+
"source="
213+
f"{source_isolated_readback}, "
214+
f"clone_a={clone_a_isolated_readback}, "
215+
f"clone_b={clone_b_isolated_readback}"
216+
),
217+
),
218+
ExampleCheck(
219+
name="snapshot-backed devboxes stay isolated from one another",
220+
passed=(
221+
len(
222+
{
223+
source_isolated_readback,
224+
clone_a_isolated_readback,
225+
clone_b_isolated_readback,
226+
}
227+
)
228+
== 3
229+
),
230+
details=(f"values={[source_isolated_readback, clone_a_isolated_readback, clone_b_isolated_readback]}"),
231+
),
232+
ExampleCheck(
233+
name="snapshot can be deleted after the demo finishes",
234+
passed=not snapshot_needs_cleanup,
235+
details=f"deleted={not snapshot_needs_cleanup}",
236+
),
237+
],
238+
)
239+
240+
241+
run_devbox_snapshots_example = wrap_recipe(recipe)
242+
243+
244+
if __name__ == "__main__":
245+
run_as_cli(run_devbox_snapshots_example)

0 commit comments

Comments
 (0)