Skip to content

Commit e3c37f9

Browse files
committed
fix: add logs to devboxes, smoke tests & examples
1 parent 92004cc commit e3c37f9

4 files changed

Lines changed: 132 additions & 3 deletions

File tree

examples/devbox_from_blueprint_lifecycle.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@
33
---
44
title: Devbox From Blueprint (Run Command, Shutdown)
55
slug: devbox-from-blueprint-lifecycle
6-
use_case: Create a devbox from a blueprint, run a command, validate output, and cleanly tear everything down.
6+
use_case: Create a devbox from a blueprint, run a command, fetch logs, validate output, and cleanly tear everything down.
77
workflow:
88
- Create a blueprint
9+
- Fetch blueprint build logs
910
- Create a devbox from the blueprint
1011
- Execute a command in the devbox
11-
- Validate exit code and stdout
12+
- Fetch devbox logs
13+
- Validate exit code, stdout, and logs
1214
- Shutdown devbox and delete blueprint
1315
tags:
1416
- devbox
1517
- blueprint
1618
- commands
19+
- logs
1720
- cleanup
1821
prerequisites:
1922
- RUNLOOP_API_KEY
@@ -34,7 +37,7 @@
3437

3538

3639
def recipe(ctx: RecipeContext) -> RecipeOutput:
37-
"""Create a devbox from a blueprint, run a command, and clean up."""
40+
"""Create a devbox from a blueprint, run a command, fetch logs, and clean up."""
3841
cleanup = ctx.cleanup
3942

4043
sdk = RunloopSDK()
@@ -46,6 +49,9 @@ def recipe(ctx: RecipeContext) -> RecipeOutput:
4649
)
4750
cleanup.add(f"blueprint:{blueprint.id}", blueprint.delete)
4851

