diff --git a/EXAMPLES.md b/EXAMPLES.md index eb1222c9c..daf4924e4 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -11,6 +11,7 @@ Runnable examples live in [`examples/`](./examples). - [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle) - [Devbox Snapshot and Resume](#devbox-snapshot-resume) - [MCP Hub + Claude Code + GitHub](#mcp-github-tools) +- [Secrets with Devbox (Create, Inject, Verify, Delete)](#secrets-with-devbox) ## Blueprint with Build Context @@ -135,3 +136,34 @@ uv run pytest -m smoketest tests/smoketests/examples/ ``` **Source:** [`examples/mcp_github_tools.py`](./examples/mcp_github_tools.py) + + +## Secrets with Devbox (Create, Inject, Verify, Delete) + +**Use case:** Create a secret, inject it into a devbox as an environment variable, verify access, and clean up. + +**Tags:** `secrets`, `devbox`, `environment-variables`, `cleanup` + +### Workflow +- Create a secret with a test value +- Create a devbox with the secret mapped to an env var +- Execute a command that reads the secret from the environment +- Verify the value matches +- Update the secret and verify +- List secrets and verify the secret appears +- Shutdown devbox and delete secret + +### Prerequisites +- `RUNLOOP_API_KEY` + +### Run +```sh +uv run python -m examples.secrets_with_devbox +``` + +### Test +```sh +uv run pytest -m smoketest tests/smoketests/examples/ +``` + +**Source:** [`examples/secrets_with_devbox.py`](./examples/secrets_with_devbox.py) diff --git a/README-SDK.md b/README-SDK.md index 115eacf22..7a03ae724 100644 --- a/README-SDK.md +++ b/README-SDK.md @@ -116,6 +116,7 @@ The SDK provides object-oriented interfaces for all major Runloop resources: - **`runloop.blueprint`** - Blueprint management (create, list, build blueprints) - **`runloop.snapshot`** - Snapshot management (list disk snapshots) - **`runloop.storage_object`** - Storage object management (upload, download, list objects) +- **`runloop.secret`** - Secret management (create, update, list, delete encrypted key-value pairs) - **`runloop.api`** - Direct access to the underlying REST API client ### Devbox diff --git a/examples/mcp_github_tools.py b/examples/mcp_github_tools.py index b2850c7af..465ed1797 100644 --- a/examples/mcp_github_tools.py +++ b/examples/mcp_github_tools.py @@ -80,9 +80,9 @@ def recipe(ctx: RecipeContext, options: McpExampleOptions) -> RecipeOutput: # n # Store the GitHub PAT as a Runloop secret secret_name = unique_name("example-github-mcp") - sdk.api.secrets.create(name=secret_name, value=github_token) + secret = sdk.secret.create(name=secret_name, value=github_token) resources_created.append(f"secret:{secret_name}") - cleanup.add(f"secret:{secret_name}", lambda: sdk.api.secrets.delete(secret_name)) + cleanup.add(f"secret:{secret_name}", secret.delete) # Launch a devbox with MCP Hub wiring devbox = sdk.devbox.create( diff --git a/examples/registry.py b/examples/registry.py index cde21979d..8bb04cba0 100644 --- a/examples/registry.py +++ b/examples/registry.py @@ -9,6 +9,7 @@ from .example_types import ExampleResult 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 from .blueprint_with_build_context import run_blueprint_with_build_context_example from .devbox_from_blueprint_lifecycle import run_devbox_from_blueprint_lifecycle_example @@ -44,6 +45,13 @@ "required_env": ["RUNLOOP_API_KEY", "GITHUB_TOKEN", "ANTHROPIC_API_KEY"], "run": run_mcp_github_tools_example, }, + { + "slug": "secrets-with-devbox", + "title": "Secrets with Devbox (Create, Inject, Verify, Delete)", + "file_name": "secrets_with_devbox.py", + "required_env": ["RUNLOOP_API_KEY"], + "run": run_secrets_with_devbox_example, + }, ] diff --git a/examples/secrets_with_devbox.py b/examples/secrets_with_devbox.py new file mode 100644 index 000000000..c992c53e2 --- /dev/null +++ b/examples/secrets_with_devbox.py @@ -0,0 +1,112 @@ +#!/usr/bin/env -S uv run python +""" +--- +title: Secrets with Devbox (Create, Inject, Verify, Delete) +slug: secrets-with-devbox +use_case: Create a secret, inject it into a devbox as an environment variable, verify access, and clean up. +workflow: + - Create a secret with a test value + - Create a devbox with the secret mapped to an env var + - Execute a command that reads the secret from the environment + - Verify the value matches + - Update the secret and verify + - List secrets and verify the secret appears + - Shutdown devbox and delete secret +tags: + - secrets + - devbox + - environment-variables + - cleanup +prerequisites: + - RUNLOOP_API_KEY +run: uv run python -m examples.secrets_with_devbox +test: uv run pytest -m smoketest tests/smoketests/examples/ +--- +""" + +from __future__ import annotations + +from runloop_api_client import RunloopSDK + +from ._harness import run_as_cli, unique_name, wrap_recipe +from .example_types import ExampleCheck, RecipeOutput, RecipeContext + +# Note: do NOT hardcode secret values in your code! +# This is example code only; use environment variables instead! +_EXAMPLE_SECRET_VALUE = "my-secret-value" +_UPDATED_SECRET_VALUE = "updated-secret-value" + + +def recipe(ctx: RecipeContext) -> RecipeOutput: + """Create a secret, inject it into a devbox, and verify it is accessible.""" + cleanup = ctx.cleanup + + sdk = RunloopSDK() + resources_created: list[str] = [] + checks: list[ExampleCheck] = [] + + secret_name = unique_name("RUNLOOP_SDK_EXAMPLE").upper().replace("-", "_") + + secret = sdk.secret.create(name=secret_name, value=_EXAMPLE_SECRET_VALUE) + resources_created.append(f"secret:{secret_name}") + cleanup.add(f"secret:{secret_name}", lambda: secret.delete()) + + secret_info = secret.get_info() + checks.append( + ExampleCheck( + name="secret created successfully", + passed=secret.name == secret_name and secret_info.id.startswith("sec_"), + details=f"name={secret.name}, id={secret_info.id}", + ) + ) + + devbox = sdk.devbox.create( + name=unique_name("secrets-example-devbox"), + secrets={ + "MY_SECRET_ENV": secret.name, + }, + launch_parameters={ + "resource_size_request": "X_SMALL", + "keep_alive_time_seconds": 60 * 5, + }, + ) + resources_created.append(f"devbox:{devbox.id}") + cleanup.add(f"devbox:{devbox.id}", devbox.shutdown) + + result = devbox.cmd.exec("echo $MY_SECRET_ENV") + stdout = result.stdout().strip() + checks.append( + ExampleCheck( + name="devbox can read secret as env var", + passed=result.exit_code == 0 and stdout == _EXAMPLE_SECRET_VALUE, + details=f'exit_code={result.exit_code}, stdout="{stdout}"', + ) + ) + + updated_info = sdk.secret.update(secret, _UPDATED_SECRET_VALUE).get_info() + checks.append( + ExampleCheck( + name="secret updated successfully", + passed=updated_info.name == secret_name, + details=f"update_time_ms={updated_info.update_time_ms}", + ) + ) + + secrets = sdk.secret.list() + found = next((s for s in secrets if s.name == secret_name), None) + checks.append( + ExampleCheck( + name="secret appears in list", + passed=found is not None, + details=f"found name={found.name}" if found else "not found", + ) + ) + + return RecipeOutput(resources_created=resources_created, checks=checks) + + +run_secrets_with_devbox_example = wrap_recipe(recipe) + + +if __name__ == "__main__": + run_as_cli(run_secrets_with_devbox_example) diff --git a/llms.txt b/llms.txt index ed9c684a8..3785def20 100644 --- a/llms.txt +++ b/llms.txt @@ -13,6 +13,7 @@ - [Devbox lifecycle example](examples/devbox_from_blueprint_lifecycle.py): Create blueprint, launch devbox, run commands, 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 ## API Reference @@ -23,7 +24,7 @@ - **Prefer `AsyncRunloopSDK` over `RunloopSDK`** for better concurrency and performance; all SDK methods have async equivalents - Use `async with await runloop.devbox.create()` for automatic cleanup via context manager -- For resources without SDK coverage (e.g., secrets, benchmarks), use `runloop.api.*` as a fallback +- For resources without SDK coverage (e.g., benchmarks), use `runloop.api.*` as a fallback - Use `await devbox.cmd.exec('command')` for commands expected to return immediately (e.g., `echo`, `pwd`, `cat`)—blocks until completion, returns `ExecutionResult` with stdout/stderr - Use `await devbox.cmd.exec_async('command')` for long-running or background processes (servers, watchers, builds)—returns immediately with `Execution` handle to check status, get result, or kill - Both `exec` and `exec_async` support streaming callbacks (`stdout`, `stderr`, `output`) for real-time output diff --git a/scripts/mock b/scripts/mock index 0b28f6ea2..bcf3b392b 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & - # Wait for server to come online + # Wait for server to come online (max 30s) echo -n "Waiting for server" + attempts=0 while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Prism server to start" + cat .prism.log + exit 1 + fi echo -n "." sleep 0.1 done diff --git a/src/runloop_api_client/resources/secrets.py b/src/runloop_api_client/resources/secrets.py index 892557497..fa7d45471 100644 --- a/src/runloop_api_client/resources/secrets.py +++ b/src/runloop_api_client/resources/secrets.py @@ -96,6 +96,39 @@ def create( cast_to=SecretView, ) + def retrieve( + self, + name: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SecretView: + """Retrieve a Secret by name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return self._get( + f"/v1/secrets/{name}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ), + cast_to=SecretView, + ) + def update( self, name: str, @@ -299,6 +332,39 @@ async def create( cast_to=SecretView, ) + async def retrieve( + self, + name: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SecretView: + """Retrieve a Secret by name. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not name: + raise ValueError(f"Expected a non-empty value for `name` but received {name!r}") + return await self._get( + f"/v1/secrets/{name}", + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ), + cast_to=SecretView, + ) + async def update( self, name: str, @@ -435,6 +501,9 @@ def __init__(self, secrets: SecretsResource) -> None: self.create = to_raw_response_wrapper( secrets.create, ) + self.retrieve = to_raw_response_wrapper( + secrets.retrieve, + ) self.update = to_raw_response_wrapper( secrets.update, ) @@ -453,6 +522,9 @@ def __init__(self, secrets: AsyncSecretsResource) -> None: self.create = async_to_raw_response_wrapper( secrets.create, ) + self.retrieve = async_to_raw_response_wrapper( + secrets.retrieve, + ) self.update = async_to_raw_response_wrapper( secrets.update, ) @@ -471,6 +543,9 @@ def __init__(self, secrets: SecretsResource) -> None: self.create = to_streamed_response_wrapper( secrets.create, ) + self.retrieve = to_streamed_response_wrapper( + secrets.retrieve, + ) self.update = to_streamed_response_wrapper( secrets.update, ) @@ -489,6 +564,9 @@ def __init__(self, secrets: AsyncSecretsResource) -> None: self.create = async_to_streamed_response_wrapper( secrets.create, ) + self.retrieve = async_to_streamed_response_wrapper( + secrets.retrieve, + ) self.update = async_to_streamed_response_wrapper( secrets.update, ) diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index e90b86ac1..126b112c8 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -9,6 +9,7 @@ AgentOps, DevboxOps, ScorerOps, + SecretOps, RunloopSDK, ScenarioOps, SnapshotOps, @@ -25,6 +26,7 @@ AsyncAgentOps, AsyncDevboxOps, AsyncScorerOps, + AsyncSecretOps, AsyncRunloopSDK, AsyncScenarioOps, AsyncSnapshotOps, @@ -37,6 +39,7 @@ ) from .devbox import Devbox, NamedShell from .scorer import Scorer +from .secret import Secret from .scenario import Scenario from .snapshot import Snapshot from .benchmark import Benchmark @@ -46,6 +49,7 @@ from .async_agent import AsyncAgent from .async_devbox import AsyncDevbox, AsyncNamedShell from .async_scorer import AsyncScorer +from .async_secret import AsyncSecret from .scenario_run import ScenarioRun from .benchmark_run import BenchmarkRun from .async_scenario import AsyncScenario @@ -82,6 +86,8 @@ "AsyncBlueprintOps", "ScenarioOps", "AsyncScenarioOps", + "SecretOps", + "AsyncSecretOps", "ScorerOps", "AsyncScorerOps", "SnapshotOps", @@ -97,6 +103,7 @@ # Resource classes "Agent", "AsyncAgent", + "AsyncSecret", "Benchmark", "AsyncBenchmark", "BenchmarkRun", @@ -116,6 +123,8 @@ "ScenarioBuilder", "AsyncScenarioBuilder", "ScenarioPreview", + "Secret", + "AsyncSecret", "Scorer", "AsyncScorer", "Snapshot", diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py index 872e80530..6dcb6dff4 100644 --- a/src/runloop_api_client/sdk/async_.py +++ b/src/runloop_api_client/sdk/async_.py @@ -11,6 +11,7 @@ import httpx from ._types import ( + BaseRequestOptions, LongRequestOptions, SDKAgentListParams, SDKDevboxListParams, @@ -40,11 +41,13 @@ from .async_agent import AsyncAgent from .async_devbox import AsyncDevbox from .async_scorer import AsyncScorer +from .async_secret import AsyncSecret from .async_scenario import AsyncScenario from .async_snapshot import AsyncSnapshot from .async_benchmark import AsyncBenchmark from .async_blueprint import AsyncBlueprint from .async_mcp_config import AsyncMcpConfig +from ..types.secret_view import SecretView from ..lib.context_loader import TarFilter, build_directory_tar from .async_gateway_config import AsyncGatewayConfig from .async_network_policy import AsyncNetworkPolicy @@ -1070,6 +1073,127 @@ async def list(self, **params: Unpack[SDKMcpConfigListParams]) -> list[AsyncMcpC return [AsyncMcpConfig(self._client, item.id) for item in page.mcp_configs] +class AsyncSecretOps: + """High-level async manager for creating and managing Secrets. + + Accessed via ``runloop.secret`` from :class:`AsyncRunloopSDK`, provides methods to + create, retrieve, update, list, and delete secrets. Secrets are encrypted + key-value pairs that can be injected into Devboxes as environment variables. + + Example: + >>> runloop = AsyncRunloopSDK() + >>> secret = await runloop.secret.create(name="MY_API_KEY", value="secret-value") + >>> secrets = await runloop.secret.list() + """ + + def __init__(self, client: AsyncRunloop) -> None: + """Initialize AsyncSecretOps. + + :param client: AsyncRunloop client instance + :type client: AsyncRunloop + """ + self._client = client + + async def create(self, name: str, value: str, **options: Unpack[LongRequestOptions]) -> AsyncSecret: + """Create a new secret. + + Example: + >>> secret = await runloop.secret.create( + ... name="DATABASE_PASSWORD", + ... value="my-secure-password", + ... ) + >>> print(f"Created secret: {secret.name}") + + :param name: Globally unique secret name (must be a valid env var name) + :type name: str + :param value: Secret value to store (encrypted at rest) + :type value: str + :param options: Optional request configuration + :return: The created AsyncSecret instance + :rtype: AsyncSecret + """ + view = await self._client.secrets.create(name=name, value=value, **options) + return AsyncSecret(self._client, view.name, view.id) + + def from_name(self, name: str) -> AsyncSecret: + """Get an AsyncSecret instance by name without making an API call. + + Use ``get_info()`` on the returned AsyncSecret to fetch the actual data. + + Example: + >>> secret = runloop.secret.from_name("MY_API_KEY") + >>> info = await secret.get_info() + >>> print(f"Secret ID: {info.id}") + + :param name: The globally unique name of the secret + :type name: str + :return: An AsyncSecret instance (no API call made) + :rtype: AsyncSecret + """ + return AsyncSecret(self._client, name) + + async def update( + self, + secret: "AsyncSecret | str", + value: str, + **options: Unpack[LongRequestOptions], + ) -> AsyncSecret: + """Update an existing secret's value. + + Example: + >>> updated = await runloop.secret.update("DATABASE_PASSWORD", "new-password") + >>> # Or using an AsyncSecret object + >>> updated = await runloop.secret.update(secret, "new-password") + + :param secret: The secret to update (AsyncSecret object or name string) + :type secret: AsyncSecret | str + :param value: The new secret value + :type value: str + :param options: Optional request configuration + :return: The updated AsyncSecret instance + :rtype: AsyncSecret + """ + name = secret if isinstance(secret, str) else secret.name + await self._client.secrets.update(name, value=value, **options) + return AsyncSecret(self._client, name) + + async def list(self, **options: Unpack[BaseRequestOptions]) -> list[AsyncSecret]: + """List all secrets. + + Example: + >>> secrets = await runloop.secret.list() + >>> for s in secrets: + ... print(s.name) + + :param options: Optional request configuration + :return: List of AsyncSecret instances + :rtype: list[AsyncSecret] + """ + result = await self._client.secrets.list(**options) + return [AsyncSecret(self._client, view.name, view.id) for view in result.secrets] + + async def delete( + self, + secret: "AsyncSecret | str", + **options: Unpack[LongRequestOptions], + ) -> SecretView: + """Delete a secret. This action is irreversible. + + Example: + >>> await runloop.secret.delete("DATABASE_PASSWORD") + >>> # Or using an AsyncSecret object + >>> await runloop.secret.delete(secret) + + :param secret: The secret to delete (AsyncSecret object or name string) + :type secret: AsyncSecret | str + :param options: Optional request configuration + :return: The deleted secret metadata + :rtype: SecretView + """ + name = secret if isinstance(secret, str) else secret.name + return await self._client.secrets.delete(name, **options) + + class AsyncRunloopSDK: """High-level asynchronous entry point for the Runloop SDK. @@ -1101,6 +1225,8 @@ class AsyncRunloopSDK: :vartype gateway_config: AsyncGatewayConfigOps :ivar mcp_config: High-level async interface for MCP config management :vartype mcp_config: AsyncMcpConfigOps + :ivar secret: High-level async interface for secret management + :vartype secret: AsyncSecretOps Example: >>> runloop = AsyncRunloopSDK() # Uses RUNLOOP_API_KEY env var @@ -1120,6 +1246,7 @@ class AsyncRunloopSDK: network_policy: AsyncNetworkPolicyOps scenario: AsyncScenarioOps scorer: AsyncScorerOps + secret: AsyncSecretOps snapshot: AsyncSnapshotOps storage_object: AsyncStorageObjectOps @@ -1168,6 +1295,7 @@ def __init__( self.gateway_config = AsyncGatewayConfigOps(self.api) self.mcp_config = AsyncMcpConfigOps(self.api) self.network_policy = AsyncNetworkPolicyOps(self.api) + self.secret = AsyncSecretOps(self.api) self.scenario = AsyncScenarioOps(self.api) self.scorer = AsyncScorerOps(self.api) self.snapshot = AsyncSnapshotOps(self.api) diff --git a/src/runloop_api_client/sdk/async_secret.py b/src/runloop_api_client/sdk/async_secret.py new file mode 100644 index 000000000..189212c42 --- /dev/null +++ b/src/runloop_api_client/sdk/async_secret.py @@ -0,0 +1,130 @@ +"""Secret resource class for asynchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ._types import BaseRequestOptions, LongRequestOptions +from .._client import AsyncRunloop +from ..types.secret_view import SecretView + + +class AsyncSecret: + """Asynchronous wrapper around a secret resource. + + Secrets are encrypted key-value pairs that can be securely stored and injected + into Devboxes as environment variables. Secrets are identified by their globally + unique name. + + Example: + >>> runloop = AsyncRunloopSDK() + >>> secret = await runloop.secret.create( + ... name="MY_API_KEY", + ... value="secret-value", + ... ) + >>> info = await secret.get_info() + >>> print(f"Secret: {info.name}, ID: {info.id}") + """ + + def __init__( + self, + client: AsyncRunloop, + name: str, + id: str | None = None, + ) -> None: + """Initialize the wrapper. + + :param client: Generated AsyncRunloop client + :type client: AsyncRunloop + :param name: The globally unique name of the secret + :type name: str + :param id: The secret ID (optional, may not be known until get_info is called) + :type id: str | None + """ + self._client = client + self._name = name + self._id = id + + @override + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str | None: + """Return the secret ID. + + :return: Secret ID, or None if not yet fetched from API + :rtype: str | None + """ + return self._id + + @property + def name(self) -> str: + """Return the secret name. + + :return: Globally unique secret name + :rtype: str + """ + return self._name + + async def get_info( + self, + **options: Unpack[BaseRequestOptions], + ) -> SecretView: + """Retrieve the latest secret details from the API. + + Note: The secret value is never returned for security reasons. + + Example: + >>> info = await secret.get_info() + >>> print(f"Secret: {info.name}, ID: {info.id}, Created: {info.create_time_ms}") + + :param options: Optional request configuration + :return: API response describing the secret (value not included) + :rtype: SecretView + """ + return await self._client.secrets.retrieve( + self._name, + **options, + ) + + async def update( + self, + value: str, + **options: Unpack[LongRequestOptions], + ) -> SecretView: + """Update this secret's value. + + Example: + >>> updated = await secret.update("new-secret-value") + >>> print(f"Updated at: {updated.update_time_ms}") + + :param value: The new secret value (will be encrypted at rest) + :type value: str + :param options: Optional request configuration + :return: Updated secret view + :rtype: SecretView + """ + return await self._client.secrets.update( + self._name, + value=value, + **options, + ) + + async def delete( + self, + **options: Unpack[LongRequestOptions], + ) -> SecretView: + """Delete this secret. This action is irreversible. + + Example: + >>> await secret.delete() + + :param options: Optional long-running request configuration + :return: API response acknowledging deletion + :rtype: SecretView + """ + return await self._client.secrets.delete( + self._name, + **options, + ) diff --git a/src/runloop_api_client/sdk/secret.py b/src/runloop_api_client/sdk/secret.py new file mode 100644 index 000000000..8fdfa8c0d --- /dev/null +++ b/src/runloop_api_client/sdk/secret.py @@ -0,0 +1,130 @@ +"""Secret resource class for synchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ._types import BaseRequestOptions, LongRequestOptions +from .._client import Runloop +from ..types.secret_view import SecretView + + +class Secret: + """Synchronous wrapper around a secret resource. + + Secrets are encrypted key-value pairs that can be securely stored and injected + into Devboxes as environment variables. Secrets are identified by their globally + unique name. + + Example: + >>> runloop = RunloopSDK() + >>> secret = runloop.secret.create( + ... name="MY_API_KEY", + ... value="secret-value", + ... ) + >>> info = secret.get_info() + >>> print(f"Secret: {info.name}, ID: {info.id}") + """ + + def __init__( + self, + client: Runloop, + name: str, + id: str | None = None, + ) -> None: + """Initialize the wrapper. + + :param client: Generated Runloop client + :type client: Runloop + :param name: The globally unique name of the secret + :type name: str + :param id: The secret ID (optional, may not be known until getInfo is called) + :type id: str | None + """ + self._client = client + self._name = name + self._id = id + + @override + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str | None: + """Return the secret ID. + + :return: Secret ID, or None if not yet fetched from API + :rtype: str | None + """ + return self._id + + @property + def name(self) -> str: + """Return the secret name. + + :return: Globally unique secret name + :rtype: str + """ + return self._name + + def get_info( + self, + **options: Unpack[BaseRequestOptions], + ) -> SecretView: + """Retrieve the latest secret details from the API. + + Note: The secret value is never returned for security reasons. + + Example: + >>> info = secret.get_info() + >>> print(f"Secret: {info.name}, ID: {info.id}, Created: {info.create_time_ms}") + + :param options: Optional request configuration + :return: API response describing the secret (value not included) + :rtype: SecretView + """ + return self._client.secrets.retrieve( + self._name, + **options, + ) + + def update( + self, + value: str, + **options: Unpack[LongRequestOptions], + ) -> SecretView: + """Update this secret's value. + + Example: + >>> updated = secret.update("new-secret-value") + >>> print(f"Updated at: {updated.update_time_ms}") + + :param value: The new secret value (will be encrypted at rest) + :type value: str + :param options: Optional request configuration + :return: Updated secret view + :rtype: SecretView + """ + return self._client.secrets.update( + self._name, + value=value, + **options, + ) + + def delete( + self, + **options: Unpack[LongRequestOptions], + ) -> SecretView: + """Delete this secret. This action is irreversible. + + Example: + >>> secret.delete() + + :param options: Optional long-running request configuration + :return: API response acknowledging deletion + :rtype: SecretView + """ + return self._client.secrets.delete( + self._name, + **options, + ) diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index a43fa198b..07dd5e8fe 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -11,6 +11,7 @@ from .agent import Agent from ._types import ( + BaseRequestOptions, LongRequestOptions, SDKAgentListParams, SDKDevboxListParams, @@ -36,6 +37,7 @@ ) from .devbox import Devbox from .scorer import Scorer +from .secret import Secret from .._types import Timeout, NotGiven, not_given from .._client import DEFAULT_MAX_RETRIES, Runloop from ._helpers import detect_content_type @@ -48,6 +50,7 @@ from .network_policy import NetworkPolicy from .storage_object import StorageObject from .scenario_builder import ScenarioBuilder +from ..types.secret_view import SecretView from ..lib.context_loader import TarFilter, build_directory_tar from ..types.object_create_params import ContentType from ..types.shared_params.agent_source import Git, Npm, Pip, Object @@ -1095,6 +1098,127 @@ def list(self, **params: Unpack[SDKMcpConfigListParams]) -> list[McpConfig]: return [McpConfig(self._client, item.id) for item in page.mcp_configs] +class SecretOps: + """High-level manager for creating and managing Secrets. + + Accessed via ``runloop.secret`` from :class:`RunloopSDK`, provides methods to + create, retrieve, update, list, and delete secrets. Secrets are encrypted + key-value pairs that can be injected into Devboxes as environment variables. + + Example: + >>> runloop = RunloopSDK() + >>> secret = runloop.secret.create(name="MY_API_KEY", value="secret-value") + >>> secrets = runloop.secret.list() + """ + + def __init__(self, client: Runloop) -> None: + """Initialize SecretOps. + + :param client: Runloop client instance + :type client: Runloop + """ + self._client = client + + def create(self, name: str, value: str, **options: Unpack[LongRequestOptions]) -> Secret: + """Create a new secret. + + Example: + >>> secret = runloop.secret.create( + ... name="DATABASE_PASSWORD", + ... value="my-secure-password", + ... ) + >>> print(f"Created secret: {secret.name}") + + :param name: Globally unique secret name (must be a valid env var name) + :type name: str + :param value: Secret value to store (encrypted at rest) + :type value: str + :param options: Optional request configuration + :return: The created Secret instance + :rtype: Secret + """ + view = self._client.secrets.create(name=name, value=value, **options) + return Secret(self._client, view.name, view.id) + + def from_name(self, name: str) -> Secret: + """Get a Secret instance by name without making an API call. + + Use ``get_info()`` on the returned Secret to fetch the actual data. + + Example: + >>> secret = runloop.secret.from_name("MY_API_KEY") + >>> info = secret.get_info() + >>> print(f"Secret ID: {info.id}") + + :param name: The globally unique name of the secret + :type name: str + :return: A Secret instance (no API call made) + :rtype: Secret + """ + return Secret(self._client, name) + + def update( + self, + secret: "Secret | str", + value: str, + **options: Unpack[LongRequestOptions], + ) -> Secret: + """Update an existing secret's value. + + Example: + >>> updated = runloop.secret.update("DATABASE_PASSWORD", "new-password") + >>> # Or using a Secret object + >>> updated = runloop.secret.update(secret, "new-password") + + :param secret: The secret to update (Secret object or name string) + :type secret: Secret | str + :param value: The new secret value + :type value: str + :param options: Optional request configuration + :return: The updated Secret instance + :rtype: Secret + """ + name = secret if isinstance(secret, str) else secret.name + self._client.secrets.update(name, value=value, **options) + return Secret(self._client, name) + + def list(self, **options: Unpack[BaseRequestOptions]) -> list[Secret]: + """List all secrets. + + Example: + >>> secrets = runloop.secret.list() + >>> for s in secrets: + ... print(s.name) + + :param options: Optional request configuration + :return: List of Secret instances + :rtype: list[Secret] + """ + result = self._client.secrets.list(**options) + return [Secret(self._client, view.name, view.id) for view in result.secrets] + + def delete( + self, + secret: "Secret | str", + **options: Unpack[LongRequestOptions], + ) -> SecretView: + """Delete a secret. This action is irreversible. + + Example: + >>> runloop.secret.delete("DATABASE_PASSWORD") + >>> # Or using a Secret object + >>> runloop.secret.delete(secret) + + :param secret: The secret to delete (Secret object or name string) + :type secret: Secret | str + :param options: Optional request configuration + :return: The deleted secret metadata + :rtype: SecretView + """ + name = secret if isinstance(secret, str) else secret.name + return self._client.secrets.delete(name, **options) + + class RunloopSDK: """High-level synchronous entry point for the Runloop SDK. @@ -1126,6 +1250,8 @@ class RunloopSDK: :vartype gateway_config: GatewayConfigOps :ivar mcp_config: High-level interface for MCP config management :vartype mcp_config: McpConfigOps + :ivar secret: High-level interface for secret management + :vartype secret: SecretOps Example: >>> runloop = RunloopSDK() # Uses RUNLOOP_API_KEY env var @@ -1145,6 +1271,7 @@ class RunloopSDK: network_policy: NetworkPolicyOps scenario: ScenarioOps scorer: ScorerOps + secret: SecretOps snapshot: SnapshotOps storage_object: StorageObjectOps @@ -1193,6 +1320,7 @@ def __init__( self.gateway_config = GatewayConfigOps(self.api) self.mcp_config = McpConfigOps(self.api) self.network_policy = NetworkPolicyOps(self.api) + self.secret = SecretOps(self.api) self.scenario = ScenarioOps(self.api) self.scorer = ScorerOps(self.api) self.snapshot = SnapshotOps(self.api) diff --git a/tests/smoketests/sdk/test_async_secret.py b/tests/smoketests/sdk/test_async_secret.py new file mode 100644 index 000000000..a8d3bfb86 --- /dev/null +++ b/tests/smoketests/sdk/test_async_secret.py @@ -0,0 +1,128 @@ +"""Asynchronous SDK smoke tests for Secret operations.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import AsyncRunloopSDK +from tests.smoketests.utils import unique_name + +pytestmark = [pytest.mark.smoketest, pytest.mark.asyncio] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestAsyncSecretLifecycle: + """Test async secret lifecycle operations: create, get_info, update, list, delete.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_secret_full_lifecycle(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test complete async secret lifecycle: create, get_info, update (both ways), list, from_name, delete.""" + secret_name = unique_name("SDK_ASYNC_TEST_SECRET").upper().replace("-", "_") + + # Create + secret = await async_sdk_client.secret.create(name=secret_name, value="initial-value") + assert secret is not None + assert secret.name == secret_name + + try: + # get_info uses GET /v1/secrets/{name} + info = await secret.get_info() + assert info.id.startswith("sec_") + assert info.name == secret_name + assert info.create_time_ms > 0 + create_time_ms = info.create_time_ms + + # Update via AsyncSecretOps + updated = await async_sdk_client.secret.update(secret, "updated-via-ops") + assert updated.name == secret_name + updated_info = await updated.get_info() + assert updated_info.update_time_ms >= create_time_ms + + # Update via instance method + instance_updated_info = await secret.update("updated-via-instance") + assert instance_updated_info.name == secret_name + + # List + secrets = await async_sdk_client.secret.list() + assert isinstance(secrets, list) + assert len(secrets) > 0 + found = next((s for s in secrets if s.name == secret_name), None) + assert found is not None + assert found.name == secret_name + + # from_name (no API call) + by_name = async_sdk_client.secret.from_name(secret_name) + assert by_name.name == secret_name + by_name_info = await by_name.get_info() + assert by_name_info.id == info.id + + finally: + # Delete + deleted = await secret.delete() + assert deleted is not None + assert deleted.name == secret_name + + # Verify deleted + remaining = await async_sdk_client.secret.list() + assert all(s.name != secret_name for s in remaining) + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_secret_delete_via_ops(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test deleting a secret via AsyncSecretOps.delete().""" + secret_name = unique_name("SDK_ASYNC_DEL_SECRET").upper().replace("-", "_") + secret = await async_sdk_client.secret.create(name=secret_name, value="to-be-deleted") + + try: + deleted = await async_sdk_client.secret.delete(secret) + assert deleted.name == secret_name + + remaining = await async_sdk_client.secret.list() + assert all(s.name != secret_name for s in remaining) + except Exception: + try: + await secret.delete() + except Exception: + pass + raise + + +class TestAsyncSecretWithDevbox: + """Test async secret injection into devboxes.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_devbox_can_access_injected_secret(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test that a secret injected into a devbox is accessible as an env var.""" + secret_name = unique_name("SDK_ASYNC_DEVBOX_SECRET").upper().replace("-", "_") + secret_value = "async-secret-for-devbox-test" + + secret = await async_sdk_client.secret.create(name=secret_name, value=secret_value) + + devbox = None + try: + devbox = await async_sdk_client.devbox.create( + name=unique_name("async-secret-test-devbox"), + secrets={ + "MY_SECRET_VAR": secret.name, + }, + launch_parameters={ + "resource_size_request": "X_SMALL", + "keep_alive_time_seconds": 60, + }, + ) + + result = await devbox.cmd.exec("echo $MY_SECRET_VAR") + assert result.exit_code == 0 + assert (await result.stdout()).strip() == secret_value + + finally: + if devbox is not None: + try: + await devbox.shutdown() + except Exception: + pass + try: + await secret.delete() + except Exception: + pass diff --git a/tests/smoketests/sdk/test_secret.py b/tests/smoketests/sdk/test_secret.py new file mode 100644 index 000000000..33f460090 --- /dev/null +++ b/tests/smoketests/sdk/test_secret.py @@ -0,0 +1,145 @@ +"""Synchronous SDK smoke tests for Secret operations.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import RunloopSDK +from tests.smoketests.utils import unique_name + +pytestmark = [pytest.mark.smoketest] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestSecretLifecycle: + """Test secret lifecycle operations: create, get_info, update, list, delete.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_secret_full_lifecycle(self, sdk_client: RunloopSDK) -> None: + """Test complete secret lifecycle: create, get_info, update (both ways), list, from_name, delete.""" + secret_name = unique_name("SDK_TEST_SECRET").upper().replace("-", "_") + + # Create + secret = sdk_client.secret.create(name=secret_name, value="initial-value") + assert secret is not None + assert secret.name == secret_name + + try: + # get_info uses GET /v1/secrets/{name} + info = secret.get_info() + assert info.id.startswith("sec_") + assert info.name == secret_name + assert info.create_time_ms > 0 + create_time_ms = info.create_time_ms + + # Update via SecretOps + updated = sdk_client.secret.update(secret, "updated-via-ops") + assert updated.name == secret_name + updated_info = updated.get_info() + assert updated_info.update_time_ms >= create_time_ms + + # Update via instance method + instance_updated_info = secret.update("updated-via-instance") + assert instance_updated_info.name == secret_name + + # List + secrets = sdk_client.secret.list() + assert isinstance(secrets, list) + assert len(secrets) > 0 + found = next((s for s in secrets if s.name == secret_name), None) + assert found is not None + assert found.name == secret_name + + # from_name (no API call) + by_name = sdk_client.secret.from_name(secret_name) + assert by_name.name == secret_name + by_name_info = by_name.get_info() + assert by_name_info.id == info.id + + finally: + # Delete + deleted = secret.delete() + assert deleted is not None + assert deleted.name == secret_name + + # Verify deleted + remaining = sdk_client.secret.list() + assert all(s.name != secret_name for s in remaining) + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_secret_delete_via_ops(self, sdk_client: RunloopSDK) -> None: + """Test deleting a secret via SecretOps.delete().""" + secret_name = unique_name("SDK_TEST_DEL_SECRET").upper().replace("-", "_") + secret = sdk_client.secret.create(name=secret_name, value="to-be-deleted") + + try: + deleted = sdk_client.secret.delete(secret) + assert deleted.name == secret_name + + remaining = sdk_client.secret.list() + assert all(s.name != secret_name for s in remaining) + except Exception: + # Cleanup if delete failed + try: + secret.delete() + except Exception: + pass + raise + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_secret_delete_by_name_string(self, sdk_client: RunloopSDK) -> None: + """Test deleting a secret by passing a name string to SecretOps.delete().""" + secret_name = unique_name("SDK_TEST_STR_SECRET").upper().replace("-", "_") + sdk_client.secret.create(name=secret_name, value="to-be-deleted-by-name") + + try: + deleted = sdk_client.secret.delete(secret_name) + assert deleted.name == secret_name + except Exception: + try: + sdk_client.secret.delete(secret_name) + except Exception: + pass + raise + + +class TestSecretWithDevbox: + """Test secret injection into devboxes.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_devbox_can_access_injected_secret(self, sdk_client: RunloopSDK) -> None: + """Test that a secret injected into a devbox is accessible as an env var.""" + secret_name = unique_name("SDK_DEVBOX_SECRET").upper().replace("-", "_") + secret_value = "secret-for-devbox-test" + + secret = sdk_client.secret.create(name=secret_name, value=secret_value) + + devbox = None + try: + devbox = sdk_client.devbox.create( + name=unique_name("secret-test-devbox"), + secrets={ + "MY_SECRET_VAR": secret.name, + }, + launch_parameters={ + "resource_size_request": "X_SMALL", + "keep_alive_time_seconds": 60, + }, + ) + + result = devbox.cmd.exec("echo $MY_SECRET_VAR") + assert result.exit_code == 0 + assert result.stdout().strip() == secret_value + + finally: + if devbox is not None: + try: + devbox.shutdown() + except Exception: + pass + try: + secret.delete() + except Exception: + pass