-
Notifications
You must be signed in to change notification settings - Fork 2
docs: add snapshot, suspend & resume example #764
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
a957c8b
e2ea0b8
462ea90
5c63629
d5b83c0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. overall looks good, but seems very similar to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for the great feedback in this PR. I made changes to address the issues you highlighted. The cleanup code is a lot less hairy now. In regards to your more general point: we could consolidate these demos but I feel like they're for slightly different use cases. I think it's better to keep them separate but this is mostly based on impressions of how I think agents and llms will process these docs. |
| 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 | ||
|
james-rl marked this conversation as resolved.
Outdated
|
||
| 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) | ||
|
james-rl marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| 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) | ||
|
james-rl marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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( | ||
|
james-rl marked this conversation as resolved.
|
||
| 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 | ||
|
james-rl marked this conversation as resolved.
Outdated
|
||
| if suspended_info.status != "suspended": | ||
| suspended_info = await source_devbox.await_suspended(polling_config=POLLING_CONFIG) | ||
|
james-rl marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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( | ||
|
james-rl marked this conversation as resolved.
|
||
| 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( | ||
|
james-rl marked this conversation as resolved.
|
||
| snapshot.id, | ||
| name=unique_name("snapshot-clone-b"), | ||
| launch_parameters={ | ||
| "resource_size_request": "X_SMALL", | ||
| }, | ||
| ) | ||
|
james-rl marked this conversation as resolved.
|
||
| 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 | ||
| ), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. isn't this guaranteed to pass when the previous check passes?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, this is an artifact from when this worked differently |
||
| 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, | ||
|
james-rl marked this conversation as resolved.
Outdated
|
||
| details=f"deleted={not snapshot_needs_cleanup}", | ||
| ), | ||
| ], | ||
| ) | ||
|
|
||
|
|
||
| run_devbox_snapshots_example = wrap_recipe(recipe) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| run_as_cli(run_devbox_snapshots_example) | ||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
llm thing I didn't catch :/