52+
# Fetch blueprint build logs
53+
blueprint_logs = blueprint.logs()
54+
4955
devbox = blueprint.create_devbox(
5056
name=unique_name("example-devbox"),
5157
launch_parameters={
@@ -58,6 +64,9 @@ def recipe(ctx: RecipeContext) -> RecipeOutput:
5864
result = devbox.cmd.exec('echo "Hello from your devbox"')
5965
stdout = result.stdout()
6066

67+
# Fetch devbox logs
68+
devbox_logs = devbox.logs()
69+
6170
return RecipeOutput(
6271
resources_created=[f"blueprint:{blueprint.id}", f"devbox:{devbox.id}"],
6372
checks=[
@@ -71,6 +80,16 @@ def recipe(ctx: RecipeContext) -> RecipeOutput:
7180
passed="Hello from your devbox" in stdout,
7281
details=stdout.strip(),
7382
),
83+
ExampleCheck(
84+
name="blueprint build logs are retrievable",
85+
passed=blueprint_logs is not None and hasattr(blueprint_logs, "logs"),
86+
details=f"blueprint_log_count={len(blueprint_logs.logs)}",
87+
),
88+
ExampleCheck(
89+
name="devbox logs are retrievable",
90+
passed=devbox_logs is not None and hasattr(devbox_logs, "logs"),
91+
details=f"devbox_log_count={len(devbox_logs.logs)}",
92+
),
7493
],
7594
)
7695

src/runloop_api_client/sdk/async_devbox.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
DevboxExecutionDetailView,
1616
DevboxCreateSSHKeyResponse,
1717
)
18+
from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView
1819
from ._types import (
1920
LogCallback,
2021
BaseRequestOptions,
@@ -163,6 +164,38 @@ async def get_tunnel_url(
163164
return None
164165
return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai"
165166

167+
async def logs(
168+
self,
169+
*,
170+
execution_id: str | None = None,
171+
shell_name: str | None = None,
172+
**options: Unpack[BaseRequestOptions],
173+
) -> DevboxLogsListView:
174+
"""Retrieve logs for the devbox.
175+
176+
Returns all logs from a running or completed devbox. Optionally filter
177+
by execution ID or shell name.
178+
179+
:param execution_id: Filter logs by execution ID, defaults to None
180+
:type execution_id: str | None, optional
181+
:param shell_name: Filter logs by shell name, defaults to None
182+
:type shell_name: str | None, optional
183+
:param options: Optional request configuration
184+
:return: Log entries for the devbox
185+
:rtype: :class:`~runloop_api_client.types.devboxes.devbox_logs_list_view.DevboxLogsListView`
186+
187+
Example:
188+
>>> logs = await devbox.logs()
189+
>>> for log in logs.logs:
190+
... print(f"[{log.level}] {log.message}")
191+
"""
192+
return await self._client.devboxes.logs.list(
193+
self._id,
194+
execution_id=execution_id,
195+
shell_name=shell_name,
196+
**options,
197+
)
198+
166199
async def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView:
167200
"""Wait for the devbox to reach running state.
168201

src/runloop_api_client/sdk/devbox.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
DevboxExecutionDetailView,
1616
DevboxCreateSSHKeyResponse,
1717
)
18+
from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView
1819
from ._types import (
1920
LogCallback,
2021
BaseRequestOptions,
@@ -162,6 +163,38 @@ def get_tunnel_url(
162163
return None
163164
return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai"
164165

166+
def logs(
167+
self,
168+
*,
169+
execution_id: str | None = None,
170+
shell_name: str | None = None,
171+
**options: Unpack[BaseRequestOptions],
172+
) -> DevboxLogsListView:
173+
"""Retrieve logs for the devbox.
174+
175+
Returns all logs from a running or completed devbox. Optionally filter
176+
by execution ID or shell name.
177+
178+
:param execution_id: Filter logs by execution ID, defaults to None
179+
:type execution_id: str | None, optional
180+
:param shell_name: Filter logs by shell name, defaults to None
181+
:type shell_name: str | None, optional
182+
:param options: Optional request configuration
183+
:return: Log entries for the devbox
184+
:rtype: :class:`~runloop_api_client.types.devboxes.devbox_logs_list_view.DevboxLogsListView`
185+
186+
Example:
187+
>>> logs = devbox.logs()
188+
>>> for log in logs.logs:
189+
... print(f"[{log.level}] {log.message}")
190+
"""
191+
return self._client.devboxes.logs.list(
192+
self._id,
193+
execution_id=execution_id,
194+
shell_name=shell_name,
195+
**options,
196+
)
197+
165198
def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView:
166199
"""Wait for the devbox to reach running state.
167200

tests/smoketests/sdk/test_devbox.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,3 +1050,47 @@ def test_shell_exec_async_with_both_streams(self, devbox: Devbox) -> None:
10501050
# Verify streaming captured same data as result
10511051
assert stdout_combined == result.stdout()
10521052
assert stderr_combined == result.stderr()
1053+
1054+
1055+
class TestDevboxLogs:
1056+
"""Test devbox logs retrieval functionality."""
1057+
1058+
@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
1059+
def test_logs_basic(self, shared_devbox: Devbox) -> None:
1060+
"""Test retrieving devbox logs returns valid response structure."""
1061+
# Fetch logs - the API may return empty logs depending on timing
1062+
logs = shared_devbox.logs()
1063+
1064+
assert logs is not None
1065+
assert hasattr(logs, "logs")
1066+
assert isinstance(logs.logs, list)
1067+
1068+
@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
1069+
def test_logs_with_execution_filter(self, shared_devbox: Devbox) -> None:
1070+
"""Test retrieving devbox logs filtered by execution ID."""
1071+
# Run a command and get its execution ID
1072+
execution = shared_devbox.cmd.exec_async('echo "filtered log test"')
1073+
result = execution.result()
1074+
assert result.exit_code == 0
1075+
1076+
# Fetch logs filtered by execution ID - verifies API accepts the filter
1077+
logs = shared_devbox.logs(execution_id=execution.execution_id)
1078+
1079+
assert logs is not None
1080+
assert isinstance(logs.logs, list)
1081+
1082+
@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
1083+
def test_logs_with_shell_name_filter(self, shared_devbox: Devbox) -> None:
1084+
"""Test retrieving devbox logs filtered by shell name."""
1085+
shell_name = "test-logs-shell"
1086+
shell = shared_devbox.shell(shell_name)
1087+
1088+
# Run a command in the named shell
1089+
result = shell.exec('echo "shell log test"')
1090+
assert result.exit_code == 0
1091+
1092+
# Fetch logs filtered by shell name - verifies API accepts the filter
1093+
logs = shared_devbox.logs(shell_name=shell_name)
1094+
1095+
assert logs is not None
1096+
assert isinstance(logs.logs, list)

0 commit comments

Comments
 (0)