diff --git a/AGENTS.md b/AGENTS.md index 1695b33..084ae46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,8 @@ Key methods: - `stream_logs(*, follow=False, tail_lines=None, since_time=None, timestamps=False)`: Stream logs from the sandbox's main process (PID 1), return `StreamReader[str]`. Only captures stdout/stderr from the command passed to `Sandbox.run()` — output from `exec()` commands is **not** included. Set `follow=True` for continuous streaming (like `tail -f`). Uses bounded queues for backpressure in follow mode. - `read_file(path)`: Return `OperationRef[bytes]` - `write_file(path, content)`: Return `OperationRef[None]` -- `stop(snapshot_on_stop=False, graceful_shutdown_seconds=10.0, missing_ok=False)`: Stop sandbox and return `OperationRef[None]`. The sandbox transitions through TERMINATING (grace period) before reaching a terminal state (COMPLETED or FAILED). The returned OperationRef resolves when the backend confirms a terminal state, not just when the stop RPC succeeds. Multiple callers share the same stop task. Raises `SandboxError` on failure. Set `snapshot_on_stop=True` to capture sandbox state before shutdown. Set `missing_ok=True` to suppress `SandboxNotFoundError`. +- `stop(snapshot_on_stop=False, graceful_shutdown_seconds=10.0, missing_ok=False, wait_for_ready=True, idempotency_key=None)`: Stop sandbox and return `OperationRef[None]`. The sandbox transitions through TERMINATING (grace period) before reaching a terminal state (COMPLETED or FAILED). The returned OperationRef resolves when the backend confirms a terminal state, not just when the stop RPC succeeds. Multiple callers share the same stop task. Raises `SandboxError` on failure. Set `snapshot_on_stop=True` to capture a file-system snapshot (FSS) of the configured mount before shutdown — the resulting ID is then available via the `file_system_snapshot_id` property. Because `stop()` coalesces concurrent callers onto one shared stop task, a `snapshot_on_stop=True` request that would join (or observe) a stop not capturing a snapshot — sandbox already stopping/stopped, or a plain `stop()` already in flight — raises `SnapshotOnStopConflictError` rather than completing with no archive; plain stops always coalesce. `wait_for_ready`/`idempotency_key` apply only when `snapshot_on_stop=True` (the client uses a larger timeout when snapshotting, since the stop blocks on the archive). Set `missing_ok=True` to suppress `SandboxNotFoundError`. +- `snapshot(wait_for_ready=True, idempotency_key=None)`: Capture a file-system snapshot (FSS) of the configured mount without stopping, return `OperationRef[str]` (the new snapshot's ID). Call `Sandbox.get_snapshot(id)` for the full record. Requires the sandbox to have been started with a `file_system_snapshot` mount and the org to be enabled for FSS. Auto-starts the sandbox first if needed. With `wait_for_ready=True` (default) blocks until the snapshot is READY/FAILED. To fork a sandbox, `snapshot()` then `Sandbox.run(file_system_snapshot=FileSystemSnapshotOptions(..., file_system_snapshot_id=))`. - `get_status()`: Fetch fresh status from API (sync). Returns cached status for terminal sandboxes (COMPLETED, FAILED, TERMINATED) since terminal states are immutable. TERMINATING is non-terminal and always fetches fresh status. Properties: @@ -62,6 +63,7 @@ Properties: - `status_updated_at`: When status was last fetched - `sandbox_id`, `runner_id`, `profile_id`, `runner_group_id`, `returncode`, `started_at` - `resource_requests`, `resource_limits` - Confirmed resources from start response (None for discovered sandboxes) +- `file_system_snapshot_id` - Snapshot ID produced by `stop(snapshot_on_stop=True)` once the stop resolves (None otherwise) Advanced configuration kwargs (for `run()`, `Session.sandbox()`, and `@session.function()`): - `resources` - Resource configuration via `ResourceOptions`, nested dict, or legacy flat dict (CPU, memory, GPU) @@ -69,6 +71,7 @@ Advanced configuration kwargs (for `run()`, `Session.sandbox()`, and `@session.f - `s3_mount` - S3 bucket mount configuration - `ports` - Port mappings for the sandbox - `network` - Network configuration via `NetworkOptions` or dict (ingress/egress modes, exposed ports) +- `file_system_snapshot` - File-system snapshot (FSS) mount configuration via `FileSystemSnapshotOptions` or dict (`mount_path`, optional `size`, optional `file_system_snapshot_id` to restore/fork) - `secrets` - Secrets to inject from secret stores as environment variables, via `Secret` or dict - `max_timeout_seconds` - Maximum timeout for sandbox operations - `environment_variables` - Environment variables to inject (merges with defaults) @@ -79,6 +82,10 @@ Class methods: - `Sandbox.list(tags=None, status=None, profile_ids=None, profile_names=None, runner_ids=None, include_stopped=False, ...)`: Query existing sandboxes, return `OperationRef[list[Sandbox]]`. Use `.result()` to block or `await` in async contexts. By default, terminal sandboxes (completed, failed, terminated) are excluded. Set `include_stopped=True` to include them. `profile_names` and `profile_ids` resolve independently; either or both may be supplied. - `Sandbox.from_id(sandbox_id)`: Attach to existing sandbox by ID, return `OperationRef[Sandbox]`. Works for both active and stopped sandboxes. - `Sandbox.delete(sandbox_id, missing_ok=False)`: Delete sandbox by ID, return `OperationRef[None]`. Raises `SandboxError` on failure. Set `missing_ok=True` to suppress `SandboxNotFoundError` for already-deleted sandboxes. +- `Sandbox.get_snapshot(file_system_snapshot_id)`: Fetch a `FileSystemSnapshot` record by ID, return `OperationRef[FileSystemSnapshot]`. Snapshots are org-scoped. Raises `SnapshotNotFoundError` if absent. +- `Sandbox.list_snapshots(source_sandbox_id=None, status=None, ...)`: List FSS records for the org, return `OperationRef[list[FileSystemSnapshot]]` (auto-paginated). `source_sandbox_id` and `status` are applied client-side. +- `Sandbox.delete_snapshot(file_system_snapshot_id, missing_ok=False)`: Delete an FSS by ID, return `OperationRef[None]`. Does not affect sandboxes already restored from it. Set `missing_ok=True` to suppress `SnapshotNotFoundError`. +- `Sandbox.get_snapshot_bucket_config()` / `Sandbox.set_snapshot_bucket_config(*, bucket_name, region="")`: Get/set the org's FSS object-storage bucket (admin), return `OperationRef[FileSystemSnapshotBucketConfig]`. Pass `bucket_name=""` to revert to the CoreWeave-managed bucket. **`Session`** (`_session.py`): Manages multiple sandboxes with shared defaults. Supports both sync and async context managers for the hybrid API. @@ -113,6 +120,7 @@ Fields (all optional with sensible defaults): - `profile_ids`, `profile_names`, `runner_ids` - Infrastructure filtering (optional tuples). `profile_names` is the preferred form; both fields resolve independently through the None/empty/defaults precedence - `resources` - Resource configuration (`ResourceOptions | dict[str, Any] | None`) - `network` - Network configuration via `NetworkOptions` +- `file_system_snapshot` - FSS mount configuration via `FileSystemSnapshotOptions` (shareable mount_path/size; explicit `run()` value replaces it wholesale) - `secrets` - Secrets to inject from secret stores (tuple of `Secret`) - `environment_variables` - Environment variables to inject - `annotations` - Kubernetes pod annotations (`dict[str, str]`, default: empty) @@ -250,6 +258,36 @@ sandbox = Sandbox.run( ) ``` +**File System Snapshots (FSS)** (`_types.py`): A configured working directory can be snapshotted (on request or on stop) and restored into new sandboxes, letting you fork a sandbox's filesystem. FSS is gated per-organization on the backend; orgs that are not enabled get `SnapshotNotSupportedError`. + +- **`FileSystemSnapshotOptions`**: Frozen dataclass for the FSS mount. Fields: `mount_path` (absolute dir, required — this is the configured directory captured/restored), `size` (K8s quantity like `"10Gi"`, optional), `file_system_snapshot_id` (optional — set to restore that snapshot into `mount_path` at start). Accepts a plain dict. Passed as `file_system_snapshot=` to `run()`/`Session.sandbox()`/`@session.function()` and settable on `SandboxDefaults`. +- **`FileSystemSnapshot`**: Frozen record returned by `get_snapshot()` and `list_snapshots()` (`snapshot()` returns only the ID). Fields: `file_system_snapshot_id`, `status` (`FileSystemSnapshotStatus`), `status_reason`, `size_bytes`, `source_sandbox_id`, `trigger` (`FileSystemSnapshotTrigger`), `idempotency_key`, `object_bucket`, `created_at`/`updated_at`/`completed_at`. +- **`FileSystemSnapshotStatus`**: StrEnum — `UNSPECIFIED`, `CREATING`, `READY`, `FAILED`, `DELETING`. +- **`FileSystemSnapshotTrigger`**: StrEnum — `UNSPECIFIED`, `STOP` (from `stop(snapshot_on_stop=True)`), `MANUAL` (from `snapshot()`). +- **`FileSystemSnapshotBucketConfig`** / **`FileSystemSnapshotBucketMode`**: Org bucket config (`mode`, `bucket_name`, `region`, `effective_bucket_name`); mode is `UNSPECIFIED`/`CW_MANAGED`/`BRING_YOUR_OWN`. + +Usage: +```python +from cwsandbox import Sandbox, FileSystemSnapshotOptions + +# Start with a snapshot-capable mount and snapshot it (returns the ID) +with Sandbox.run( + file_system_snapshot=FileSystemSnapshotOptions(mount_path="/workspace", size="10Gi"), +) as sb: + sb.exec(["sh", "-c", "echo seed > /workspace/data.txt"]).result() + snapshot_id = sb.snapshot().result() # str (snapshot is READY) + +# Fork = restore the snapshot into a fresh sandbox +with Sandbox.run( + file_system_snapshot=FileSystemSnapshotOptions(mount_path="/workspace", file_system_snapshot_id=snapshot_id), +) as restored: + ... + +# Snapshot on stop, then read the ID +sb.stop(snapshot_on_stop=True).result() +file_system_snapshot_id = sb.file_system_snapshot_id +``` + ### Authentication Flow `_auth.py` implements a pluggable auth mode system with a single active mode: @@ -453,17 +491,26 @@ CWSandboxError ├── SandboxError │ ├── SandboxNotRunningError │ │ └── SandboxUnavailableError # transient service unavailability (gRPC UNAVAILABLE / AIP-193 UNAVAILABLE_REASONS) +│ │ └── SnapshotBackendThrottledError # transient FSS throttle/inflight cap (CWSANDBOX_FSS_BACKEND_THROTTLED / _INFLIGHT_LIMIT); retryable │ │ # raw SandboxNotRunningError is also emitted for CANCELLED and local-stop paths │ ├── SandboxTimeoutError │ │ ├── SandboxRequestTimeoutError # gRPC request deadline (DEADLINE_EXCEEDED) -│ │ └── SandboxCommandTimeoutError # user command exceeded its timeout (AIP-193 CWSANDBOX_COMMAND_TIMEOUT) +│ │ ├── SandboxCommandTimeoutError # user command exceeded its timeout (AIP-193 CWSANDBOX_COMMAND_TIMEOUT) +│ │ └── SnapshotWaitTimeoutError # wait_for_ready budget exceeded (CWSANDBOX_FSS_WAIT_TIMEOUT) │ ├── SandboxResourceExhaustedError # backend resource pressure (gRPC RESOURCE_EXHAUSTED) │ ├── SandboxTerminalStateUnavailableError # post-stop NOT_FOUND past retry budget (backend did not report terminal state) │ ├── SandboxTerminatedError │ ├── SandboxFailedError │ ├── SandboxNotFoundError # .sandbox_id attribute │ ├── SandboxExecutionError # .exec_result, .exception_type, .exception_message attributes -│ └── SandboxFileError # .filepath attribute +│ ├── SandboxFileError # .filepath attribute +│ └── SandboxSnapshotError # FSS failures; .file_system_snapshot_id attribute +│ ├── SnapshotNotFoundError # CWSANDBOX_FSS_NOT_FOUND +│ ├── SnapshotNotReadyError # CWSANDBOX_FSS_NOT_READY +│ ├── SnapshotNotSupportedError # CWSANDBOX_FSS_NOT_SUPPORTED (org not enabled for FSS) +│ ├── SnapshotSizeExceededError # CWSANDBOX_FSS_SIZE_EXCEEDED +│ ├── SnapshotQuotaExceededError # CWSANDBOX_FSS_QUOTA_EXCEEDED +│ └── SnapshotBucketMismatchError # CWSANDBOX_FSS_BUCKET_MISMATCH (reversible) ├── DiscoveryError │ ├── RunnerNotFoundError # .runner_id attribute │ └── ProfileNotFoundError # .profile_name, .runner_id attributes @@ -478,6 +525,18 @@ in ``src/cwsandbox/_sandbox.py`` for the current membership. Retryable classes are subclasses of the existing umbrella exceptions, so callers catching the parent classes continue to work unchanged. +**FSS RPC retries**: The file-system snapshot RPCs (`snapshot()`/create, +`get_snapshot`, `list_snapshots`, `delete_snapshot`, bucket config) retry +*transient* errors with a bounded wall-clock budget +(`DEFAULT_FSS_RETRY_BUDGET_SECONDS`, default 30s; set to 0 to disable). They +reuse the poll loop's `_classify_poll_error`, so only the same retryable classes +are retried — transient unavailability, request-deadline, resource-exhaustion, +and FSS backend-throttling. Everything else (NOT_FOUND, FAILED_PRECONDITION, +quota/size, NOT_SUPPORTED) is fatal on the first attempt. Backoff is decorrelated +jitter honoring AIP-193 `RetryInfo` hints, via the shared `_retry_transient_rpc` +helper. `snapshot()` auto-generates an idempotency key when the caller omits one, +so a retried create dedups instead of producing a duplicate snapshot. + ## Examples The `examples/` directory contains runnable scripts demonstrating common patterns: @@ -488,6 +547,7 @@ The `examples/` directory contains runnable scripts demonstrating common pattern - `interactive_streaming_sandbox.py` - Log streaming with `stream_logs()` and CLI interaction (`exec`, `sh`, `logs`) - `reconnect_to_sandbox.py`, `async_patterns.py` - Discovery and reconnection - `delete_sandboxes.py` - Deletion patterns with `Sandbox.delete()` +- `file_system_snapshots.py` - Snapshot/restore/fork with `file_system_snapshot`, `snapshot()`, and snapshot management - `error_handling.py` - Exception hierarchy and error recovery patterns - `session_adopt_orphans.py`, `cleanup_by_tag.py`, `cleanup_old_sandboxes.py` - Orphan management and cleanup - `parallel_batch_job.py` - Parallel batch processing with progress tracking diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 5a11378..cddab1a 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -22,6 +22,7 @@ Runnable example scripts demonstrating cwsandbox SDK usage patterns. | `function_decorator.py` | Sync | `def main()` | Remote function execution with `@session.function()` | | `resource_configuration.py` | Sync | `def main()` | ResourceOptions, flat dict, nested dict, GPU, response properties | | `error_handling.py` | Sync | `def main()` | Exception hierarchy: SandboxExecutionError, TimeoutError, NotFoundError | +| `file_system_snapshots.py` | Sync | `def main()` | FSS snapshot/restore/fork with `file_system_snapshot`, `snapshot()`, snapshot management | | `multiple_sandboxes.py` | Sync | `def main()` | Session-based multi-sandbox management | | `delete_sandboxes.py` | Sync | `def main()` | Deletion patterns with `Sandbox.delete()` | | `interactive_streaming_sandbox.py` | Sync | `def main()` | Log streaming with `stream_logs()` and CLI interaction | diff --git a/examples/file_system_snapshots.py b/examples/file_system_snapshots.py new file mode 100644 index 0000000..48e1234 --- /dev/null +++ b/examples/file_system_snapshots.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2025 CoreWeave, Inc. +# SPDX-License-Identifier: BSD-3-Clause +# SPDX-PackageName: cwsandbox-client + +"""Example: File-system snapshots (FSS) — snapshot, restore, and fork. + +A configured working directory (the ``mount_path``) can be snapshotted on +request or on stop. The resulting snapshot can be restored into new sandboxes — +restoring is how you "fork" a sandbox's filesystem. + +Demonstrates: +- Starting a sandbox with a snapshot-capable mount (``file_system_snapshot``) +- Taking a mid-life snapshot with ``sandbox.snapshot()`` (returns the ID) +- Forking = snapshot + restore into a fresh sandbox via ``Sandbox.run(...)`` +- Capturing a snapshot on stop and reading ``file_system_snapshot_id`` +- Managing snapshots: ``list_snapshots()`` / ``get_snapshot()`` / ``delete_snapshot()`` + +FSS is gated per-organization on the backend. If your org is not enabled, the +snapshot calls raise ``SnapshotNotSupportedError``. + +Usage: + uv run examples/file_system_snapshots.py +""" + +from cwsandbox import ( + FileSystemSnapshotOptions, + Sandbox, + SandboxDefaults, +) +from cwsandbox.exceptions import SnapshotNotSupportedError + +MOUNT_PATH = "/workspace" + + +def main() -> None: + defaults = SandboxDefaults( + container_image="python:3.11", + tags=("example", "file-system-snapshots"), + ) + + try: + # 1. Start a sandbox with a snapshot-capable mount and seed some data. + with Sandbox.run( + defaults=defaults, + file_system_snapshot=FileSystemSnapshotOptions(mount_path=MOUNT_PATH, size="1Gi"), + ) as source: + source.exec(["sh", "-c", f"echo 'hello from source' > {MOUNT_PATH}/data.txt"]).result() + print(f"Seeded {MOUNT_PATH}/data.txt in source sandbox {source.sandbox_id}") + + # 2. Take a mid-life snapshot (waits until READY by default). + # snapshot() returns just the ID; use get_snapshot() for details. + snapshot_id = source.snapshot().result() + print(f"Created snapshot {snapshot_id}") + + # 3. Fork = restore the snapshot into a brand-new sandbox. + with Sandbox.run( + defaults=defaults, + file_system_snapshot=FileSystemSnapshotOptions( + mount_path=MOUNT_PATH, file_system_snapshot_id=snapshot_id + ), + ) as restored: + contents = restored.exec(["cat", f"{MOUNT_PATH}/data.txt"]).result() + print(f"Restored sandbox sees: {contents.stdout.strip()!r}") + + # 4. Snapshot on stop: the ID is available after the stop resolves. + on_stop = Sandbox.run( + defaults=defaults, + file_system_snapshot=FileSystemSnapshotOptions(mount_path=MOUNT_PATH), + ) + on_stop.exec(["sh", "-c", f"echo bye > {MOUNT_PATH}/farewell.txt"]).result() + on_stop.stop(snapshot_on_stop=True).result() + print(f"Snapshot-on-stop produced: {on_stop.file_system_snapshot_id}") + + # 5. Manage snapshots: list, fetch, and clean up. + snapshots = Sandbox.list_snapshots().result() + print(f"Org has {len(snapshots)} snapshot(s)") + + fetched = Sandbox.get_snapshot(snapshot_id).result() + print(f"Fetched {fetched.file_system_snapshot_id}: {fetched.size_bytes} bytes") + + # Delete the snapshots we created (idempotent with missing_ok). + for snap_id in {snapshot_id, on_stop.file_system_snapshot_id}: + if snap_id: + Sandbox.delete_snapshot(snap_id, missing_ok=True).result() + print(f"Deleted snapshot {snap_id}") + + except SnapshotNotSupportedError: + print( + "File-system snapshots are not enabled for this organization. " + "Contact CoreWeave to enable FSS." + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/update-protos.sh b/scripts/update-protos.sh index c62443e..3a93ff3 100755 --- a/scripts/update-protos.sh +++ b/scripts/update-protos.sh @@ -8,6 +8,10 @@ # Usage: # scripts/update-protos.sh # download from buf.build # scripts/update-protos.sh --local ../sandbox/gen/python # copy from local path +# +# If the buf.build pins below lag the backend, regenerate from a local sandbox +# proto checkout with `buf generate` (protobuf python plugin pinned to v26.1 / <=5.26 +# to pass validate_protobuf_version) and pass the output via --local. set -euo pipefail diff --git a/src/cwsandbox/__init__.py b/src/cwsandbox/__init__.py index 353d654..3013ee2 100644 --- a/src/cwsandbox/__init__.py +++ b/src/cwsandbox/__init__.py @@ -33,6 +33,12 @@ from cwsandbox._sandbox import Sandbox, SandboxStatus from cwsandbox._session import Session from cwsandbox._types import ( + FileSystemSnapshot, + FileSystemSnapshotBucketConfig, + FileSystemSnapshotBucketMode, + FileSystemSnapshotOptions, + FileSystemSnapshotStatus, + FileSystemSnapshotTrigger, NetworkOptions, OperationRef, Process, @@ -61,12 +67,22 @@ SandboxNotRunningError, SandboxRequestTimeoutError, SandboxResourceExhaustedError, + SandboxSnapshotError, SandboxStreamBackpressureError, SandboxStreamTruncatedError, SandboxTerminalStateUnavailableError, SandboxTerminatedError, SandboxTimeoutError, SandboxUnavailableError, + SnapshotBackendThrottledError, + SnapshotBucketMismatchError, + SnapshotNotFoundError, + SnapshotNotReadyError, + SnapshotNotSupportedError, + SnapshotOnStopConflictError, + SnapshotQuotaExceededError, + SnapshotSizeExceededError, + SnapshotWaitTimeoutError, ) if TYPE_CHECKING: @@ -267,6 +283,12 @@ def _to_awaitable(w: Waitable) -> asyncio.Task[Any]: "CWSandboxError", "DiscoveryError", "EgressMode", + "FileSystemSnapshot", + "FileSystemSnapshotBucketConfig", + "FileSystemSnapshotBucketMode", + "FileSystemSnapshotOptions", + "FileSystemSnapshotStatus", + "FileSystemSnapshotTrigger", "FunctionError", "ServiceExposureMode", "get_profile", @@ -296,6 +318,7 @@ def _to_awaitable(w: Waitable) -> asyncio.Task[Any]: "SandboxNotRunningError", "SandboxRequestTimeoutError", "SandboxResourceExhaustedError", + "SandboxSnapshotError", "SandboxStatus", "SandboxStreamBackpressureError", "SandboxStreamTruncatedError", @@ -303,6 +326,15 @@ def _to_awaitable(w: Waitable) -> asyncio.Task[Any]: "SandboxTerminatedError", "SandboxTimeoutError", "SandboxUnavailableError", + "SnapshotBackendThrottledError", + "SnapshotBucketMismatchError", + "SnapshotNotFoundError", + "SnapshotNotReadyError", + "SnapshotNotSupportedError", + "SnapshotOnStopConflictError", + "SnapshotQuotaExceededError", + "SnapshotSizeExceededError", + "SnapshotWaitTimeoutError", "Secret", "Session", "StreamReader", diff --git a/src/cwsandbox/_defaults.py b/src/cwsandbox/_defaults.py index 4bfdd33..b78ac94 100644 --- a/src/cwsandbox/_defaults.py +++ b/src/cwsandbox/_defaults.py @@ -9,7 +9,12 @@ from dataclasses import dataclass, field, fields, replace from typing import Any -from cwsandbox._types import NetworkOptions, ResourceOptions, Secret +from cwsandbox._types import ( + FileSystemSnapshotOptions, + NetworkOptions, + ResourceOptions, + Secret, +) DEFAULT_CONTAINER_IMAGE: str = "python:3.11" # Shell-trapped keep-alive: installs an explicit SIGTERM handler so the PID 1 @@ -21,6 +26,25 @@ DEFAULT_ARGS: tuple[str, ...] = ("-c", 'trap "exit 0" TERM INT; sleep infinity & wait') DEFAULT_BASE_URL: str = "https://api.cwsandbox.com" DEFAULT_GRACEFUL_SHUTDOWN_SECONDS: float = 10.0 +# Archive budget for stop(snapshot_on_stop=True): the time the backend spends +# capturing the file-system snapshot before tearing down the pod. Sent as the +# StopSandboxRequest.max_timeout_seconds field and matches the backend's default +# when FSS is requested (the backend does not cap this). This bounds only the +# archive phase — the post-archive pod-delete grace is a separate, additive +# budget (graceful_shutdown_seconds), so the client deadline is the sum of both +# (see _do_stop and the two constants below), not this value alone. +DEFAULT_FSS_STOP_TIMEOUT_SECONDS: float = 600.0 +# Post-archive pod-delete grace the backend substitutes when a snapshot-on-stop +# is sent with graceful_shutdown_seconds=0. Mirrors the backend's +# defaultGraceSecondsAfterFSSnapshot so the client deadline budgets the grace +# the server will actually apply (sending 0 does NOT mean "no grace"). +DEFAULT_FSS_STOP_GRACE_FALLBACK_SECONDS: float = 30.0 +# Extra slack added to the snapshot-on-stop client deadline on top of the two +# server phase budgets (archive + grace). Covers the backend's gateway +# request-context slack (~30s it waits beyond the archive budget) plus ~5s of +# network round-trip, keeping the client deadline ~5s past the backend's +# worst-case wall-clock so a healthy stop is never cut off client-side. +DEFAULT_FSS_STOP_CLIENT_SLACK_SECONDS: float = 35.0 DEFAULT_POLL_INTERVAL_SECONDS: float = 0.2 DEFAULT_MAX_POLL_INTERVAL_SECONDS: float = 2.0 DEFAULT_POLL_BACKOFF_FACTOR: float = 1.5 @@ -69,6 +93,13 @@ # entirely (the first transient error re-raises). DEFAULT_POLL_RETRY_BUDGET_SECONDS: float = 30.0 +# Wall-clock budget for retrying transient failures on file-system snapshot +# (FSS) RPCs (create/get/list/delete/bucket config). Mirrors the poll budget: +# it caps time spent retrying after a transient error (UNAVAILABLE / +# DEADLINE_EXCEEDED / RESOURCE_EXHAUSTED / FSS backend-throttle) and does not +# delay the happy path. Set to 0.0 to disable FSS retries entirely. +DEFAULT_FSS_RETRY_BUDGET_SECONDS: float = 30.0 + # Per-call timeout for poll Get RPCs (seconds). Kept separate from # request_timeout_seconds so a wedged poll fails fast instead of blocking # on the broader request timeout. @@ -263,6 +294,9 @@ class SandboxDefaults: resources: Resource configuration. Accepts ``ResourceOptions`` for separate requests/limits, or a flat dict for backward-compatible Guaranteed QoS. network: Network configuration via ``NetworkOptions``. + file_system_snapshot: File-system snapshot (FSS) mount configuration via + ``FileSystemSnapshotOptions``. Shareable mount defaults (mount_path, + size); an explicit ``run()`` value replaces it wholesale. secrets: Secrets to inject as environment variables. environment_variables: Environment variables injected into the sandbox. annotations: Kubernetes pod annotations (key-value string pairs). @@ -299,6 +333,7 @@ class SandboxDefaults: runner_ids: tuple[str, ...] | None = None resources: ResourceOptions | dict[str, Any] | None = None network: NetworkOptions | None = None + file_system_snapshot: FileSystemSnapshotOptions | dict[str, Any] | None = None secrets: tuple[Secret, ...] | None = None environment_variables: dict[str, str] = field(default_factory=dict) annotations: dict[str, str] = field(default_factory=dict) @@ -380,6 +415,10 @@ def from_dict(cls, d: Mapping[str, Any] | None) -> SandboxDefaults: net = kwargs.get("network") if net is not None and not isinstance(net, NetworkOptions): kwargs["network"] = NetworkOptions(**net) + # Coerce file_system_snapshot dict -> FileSystemSnapshotOptions (preserve None) + fss = kwargs.get("file_system_snapshot") + if fss is not None and not isinstance(fss, FileSystemSnapshotOptions): + kwargs["file_system_snapshot"] = FileSystemSnapshotOptions(**fss) # Coerce secrets list of dicts -> tuple of Secret (preserve None) secrets = kwargs.get("secrets") if secrets is not None: diff --git a/src/cwsandbox/_error_info.py b/src/cwsandbox/_error_info.py index 780d34b..5a9e679 100644 --- a/src/cwsandbox/_error_info.py +++ b/src/cwsandbox/_error_info.py @@ -105,6 +105,46 @@ } ) +# File-system snapshot (FSS) reasons. Emitted by the snapshot create/restore, +# snapshot-on-stop, and snapshot management RPCs. +CWSANDBOX_FSS_NOT_FOUND = "CWSANDBOX_FSS_NOT_FOUND" +CWSANDBOX_FSS_NOT_READY = "CWSANDBOX_FSS_NOT_READY" +CWSANDBOX_FSS_NOT_SUPPORTED = "CWSANDBOX_FSS_NOT_SUPPORTED" +CWSANDBOX_FSS_SIZE_EXCEEDED = "CWSANDBOX_FSS_SIZE_EXCEEDED" +CWSANDBOX_FSS_QUOTA_EXCEEDED = "CWSANDBOX_FSS_QUOTA_EXCEEDED" +CWSANDBOX_FSS_BUCKET_MISMATCH = "CWSANDBOX_FSS_BUCKET_MISMATCH" +CWSANDBOX_FSS_BUCKET_UNAVAILABLE = "CWSANDBOX_FSS_BUCKET_UNAVAILABLE" +CWSANDBOX_FSS_RESTORE_FAILED = "CWSANDBOX_FSS_RESTORE_FAILED" +CWSANDBOX_FSS_CREATE_FAILED = "CWSANDBOX_FSS_CREATE_FAILED" +CWSANDBOX_FSS_CREATE_TIMED_OUT = "CWSANDBOX_FSS_CREATE_TIMED_OUT" +CWSANDBOX_FSS_AUTH_FAILED = "CWSANDBOX_FSS_AUTH_FAILED" +CWSANDBOX_FSS_TRANSPORT_FAILED = "CWSANDBOX_FSS_TRANSPORT_FAILED" +CWSANDBOX_FSS_WAIT_TIMEOUT = "CWSANDBOX_FSS_WAIT_TIMEOUT" +CWSANDBOX_FSS_CANCELED = "CWSANDBOX_FSS_CANCELED" +CWSANDBOX_FSS_BACKEND_THROTTLED = "CWSANDBOX_FSS_BACKEND_THROTTLED" +CWSANDBOX_FSS_INFLIGHT_LIMIT = "CWSANDBOX_FSS_INFLIGHT_LIMIT" + +# Internal/terminal FSS failures that map to the generic SandboxSnapshotError. +SNAPSHOT_INTERNAL_REASONS: frozenset[str] = frozenset( + { + CWSANDBOX_FSS_BUCKET_UNAVAILABLE, + CWSANDBOX_FSS_RESTORE_FAILED, + CWSANDBOX_FSS_CREATE_FAILED, + CWSANDBOX_FSS_CREATE_TIMED_OUT, + CWSANDBOX_FSS_AUTH_FAILED, + CWSANDBOX_FSS_TRANSPORT_FAILED, + CWSANDBOX_FSS_CANCELED, + } +) + +# Transient FSS failures (gRPC UNAVAILABLE): safe to retry with backoff. +SNAPSHOT_TRANSIENT_REASONS: frozenset[str] = frozenset( + { + CWSANDBOX_FSS_BACKEND_THROTTLED, + CWSANDBOX_FSS_INFLIGHT_LIMIT, + } +) + @dataclass(frozen=True) class ParsedError: diff --git a/src/cwsandbox/_function.py b/src/cwsandbox/_function.py index a7808f6..87ee452 100644 --- a/src/cwsandbox/_function.py +++ b/src/cwsandbox/_function.py @@ -15,7 +15,12 @@ from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar from cwsandbox._defaults import DEFAULT_TEMP_DIR -from cwsandbox._types import NetworkOptions, OperationRef, ResourceOptions +from cwsandbox._types import ( + FileSystemSnapshotOptions, + NetworkOptions, + OperationRef, + ResourceOptions, +) from cwsandbox.exceptions import AsyncFunctionError, SandboxExecutionError if TYPE_CHECKING: @@ -83,6 +88,7 @@ def __init__( s3_mount: dict[str, Any] | None = None, ports: list[dict[str, Any]] | None = None, network: NetworkOptions | dict[str, Any] | None = None, + file_system_snapshot: FileSystemSnapshotOptions | dict[str, Any] | None = None, max_timeout_seconds: int | None = None, environment_variables: dict[str, str] | None = None, annotations: dict[str, str] | None = None, @@ -110,6 +116,9 @@ def __init__( s3_mount: S3 bucket mount configuration ports: Port mappings for the sandbox network: Network configuration (NetworkOptions dataclass) + file_system_snapshot: File-system snapshot (FSS) mount configuration. + Accepts a FileSystemSnapshotOptions or a dict with ``mount_path``, + optional ``size``, and optional ``file_system_snapshot_id`` (restore on start). max_timeout_seconds: Maximum timeout for sandbox operations environment_variables: Environment variables to inject into the sandbox. Merges with and overrides matching keys from the session defaults. @@ -150,6 +159,7 @@ def __init__( self._s3_mount = s3_mount self._ports = ports self._network = network + self._file_system_snapshot = file_system_snapshot self._max_timeout_seconds = max_timeout_seconds self._environment_variables = environment_variables self._annotations = annotations @@ -270,6 +280,8 @@ async def _execute_async(self, *args: Any, **kwargs: Any) -> R: sandbox_kwargs["ports"] = self._ports if self._network is not None: sandbox_kwargs["network"] = self._network + if self._file_system_snapshot is not None: + sandbox_kwargs["file_system_snapshot"] = self._file_system_snapshot if self._max_timeout_seconds is not None: sandbox_kwargs["max_timeout_seconds"] = self._max_timeout_seconds if self._environment_variables is not None: diff --git a/src/cwsandbox/_proto/gateway_pb2.py b/src/cwsandbox/_proto/gateway_pb2.py index 74b6b93..12b2924 100644 --- a/src/cwsandbox/_proto/gateway_pb2.py +++ b/src/cwsandbox/_proto/gateway_pb2.py @@ -21,7 +21,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\'coreweave/sandbox/v1beta2/gateway.proto\x12\x19\x63oreweave.sandbox.v1beta2\x1a\'coreweave/sandbox/v1beta2/secrets.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"Y\n\x0bMountedFile\x12\"\n\nmount_path\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tmountPath\x12&\n\x0c\x66ile_content\x18\x02 \x01(\x0c\x42\x03\xe0\x41\x02R\x0b\x66ileContent\"t\n\nGpuRequest\x12\x1b\n\tgpu_count\x18\x01 \x01(\x03R\x08gpuCount\x12\x1b\n\x08gpu_type\x18\x02 \x01(\tH\x00R\x07gpuType\x12$\n\rgpu_memory_gb\x18\x03 \x01(\x03H\x00R\x0bgpuMemoryGbB\x06\n\x04spec\"\x83\x01\n\x0fResourceRequest\x12\x15\n\x03\x63pu\x18\x01 \x01(\tB\x03\xe0\x41\x01R\x03\x63pu\x12\x1b\n\x06memory\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x06memory\x12<\n\x03gpu\x18\x03 \x01(\x0b\x32%.coreweave.sandbox.v1beta2.GpuRequestB\x03\xe0\x41\x01R\x03gpu\"l\n\x04Port\x12*\n\x0e\x63ontainer_port\x18\x01 \x01(\x05\x42\x03\xe0\x41\x02R\rcontainerPort\x12\x17\n\x04name\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x04name\x12\x1f\n\x08protocol\x18\x03 \x01(\tB\x03\xe0\x41\x01R\x08protocol\"9\n\rServiceConfig\x12(\n\rexposed_ports\x18\x01 \x03(\x05\x42\x03\xe0\x41\x02R\x0c\x65xposedPorts\"\x88\x01\n\x0eNetworkOptions\x12&\n\x0cingress_mode\x18\x01 \x01(\tB\x03\xe0\x41\x01R\x0bingressMode\x12(\n\rexposed_ports\x18\x02 \x03(\x05\x42\x03\xe0\x41\x01R\x0c\x65xposedPorts\x12$\n\x0b\x65gress_mode\x18\x03 \x01(\tB\x03\xe0\x41\x01R\negressMode\"b\n\x16KubernetesSecretSource\x12$\n\x0bsecret_name\x18\x01 \x01(\tB\x03\xe0\x41\x02R\nsecretName\x12\"\n\nsecret_key\x18\x02 \x01(\tB\x03\xe0\x41\x02R\tsecretKey\"\xa1\x01\n\x1cRunnerClusterSecretReference\x12S\n\nkubernetes\x18\x01 \x01(\x0b\x32\x31.coreweave.sandbox.v1beta2.KubernetesSecretSourceH\x00R\nkubernetes\x12\x1c\n\x07\x65nv_var\x18\n \x01(\tB\x03\xe0\x41\x02R\x06\x65nvVarB\x08\n\x06sourceJ\x04\x08\x02\x10\n\"m\n\x07S3Mount\x12\x1b\n\x06\x62ucket\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x06\x62ucket\x12!\n\tdirectory\x18\x02 \x01(\tB\x03\xe0\x41\x02R\tdirectory\x12\"\n\nmount_path\x18\x03 \x01(\tB\x03\xe0\x41\x02R\tmountPath\"E\n\x0b\x45xecPayload\x12\x1d\n\x07\x63ommand\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x07\x63ommand\x12\x17\n\x04\x61rgs\x18\x02 \x03(\tB\x03\xe0\x41\x01R\x04\x61rgs\"[\n\x0c\x45xecResponse\x12\x16\n\x06stdout\x18\x01 \x01(\x0cR\x06stdout\x12\x16\n\x06stderr\x18\x02 \x01(\x0cR\x06stderr\x12\x1b\n\texit_code\x18\x03 \x01(\x05R\x08\x65xitCode\"\x8b\x01\n\rResourceUsage\x12.\n\x13\x63pu_millicores_used\x18\x01 \x01(\x03R\x11\x63puMillicoresUsed\x12$\n\x0ememory_mb_used\x18\x02 \x01(\x03R\x0cmemoryMbUsed\x12$\n\x0egpu_count_used\x18\x03 \x01(\x03R\x0cgpuCountUsed\"\x83\x01\n\x13ObjectStorageAccess\x12\x18\n\x07\x62uckets\x18\x01 \x03(\tR\x07\x62uckets\x12R\n\npermission\x18\x02 \x01(\x0e\x32\x32.coreweave.sandbox.v1beta2.ObjectStoragePermissionR\npermission\"\xd2\x0c\n\x13StartSandboxRequest\x12\x1d\n\x07\x63ommand\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x07\x63ommand\x12\x17\n\x04\x61rgs\x18\x02 \x03(\tB\x03\xe0\x41\x01R\x04\x61rgs\x12\x17\n\x04tags\x18\x03 \x03(\tB\x03\xe0\x41\x01R\x04tags\x12M\n\tresources\x18\x04 \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestB\x03\xe0\x41\x01R\tresources\x12,\n\x0f\x63ontainer_image\x18\x05 \x01(\tB\x03\xe0\x41\x02R\x0e\x63ontainerImage\x12\x82\x01\n\x15\x65nvironment_variables\x18\x06 \x03(\x0b\x32H.coreweave.sandbox.v1beta2.StartSandboxRequest.EnvironmentVariablesEntryB\x03\xe0\x41\x01R\x14\x65nvironmentVariables\x12:\n\x05ports\x18\x07 \x03(\x0b\x32\x1f.coreweave.sandbox.v1beta2.PortB\x03\xe0\x41\x01R\x05ports\x12P\n\rmounted_files\x18\x08 \x03(\x0b\x32&.coreweave.sandbox.v1beta2.MountedFileB\x03\xe0\x41\x01R\x0cmountedFiles\x12\x42\n\x08s3_mount\x18\t \x01(\x0b\x32\".coreweave.sandbox.v1beta2.S3MountB\x03\xe0\x41\x01R\x07s3Mount\x12H\n\x07network\x18\n \x01(\x0b\x32).coreweave.sandbox.v1beta2.NetworkOptionsB\x03\xe0\x41\x01R\x07network\x12$\n\x0bprofile_ids\x18\x14 \x03(\tB\x03\xe0\x41\x01R\nprofileIds\x12\"\n\nrunner_ids\x18\x15 \x03(\tB\x03\xe0\x41\x01R\trunnerIds\x12(\n\rprofile_names\x18! \x03(\tB\x03\xe0\x41\x01R\x0cprofileNames\x12\x35\n\x14max_lifetime_seconds\x18\x16 \x01(\x05\x42\x03\xe0\x41\x01R\x12maxLifetimeSeconds\x12\x33\n\x13max_timeout_seconds\x18\x17 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\x12r\n\x16runner_cluster_secrets\x18\x18 \x03(\x0b\x32\x37.coreweave.sandbox.v1beta2.RunnerClusterSecretReferenceB\x03\xe0\x41\x01R\x14runnerClusterSecrets\x12g\n\x15object_storage_access\x18\x19 \x01(\x0b\x32..coreweave.sandbox.v1beta2.ObjectStorageAccessB\x03\xe0\x41\x01R\x13objectStorageAccess\x12p\n\x0fpod_annotations\x18\x1a \x03(\x0b\x32\x42.coreweave.sandbox.v1beta2.StartSandboxRequest.PodAnnotationsEntryB\x03\xe0\x41\x01R\x0epodAnnotations\x12Y\n\rsecret_stores\x18\x1e \x03(\x0b\x32/.coreweave.sandbox.v1beta2.SecretStoreReferenceB\x03\xe0\x41\x01R\x0csecretStores\x12X\n\x0fresource_limits\x18\x1f \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestB\x03\xe0\x41\x01R\x0eresourceLimits\x12\\\n\x11resource_requests\x18 \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestB\x03\xe0\x41\x01R\x10resourceRequests\x1aG\n\x19\x45nvironmentVariablesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x1a\x41\n\x13PodAnnotationsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\x88\x06\n\x14StartSandboxResponse\x12\x1d\n\nsandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x42\n\x0fstarted_at_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\rstartedAtTime\x12\'\n\x0fservice_address\x18\x03 \x01(\tR\x0eserviceAddress\x12\x44\n\rexposed_ports\x18\x04 \x03(\x0b\x32\x1f.coreweave.sandbox.v1beta2.PortR\x0c\x65xposedPorts\x12[\n\x13requested_resources\x18\x05 \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestR\x12requestedResources\x12\x1d\n\nprofile_id\x18\x06 \x01(\tR\tprofileId\x12\x1b\n\trunner_id\x18\x07 \x01(\tR\x08runnerId\x12O\n\x0esandbox_status\x18\x08 \x01(\x0e\x32(.coreweave.sandbox.v1beta2.SandboxStatusR\rsandboxStatus\x12\x30\n\x14\x61pplied_ingress_mode\x18\t \x01(\tR\x12\x61ppliedIngressMode\x12.\n\x13\x61pplied_egress_mode\x18\n \x01(\tR\x11\x61ppliedEgressMode\x12\x66\n\x19requested_resource_limits\x18\x0b \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestR\x17requestedResourceLimits\x12j\n\x1brequested_resource_requests\x18\x0c \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestR\x19requestedResourceRequests\"\xdd\x01\n\x12StopSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12?\n\x19graceful_shutdown_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x17gracefulShutdownSeconds\x12-\n\x10snapshot_on_stop\x18\x03 \x01(\x08\x42\x03\xe0\x41\x01R\x0esnapshotOnStop\x12\x33\n\x13max_timeout_seconds\x18\x04 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"T\n\x13StopSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"l\n\x11GetSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"\xdd\x04\n\x12GetSandboxResponse\x12\x1d\n\nsandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x42\n\x0fstarted_at_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\rstartedAtTime\x12O\n\x0esandbox_status\x18\x03 \x01(\x0e\x32(.coreweave.sandbox.v1beta2.SandboxStatusR\rsandboxStatus\x12^\n\x16\x63urrent_resource_usage\x18\x04 \x01(\x0b\x32(.coreweave.sandbox.v1beta2.ResourceUsageR\x14\x63urrentResourceUsage\x12\x1b\n\trunner_id\x18\x05 \x01(\tR\x08runnerId\x12&\n\x0frunner_group_id\x18\x06 \x01(\tR\rrunnerGroupId\x12\x1d\n\nprofile_id\x18\x07 \x01(\tR\tprofileId\x12\'\n\x0fservice_address\x18\x08 \x01(\tR\x0eserviceAddress\x12\x44\n\rexposed_ports\x18\t \x03(\x0b\x32\x1f.coreweave.sandbox.v1beta2.PortR\x0c\x65xposedPorts\x12\x30\n\x14\x61pplied_ingress_mode\x18\n \x01(\tR\x12\x61ppliedIngressMode\x12.\n\x13\x61pplied_egress_mode\x18\x0b \x01(\tR\x11\x61ppliedEgressMode\"\x93\x03\n\x14ListSandboxesRequest\x12\x17\n\x04tags\x18\x01 \x03(\tB\x03\xe0\x41\x01R\x04tags\x12\x45\n\x06status\x18\x02 \x01(\x0e\x32(.coreweave.sandbox.v1beta2.SandboxStatusB\x03\xe0\x41\x01R\x06status\x12$\n\x0bprofile_ids\x18\x03 \x03(\tB\x03\xe0\x41\x01R\nprofileIds\x12\"\n\nrunner_ids\x18\x04 \x03(\tB\x03\xe0\x41\x01R\trunnerIds\x12(\n\rprofile_names\x18\x07 \x03(\tB\x03\xe0\x41\x01R\x0cprofileNames\x12\x33\n\x13max_timeout_seconds\x18\x05 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\x12,\n\x0finclude_stopped\x18\x06 \x01(\x08\x42\x03\xe0\x41\x01R\x0eincludeStopped\x12 \n\tpage_size\x18\x08 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\t \x01(\tB\x03\xe0\x41\x01R\tpageToken\"\x85\x01\n\x15ListSandboxesResponse\x12\x44\n\tsandboxes\x18\x01 \x03(\x0b\x32&.coreweave.sandbox.v1beta2.SandboxInfoR\tsandboxes\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xd6\x04\n\x0bSandboxInfo\x12\x1d\n\nsandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x42\n\x0fstarted_at_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\rstartedAtTime\x12O\n\x0esandbox_status\x18\x03 \x01(\x0e\x32(.coreweave.sandbox.v1beta2.SandboxStatusR\rsandboxStatus\x12^\n\x16\x63urrent_resource_usage\x18\x04 \x01(\x0b\x32(.coreweave.sandbox.v1beta2.ResourceUsageR\x14\x63urrentResourceUsage\x12\x1b\n\trunner_id\x18\x05 \x01(\tR\x08runnerId\x12&\n\x0frunner_group_id\x18\x06 \x01(\tR\rrunnerGroupId\x12\x1d\n\nprofile_id\x18\x07 \x01(\tR\tprofileId\x12\'\n\x0fservice_address\x18\x08 \x01(\tR\x0eserviceAddress\x12\x44\n\rexposed_ports\x18\t \x03(\x0b\x32\x1f.coreweave.sandbox.v1beta2.PortR\x0c\x65xposedPorts\x12\x30\n\x14\x61pplied_ingress_mode\x18\n \x01(\tR\x12\x61ppliedIngressMode\x12.\n\x13\x61pplied_egress_mode\x18\x0b \x01(\tR\x11\x61ppliedEgressMode\"o\n\x14\x44\x65leteSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"V\n\x15\x44\x65leteSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"\xa5\x01\n\x12\x45xecSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x1d\n\x07\x63ommand\x18\x02 \x03(\tB\x03\xe0\x41\x02R\x07\x63ommand\x12\x17\n\x04\x61rgs\x18\x03 \x03(\tB\x03\xe0\x41\x01R\x04\x61rgs\x12\x33\n\x13max_timeout_seconds\x18\x04 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"V\n\x13\x45xecSandboxResponse\x12?\n\x06result\x18\x01 \x01(\x0b\x32\'.coreweave.sandbox.v1beta2.ExecResponseR\x06result\"\xbb\x01\n\x15\x41\x64\x64\x46ileSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12(\n\rfile_contents\x18\x02 \x01(\x0c\x42\x03\xe0\x41\x02R\x0c\x66ileContents\x12\x1f\n\x08\x66ilepath\x18\x03 \x01(\tB\x03\xe0\x41\x02R\x08\x66ilepath\x12\x33\n\x13max_timeout_seconds\x18\x04 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"W\n\x16\x41\x64\x64\x46ileSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"\x96\x01\n\x1aRetrieveFileSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x1f\n\x08\x66ilepath\x18\x02 \x01(\tB\x03\xe0\x41\x02R\x08\x66ilepath\x12\x33\n\x13max_timeout_seconds\x18\x03 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"\x81\x01\n\x1bRetrieveFileSandboxResponse\x12#\n\rfile_contents\x18\x01 \x01(\x0cR\x0c\x66ileContents\x12\x18\n\x07success\x18\x02 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x03 \x01(\tR\x0c\x65rrorMessage\"n\n\x13PauseSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"U\n\x14PauseSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"o\n\x14ResumeSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"V\n\x15ResumeSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"\xe3\x03\n\x11RawSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12K\n\x0b\x61\x63tion_type\x18\x02 \x01(\x0e\x32%.coreweave.sandbox.v1beta2.ActionTypeB\x03\xe0\x41\x02R\nactionType\x12K\n\x0c\x65xec_payload\x18\x03 \x01(\x0b\x32&.coreweave.sandbox.v1beta2.ExecPayloadH\x00R\x0b\x65xecPayload\x12\\\n\x10\x61\x64\x64_file_payload\x18\x04 \x01(\x0b\x32\x30.coreweave.sandbox.v1beta2.AddFileSandboxRequestH\x00R\x0e\x61\x64\x64\x46ilePayload\x12k\n\x15retrieve_file_payload\x18\x05 \x01(\x0b\x32\x35.coreweave.sandbox.v1beta2.RetrieveFileSandboxRequestH\x00R\x13retrieveFilePayload\x12\x33\n\x13max_timeout_seconds\x18\x06 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSecondsB\x10\n\x0e\x61\x63tion_payload\"\x97\x03\n\x12RawSandboxResponse\x12\x46\n\x0b\x61\x63tion_type\x18\x01 \x01(\x0e\x32%.coreweave.sandbox.v1beta2.ActionTypeR\nactionType\x12U\n\rexec_response\x18\x02 \x01(\x0b\x32..coreweave.sandbox.v1beta2.ExecSandboxResponseH\x00R\x0c\x65xecResponse\x12_\n\x11\x61\x64\x64_file_response\x18\x03 \x01(\x0b\x32\x31.coreweave.sandbox.v1beta2.AddFileSandboxResponseH\x00R\x0f\x61\x64\x64\x46ileResponse\x12n\n\x16retrieve_file_response\x18\x04 \x01(\x0b\x32\x36.coreweave.sandbox.v1beta2.RetrieveFileSandboxResponseH\x00R\x14retrieveFileResponseB\x11\n\x0f\x61\x63tion_response\"\x8f\x03\n\x16ObjectStorageWIFConfig\x12\x13\n\x02id\x18\x01 \x01(\tB\x03\xe0\x41\x03R\x02id\x12\'\n\rwif_config_id\x18\x02 \x01(\tB\x03\xe0\x41\x02R\x0bwifConfigId\x12\x1d\n\x07\x65nabled\x18\x03 \x01(\x08H\x00R\x07\x65nabled\x88\x01\x01\x12,\n\x0f\x61llowed_buckets\x18\x04 \x03(\tB\x03\xe0\x41\x01R\x0e\x61llowedBuckets\x12^\n\x0emax_permission\x18\x05 \x01(\x0e\x32\x32.coreweave.sandbox.v1beta2.ObjectStoragePermissionB\x03\xe0\x41\x02R\rmaxPermission\x12>\n\ncreated_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03R\tcreatedAt\x12>\n\nupdated_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03R\tupdatedAtB\n\n\x08_enabled\"\"\n GetObjectStorageWIFConfigRequest\"\x84\x02\n SetObjectStorageWIFConfigRequest\x12\'\n\rwif_config_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x0bwifConfigId\x12\x1d\n\x07\x65nabled\x18\x02 \x01(\x08H\x00R\x07\x65nabled\x88\x01\x01\x12,\n\x0f\x61llowed_buckets\x18\x03 \x03(\tB\x03\xe0\x41\x01R\x0e\x61llowedBuckets\x12^\n\x0emax_permission\x18\x04 \x01(\x0e\x32\x32.coreweave.sandbox.v1beta2.ObjectStoragePermissionB\x03\xe0\x41\x02R\rmaxPermissionB\n\n\x08_enabled\"%\n#DeleteObjectStorageWIFConfigRequest\"&\n$DeleteObjectStorageWIFConfigResponse*\x9b\x02\n\rSandboxStatus\x12\x1e\n\x1aSANDBOX_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n\x17SANDBOX_STATUS_CREATING\x10\x01\x12\x1a\n\x16SANDBOX_STATUS_RUNNING\x10\x02\x12\x1c\n\x18SANDBOX_STATUS_COMPLETED\x10\x03\x12\x19\n\x15SANDBOX_STATUS_FAILED\x10\x04\x12!\n\x19SANDBOX_STATUS_TERMINATED\x10\x05\x1a\x02\x08\x01\x12\x1a\n\x16SANDBOX_STATUS_PENDING\x10\x06\x12\x19\n\x15SANDBOX_STATUS_PAUSED\x10\x07\x12\x1e\n\x1aSANDBOX_STATUS_TERMINATING\x10\t*\xf3\x01\n\nActionType\x12\x1b\n\x17\x41\x43TION_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10\x41\x43TION_TYPE_EXEC\x10\x01\x12\x18\n\x14\x41\x43TION_TYPE_ADD_FILE\x10\x02\x12\x1d\n\x19\x41\x43TION_TYPE_RETRIEVE_FILE\x10\x03\x12\x18\n\x14\x41\x43TION_TYPE_GET_LOGS\x10\x04\x12\x18\n\x14\x41\x43TION_TYPE_SNAPSHOT\x10\x05\x12\x17\n\x13\x41\x43TION_TYPE_RESTORE\x10\x06\x12\x16\n\x12\x41\x43TION_TYPE_STATUS\x10\x07\x12\x14\n\x10\x41\x43TION_TYPE_STOP\x10\x08*\xb7\x01\n\nEgressType\x12\x1b\n\x17\x45GRESS_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10\x45GRESS_TYPE_NONE\x10\x01\x12\x18\n\x14\x45GRESS_TYPE_INTERNET\x10\x02\x12\x14\n\x10\x45GRESS_TYPE_USER\x10\x03\x12\x13\n\x0f\x45GRESS_TYPE_ORG\x10\x04\x12\x16\n\x12\x45GRESS_TYPE_RUNWAY\x10\x05\x12\x19\n\x15\x45GRESS_TYPE_ALLOWLIST\x10\x06*\x92\x01\n\x17ObjectStoragePermission\x12)\n%OBJECT_STORAGE_PERMISSION_UNSPECIFIED\x10\x00\x12\"\n\x1eOBJECT_STORAGE_PERMISSION_READ\x10\x01\x12(\n$OBJECT_STORAGE_PERMISSION_READ_WRITE\x10\x02\x32\xee\x11\n\x0eGatewayService\x12\x87\x01\n\x05Start\x12..coreweave.sandbox.v1beta2.StartSandboxRequest\x1a/.coreweave.sandbox.v1beta2.StartSandboxResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\"\x12/v1beta2/sandboxes:\x01*\x12\x96\x01\n\x04Stop\x12-.coreweave.sandbox.v1beta2.StopSandboxRequest\x1a..coreweave.sandbox.v1beta2.StopSandboxResponse\"/\x82\xd3\xe4\x93\x02)\"$/v1beta2/sandboxes/{sandbox_id}/stop:\x01*\x12\x8b\x01\n\x03Get\x12,.coreweave.sandbox.v1beta2.GetSandboxRequest\x1a-.coreweave.sandbox.v1beta2.GetSandboxResponse\"\'\x82\xd3\xe4\x93\x02!\x12\x1f/v1beta2/sandboxes/{sandbox_id}\x12\x85\x01\n\x04List\x12/.coreweave.sandbox.v1beta2.ListSandboxesRequest\x1a\x30.coreweave.sandbox.v1beta2.ListSandboxesResponse\"\x1a\x82\xd3\xe4\x93\x02\x14\x12\x12/v1beta2/sandboxes\x12\x94\x01\n\x06\x44\x65lete\x12/.coreweave.sandbox.v1beta2.DeleteSandboxRequest\x1a\x30.coreweave.sandbox.v1beta2.DeleteSandboxResponse\"\'\x82\xd3\xe4\x93\x02!*\x1f/v1beta2/sandboxes/{sandbox_id}\x12\x96\x01\n\x04\x45xec\x12-.coreweave.sandbox.v1beta2.ExecSandboxRequest\x1a..coreweave.sandbox.v1beta2.ExecSandboxResponse\"/\x82\xd3\xe4\x93\x02)\"$/v1beta2/sandboxes/{sandbox_id}/exec:\x01*\x12\xa0\x01\n\x07\x41\x64\x64\x46ile\x12\x30.coreweave.sandbox.v1beta2.AddFileSandboxRequest\x1a\x31.coreweave.sandbox.v1beta2.AddFileSandboxResponse\"0\x82\xd3\xe4\x93\x02*\"%/v1beta2/sandboxes/{sandbox_id}/files:\x01*\x12\xb7\x01\n\x0cRetrieveFile\x12\x35.coreweave.sandbox.v1beta2.RetrieveFileSandboxRequest\x1a\x36.coreweave.sandbox.v1beta2.RetrieveFileSandboxResponse\"8\x82\xd3\xe4\x93\x02\x32\x12\x30/v1beta2/sandboxes/{sandbox_id}/files/{filepath}\x12\x9a\x01\n\x05Pause\x12..coreweave.sandbox.v1beta2.PauseSandboxRequest\x1a/.coreweave.sandbox.v1beta2.PauseSandboxResponse\"0\x82\xd3\xe4\x93\x02*\"%/v1beta2/sandboxes/{sandbox_id}/pause:\x01*\x12\x9e\x01\n\x06Resume\x12/.coreweave.sandbox.v1beta2.ResumeSandboxRequest\x1a\x30.coreweave.sandbox.v1beta2.ResumeSandboxResponse\"1\x82\xd3\xe4\x93\x02+\"&/v1beta2/sandboxes/{sandbox_id}/resume:\x01*\x12\x92\x01\n\x03Raw\x12,.coreweave.sandbox.v1beta2.RawSandboxRequest\x1a-.coreweave.sandbox.v1beta2.RawSandboxResponse\".\x82\xd3\xe4\x93\x02(\"#/v1beta2/sandboxes/{sandbox_id}/raw:\x01*\x12\xb7\x01\n\x19GetObjectStorageWIFConfig\x12;.coreweave.sandbox.v1beta2.GetObjectStorageWIFConfigRequest\x1a\x31.coreweave.sandbox.v1beta2.ObjectStorageWIFConfig\"*\x82\xd3\xe4\x93\x02$\x12\"/v1beta2/object-storage/wif-config\x12\xba\x01\n\x19SetObjectStorageWIFConfig\x12;.coreweave.sandbox.v1beta2.SetObjectStorageWIFConfigRequest\x1a\x31.coreweave.sandbox.v1beta2.ObjectStorageWIFConfig\"-\x82\xd3\xe4\x93\x02\'\x1a\"/v1beta2/object-storage/wif-config:\x01*\x12\xcb\x01\n\x1c\x44\x65leteObjectStorageWIFConfig\x12>.coreweave.sandbox.v1beta2.DeleteObjectStorageWIFConfigRequest\x1a?.coreweave.sandbox.v1beta2.DeleteObjectStorageWIFConfigResponse\"*\x82\xd3\xe4\x93\x02$*\"/v1beta2/object-storage/wif-configBKZIgithub.com/coreweave/sandbox/api/coreweave/sandbox/v1beta2;sandboxv1beta2b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\'coreweave/sandbox/v1beta2/gateway.proto\x12\x19\x63oreweave.sandbox.v1beta2\x1a\'coreweave/sandbox/v1beta2/secrets.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"Y\n\x0bMountedFile\x12\"\n\nmount_path\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tmountPath\x12&\n\x0c\x66ile_content\x18\x02 \x01(\x0c\x42\x03\xe0\x41\x02R\x0b\x66ileContent\"t\n\nGpuRequest\x12\x1b\n\tgpu_count\x18\x01 \x01(\x03R\x08gpuCount\x12\x1b\n\x08gpu_type\x18\x02 \x01(\tH\x00R\x07gpuType\x12$\n\rgpu_memory_gb\x18\x03 \x01(\x03H\x00R\x0bgpuMemoryGbB\x06\n\x04spec\"\x83\x01\n\x0fResourceRequest\x12\x15\n\x03\x63pu\x18\x01 \x01(\tB\x03\xe0\x41\x01R\x03\x63pu\x12\x1b\n\x06memory\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x06memory\x12<\n\x03gpu\x18\x03 \x01(\x0b\x32%.coreweave.sandbox.v1beta2.GpuRequestB\x03\xe0\x41\x01R\x03gpu\"l\n\x04Port\x12*\n\x0e\x63ontainer_port\x18\x01 \x01(\x05\x42\x03\xe0\x41\x02R\rcontainerPort\x12\x17\n\x04name\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x04name\x12\x1f\n\x08protocol\x18\x03 \x01(\tB\x03\xe0\x41\x01R\x08protocol\"9\n\rServiceConfig\x12(\n\rexposed_ports\x18\x01 \x03(\x05\x42\x03\xe0\x41\x02R\x0c\x65xposedPorts\"\x88\x01\n\x0eNetworkOptions\x12&\n\x0cingress_mode\x18\x01 \x01(\tB\x03\xe0\x41\x01R\x0bingressMode\x12(\n\rexposed_ports\x18\x02 \x03(\x05\x42\x03\xe0\x41\x01R\x0c\x65xposedPorts\x12$\n\x0b\x65gress_mode\x18\x03 \x01(\tB\x03\xe0\x41\x01R\negressMode\"b\n\x16KubernetesSecretSource\x12$\n\x0bsecret_name\x18\x01 \x01(\tB\x03\xe0\x41\x02R\nsecretName\x12\"\n\nsecret_key\x18\x02 \x01(\tB\x03\xe0\x41\x02R\tsecretKey\"\xa1\x01\n\x1cRunnerClusterSecretReference\x12S\n\nkubernetes\x18\x01 \x01(\x0b\x32\x31.coreweave.sandbox.v1beta2.KubernetesSecretSourceH\x00R\nkubernetes\x12\x1c\n\x07\x65nv_var\x18\n \x01(\tB\x03\xe0\x41\x02R\x06\x65nvVarB\x08\n\x06sourceJ\x04\x08\x02\x10\n\"m\n\x07S3Mount\x12\x1b\n\x06\x62ucket\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x06\x62ucket\x12!\n\tdirectory\x18\x02 \x01(\tB\x03\xe0\x41\x02R\tdirectory\x12\"\n\nmount_path\x18\x03 \x01(\tB\x03\xe0\x41\x02R\tmountPath\"V\n\x18\x46ileSystemSnapshotSource\x12:\n\x17\x66ile_system_snapshot_id\x18\x01 \x01(\tB\x03\xe0\x41\x01R\x14\x66ileSystemSnapshotId\"\xe0\x01\n\x16SandboxFileSystemMount\x12\"\n\nmount_path\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tmountPath\x12\x17\n\x04size\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x04size\x12g\n\x14\x66ile_system_snapshot\x18\x03 \x01(\x0b\x32\x33.coreweave.sandbox.v1beta2.FileSystemSnapshotSourceH\x00R\x12\x66ileSystemSnapshotB\x08\n\x06sourceJ\x04\x08\x04\x10\x05R\x10live_file_system\"E\n\x0b\x45xecPayload\x12\x1d\n\x07\x63ommand\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x07\x63ommand\x12\x17\n\x04\x61rgs\x18\x02 \x03(\tB\x03\xe0\x41\x01R\x04\x61rgs\"\x99\x02\n\x0c\x45xecResponse\x12\x16\n\x06stdout\x18\x01 \x01(\x0cR\x06stdout\x12\x16\n\x06stderr\x18\x02 \x01(\x0cR\x06stderr\x12\x1b\n\texit_code\x18\x03 \x01(\x05R\x08\x65xitCode\x12)\n\x10stdout_truncated\x18\x04 \x01(\x08R\x0fstdoutTruncated\x12)\n\x10stderr_truncated\x18\x05 \x01(\x08R\x0fstderrTruncated\x12\x32\n\x15stdout_bytes_produced\x18\x06 \x01(\x03R\x13stdoutBytesProduced\x12\x32\n\x15stderr_bytes_produced\x18\x07 \x01(\x03R\x13stderrBytesProduced\"\x8b\x01\n\rResourceUsage\x12.\n\x13\x63pu_millicores_used\x18\x01 \x01(\x03R\x11\x63puMillicoresUsed\x12$\n\x0ememory_mb_used\x18\x02 \x01(\x03R\x0cmemoryMbUsed\x12$\n\x0egpu_count_used\x18\x03 \x01(\x03R\x0cgpuCountUsed\"\x83\x01\n\x13ObjectStorageAccess\x12\x18\n\x07\x62uckets\x18\x01 \x03(\tR\x07\x62uckets\x12R\n\npermission\x18\x02 \x01(\x0e\x32\x32.coreweave.sandbox.v1beta2.ObjectStoragePermissionR\npermission\"\xab\r\n\x13StartSandboxRequest\x12\x1d\n\x07\x63ommand\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x07\x63ommand\x12\x17\n\x04\x61rgs\x18\x02 \x03(\tB\x03\xe0\x41\x01R\x04\x61rgs\x12\x17\n\x04tags\x18\x03 \x03(\tB\x03\xe0\x41\x01R\x04tags\x12M\n\tresources\x18\x04 \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestB\x03\xe0\x41\x01R\tresources\x12,\n\x0f\x63ontainer_image\x18\x05 \x01(\tB\x03\xe0\x41\x02R\x0e\x63ontainerImage\x12\x82\x01\n\x15\x65nvironment_variables\x18\x06 \x03(\x0b\x32H.coreweave.sandbox.v1beta2.StartSandboxRequest.EnvironmentVariablesEntryB\x03\xe0\x41\x01R\x14\x65nvironmentVariables\x12:\n\x05ports\x18\x07 \x03(\x0b\x32\x1f.coreweave.sandbox.v1beta2.PortB\x03\xe0\x41\x01R\x05ports\x12P\n\rmounted_files\x18\x08 \x03(\x0b\x32&.coreweave.sandbox.v1beta2.MountedFileB\x03\xe0\x41\x01R\x0cmountedFiles\x12\x42\n\x08s3_mount\x18\t \x01(\x0b\x32\".coreweave.sandbox.v1beta2.S3MountB\x03\xe0\x41\x01R\x07s3Mount\x12H\n\x07network\x18\n \x01(\x0b\x32).coreweave.sandbox.v1beta2.NetworkOptionsB\x03\xe0\x41\x01R\x07network\x12W\n\x0b\x66ile_system\x18\x0b \x01(\x0b\x32\x31.coreweave.sandbox.v1beta2.SandboxFileSystemMountB\x03\xe0\x41\x01R\nfileSystem\x12$\n\x0bprofile_ids\x18\x14 \x03(\tB\x03\xe0\x41\x01R\nprofileIds\x12\"\n\nrunner_ids\x18\x15 \x03(\tB\x03\xe0\x41\x01R\trunnerIds\x12(\n\rprofile_names\x18! \x03(\tB\x03\xe0\x41\x01R\x0cprofileNames\x12\x35\n\x14max_lifetime_seconds\x18\x16 \x01(\x05\x42\x03\xe0\x41\x01R\x12maxLifetimeSeconds\x12\x33\n\x13max_timeout_seconds\x18\x17 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\x12r\n\x16runner_cluster_secrets\x18\x18 \x03(\x0b\x32\x37.coreweave.sandbox.v1beta2.RunnerClusterSecretReferenceB\x03\xe0\x41\x01R\x14runnerClusterSecrets\x12g\n\x15object_storage_access\x18\x19 \x01(\x0b\x32..coreweave.sandbox.v1beta2.ObjectStorageAccessB\x03\xe0\x41\x01R\x13objectStorageAccess\x12p\n\x0fpod_annotations\x18\x1a \x03(\x0b\x32\x42.coreweave.sandbox.v1beta2.StartSandboxRequest.PodAnnotationsEntryB\x03\xe0\x41\x01R\x0epodAnnotations\x12Y\n\rsecret_stores\x18\x1e \x03(\x0b\x32/.coreweave.sandbox.v1beta2.SecretStoreReferenceB\x03\xe0\x41\x01R\x0csecretStores\x12X\n\x0fresource_limits\x18\x1f \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestB\x03\xe0\x41\x01R\x0eresourceLimits\x12\\\n\x11resource_requests\x18 \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestB\x03\xe0\x41\x01R\x10resourceRequests\x1aG\n\x19\x45nvironmentVariablesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x1a\x41\n\x13PodAnnotationsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\x88\x06\n\x14StartSandboxResponse\x12\x1d\n\nsandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x42\n\x0fstarted_at_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\rstartedAtTime\x12\'\n\x0fservice_address\x18\x03 \x01(\tR\x0eserviceAddress\x12\x44\n\rexposed_ports\x18\x04 \x03(\x0b\x32\x1f.coreweave.sandbox.v1beta2.PortR\x0c\x65xposedPorts\x12[\n\x13requested_resources\x18\x05 \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestR\x12requestedResources\x12\x1d\n\nprofile_id\x18\x06 \x01(\tR\tprofileId\x12\x1b\n\trunner_id\x18\x07 \x01(\tR\x08runnerId\x12O\n\x0esandbox_status\x18\x08 \x01(\x0e\x32(.coreweave.sandbox.v1beta2.SandboxStatusR\rsandboxStatus\x12\x30\n\x14\x61pplied_ingress_mode\x18\t \x01(\tR\x12\x61ppliedIngressMode\x12.\n\x13\x61pplied_egress_mode\x18\n \x01(\tR\x11\x61ppliedEgressMode\x12\x66\n\x19requested_resource_limits\x18\x0b \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestR\x17requestedResourceLimits\x12j\n\x1brequested_resource_requests\x18\x0c \x01(\x0b\x32*.coreweave.sandbox.v1beta2.ResourceRequestR\x19requestedResourceRequests\"\xe4\x02\n\x12StopSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12?\n\x19graceful_shutdown_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x17gracefulShutdownSeconds\x12\x43\n\x1c\x66ile_system_snapshot_on_stop\x18\x03 \x01(\x08\x42\x03\xe0\x41\x01R\x18\x66ileSystemSnapshotOnStop\x12\x33\n\x13max_timeout_seconds\x18\x04 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\x12,\n\x0fidempotency_key\x18\x05 \x01(\tB\x03\xe0\x41\x01R\x0eidempotencyKey\x12.\n\x0ewait_for_ready\x18\x06 \x01(\x08\x42\x03\xe0\x41\x01H\x00R\x0cwaitForReady\x88\x01\x01\x42\x11\n\x0f_wait_for_ready\"\x8b\x01\n\x13StopSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\x12\x35\n\x17\x66ile_system_snapshot_id\x18\x03 \x01(\tR\x14\x66ileSystemSnapshotId\"\x88\x02\n\x1f\x43reateFileSystemSnapshotRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12,\n\x0fidempotency_key\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x0eidempotencyKey\x12.\n\x0ewait_for_ready\x18\x03 \x01(\x08\x42\x03\xe0\x41\x01H\x00R\x0cwaitForReady\x88\x01\x01\x12\x38\n\x13max_timeout_seconds\x18\x04 \x01(\x05\x42\x03\xe0\x41\x01H\x01R\x11maxTimeoutSeconds\x88\x01\x01\x42\x11\n\x0f_wait_for_readyB\x16\n\x14_max_timeout_seconds\"\x98\x01\n CreateFileSystemSnapshotResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\x12\x35\n\x17\x66ile_system_snapshot_id\x18\x03 \x01(\tR\x14\x66ileSystemSnapshotId\"\xe0\x04\n\x12\x46ileSystemSnapshot\x12\x35\n\x17\x66ile_system_snapshot_id\x18\x01 \x01(\tR\x14\x66ileSystemSnapshotId\x12K\n\x06status\x18\x02 \x01(\x0e\x32\x33.coreweave.sandbox.v1beta2.FileSystemSnapshotStatusR\x06status\x12#\n\rstatus_reason\x18\x03 \x01(\tR\x0cstatusReason\x12\x1d\n\nsize_bytes\x18\x04 \x01(\x03R\tsizeBytes\x12\x39\n\ncreated_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x39\n\nupdated_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\tupdatedAt\x12=\n\x0c\x63ompleted_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0b\x63ompletedAt\x12*\n\x11source_sandbox_id\x18\x08 \x01(\tR\x0fsourceSandboxId\x12N\n\x07trigger\x18\t \x01(\x0e\x32\x34.coreweave.sandbox.v1beta2.FileSystemSnapshotTriggerR\x07trigger\x12\'\n\x0fidempotency_key\x18\n \x01(\tR\x0eidempotencyKey\x12(\n\robject_bucket\x18\x0b \x01(\tB\x03\xe0\x41\x03R\x0cobjectBucket\"\x8f\x01\n\x1cGetFileSystemSnapshotRequest\x12:\n\x17\x66ile_system_snapshot_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x14\x66ileSystemSnapshotId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"\x9b\x01\n\x1eListFileSystemSnapshotsRequest\x12 \n\tpage_size\x18\x01 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\x02 \x01(\tB\x03\xe0\x41\x01R\tpageToken\x12\x33\n\x13max_timeout_seconds\x18\x03 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"\xac\x01\n\x1fListFileSystemSnapshotsResponse\x12\x61\n\x15\x66ile_system_snapshots\x18\x01 \x03(\x0b\x32-.coreweave.sandbox.v1beta2.FileSystemSnapshotR\x13\x66ileSystemSnapshots\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x92\x01\n\x1f\x44\x65leteFileSystemSnapshotRequest\x12:\n\x17\x66ile_system_snapshot_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x14\x66ileSystemSnapshotId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"a\n DeleteFileSystemSnapshotResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"l\n\x11GetSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"\x82\x05\n\x12GetSandboxResponse\x12\x1d\n\nsandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x42\n\x0fstarted_at_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\rstartedAtTime\x12O\n\x0esandbox_status\x18\x03 \x01(\x0e\x32(.coreweave.sandbox.v1beta2.SandboxStatusR\rsandboxStatus\x12^\n\x16\x63urrent_resource_usage\x18\x04 \x01(\x0b\x32(.coreweave.sandbox.v1beta2.ResourceUsageR\x14\x63urrentResourceUsage\x12\x1b\n\trunner_id\x18\x05 \x01(\tR\x08runnerId\x12&\n\x0frunner_group_id\x18\x06 \x01(\tR\rrunnerGroupId\x12\x1d\n\nprofile_id\x18\x07 \x01(\tR\tprofileId\x12\'\n\x0fservice_address\x18\x08 \x01(\tR\x0eserviceAddress\x12\x44\n\rexposed_ports\x18\t \x03(\x0b\x32\x1f.coreweave.sandbox.v1beta2.PortR\x0c\x65xposedPorts\x12\x30\n\x14\x61pplied_ingress_mode\x18\n \x01(\tR\x12\x61ppliedIngressMode\x12.\n\x13\x61pplied_egress_mode\x18\x0b \x01(\tR\x11\x61ppliedEgressMode\x12#\n\rstatus_reason\x18\x0c \x01(\tR\x0cstatusReason\"\x93\x03\n\x14ListSandboxesRequest\x12\x17\n\x04tags\x18\x01 \x03(\tB\x03\xe0\x41\x01R\x04tags\x12\x45\n\x06status\x18\x02 \x01(\x0e\x32(.coreweave.sandbox.v1beta2.SandboxStatusB\x03\xe0\x41\x01R\x06status\x12$\n\x0bprofile_ids\x18\x03 \x03(\tB\x03\xe0\x41\x01R\nprofileIds\x12\"\n\nrunner_ids\x18\x04 \x03(\tB\x03\xe0\x41\x01R\trunnerIds\x12(\n\rprofile_names\x18\x07 \x03(\tB\x03\xe0\x41\x01R\x0cprofileNames\x12\x33\n\x13max_timeout_seconds\x18\x05 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\x12,\n\x0finclude_stopped\x18\x06 \x01(\x08\x42\x03\xe0\x41\x01R\x0eincludeStopped\x12 \n\tpage_size\x18\x08 \x01(\x05\x42\x03\xe0\x41\x01R\x08pageSize\x12\"\n\npage_token\x18\t \x01(\tB\x03\xe0\x41\x01R\tpageToken\"\x85\x01\n\x15ListSandboxesResponse\x12\x44\n\tsandboxes\x18\x01 \x03(\x0b\x32&.coreweave.sandbox.v1beta2.SandboxInfoR\tsandboxes\x12&\n\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xd6\x04\n\x0bSandboxInfo\x12\x1d\n\nsandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x42\n\x0fstarted_at_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\rstartedAtTime\x12O\n\x0esandbox_status\x18\x03 \x01(\x0e\x32(.coreweave.sandbox.v1beta2.SandboxStatusR\rsandboxStatus\x12^\n\x16\x63urrent_resource_usage\x18\x04 \x01(\x0b\x32(.coreweave.sandbox.v1beta2.ResourceUsageR\x14\x63urrentResourceUsage\x12\x1b\n\trunner_id\x18\x05 \x01(\tR\x08runnerId\x12&\n\x0frunner_group_id\x18\x06 \x01(\tR\rrunnerGroupId\x12\x1d\n\nprofile_id\x18\x07 \x01(\tR\tprofileId\x12\'\n\x0fservice_address\x18\x08 \x01(\tR\x0eserviceAddress\x12\x44\n\rexposed_ports\x18\t \x03(\x0b\x32\x1f.coreweave.sandbox.v1beta2.PortR\x0c\x65xposedPorts\x12\x30\n\x14\x61pplied_ingress_mode\x18\n \x01(\tR\x12\x61ppliedIngressMode\x12.\n\x13\x61pplied_egress_mode\x18\x0b \x01(\tR\x11\x61ppliedEgressMode\"o\n\x14\x44\x65leteSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"V\n\x15\x44\x65leteSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"\xab\x02\n\x12\x45xecSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x1d\n\x07\x63ommand\x18\x02 \x03(\tB\x03\xe0\x41\x02R\x07\x63ommand\x12\x17\n\x04\x61rgs\x18\x03 \x03(\tB\x03\xe0\x41\x01R\x04\x61rgs\x12\x33\n\x13max_timeout_seconds\x18\x04 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\x12U\n\x0foutput_handling\x18\x05 \x01(\x0e\x32\'.coreweave.sandbox.v1beta2.OutputPolicyB\x03\xe0\x41\x01R\x0eoutputHandling\x12-\n\x10\x62uffered_max_kib\x18\x06 \x01(\rB\x03\xe0\x41\x01R\x0e\x62ufferedMaxKib\"V\n\x13\x45xecSandboxResponse\x12?\n\x06result\x18\x01 \x01(\x0b\x32\'.coreweave.sandbox.v1beta2.ExecResponseR\x06result\"\xbb\x01\n\x15\x41\x64\x64\x46ileSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12(\n\rfile_contents\x18\x02 \x01(\x0c\x42\x03\xe0\x41\x01R\x0c\x66ileContents\x12\x1f\n\x08\x66ilepath\x18\x03 \x01(\tB\x03\xe0\x41\x02R\x08\x66ilepath\x12\x33\n\x13max_timeout_seconds\x18\x04 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"W\n\x16\x41\x64\x64\x46ileSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"\x96\x01\n\x1aRetrieveFileSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x1f\n\x08\x66ilepath\x18\x02 \x01(\tB\x03\xe0\x41\x02R\x08\x66ilepath\x12\x33\n\x13max_timeout_seconds\x18\x03 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"\x81\x01\n\x1bRetrieveFileSandboxResponse\x12#\n\rfile_contents\x18\x01 \x01(\x0cR\x0c\x66ileContents\x12\x18\n\x07success\x18\x02 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x03 \x01(\tR\x0c\x65rrorMessage\"n\n\x13PauseSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"U\n\x14PauseSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"o\n\x14ResumeSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12\x33\n\x13max_timeout_seconds\x18\x02 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSeconds\"V\n\x15ResumeSandboxResponse\x12\x18\n\x07success\x18\x01 \x01(\x08R\x07success\x12#\n\rerror_message\x18\x02 \x01(\tR\x0c\x65rrorMessage\"\xe3\x03\n\x11RawSandboxRequest\x12\"\n\nsandbox_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\tsandboxId\x12K\n\x0b\x61\x63tion_type\x18\x02 \x01(\x0e\x32%.coreweave.sandbox.v1beta2.ActionTypeB\x03\xe0\x41\x02R\nactionType\x12K\n\x0c\x65xec_payload\x18\x03 \x01(\x0b\x32&.coreweave.sandbox.v1beta2.ExecPayloadH\x00R\x0b\x65xecPayload\x12\\\n\x10\x61\x64\x64_file_payload\x18\x04 \x01(\x0b\x32\x30.coreweave.sandbox.v1beta2.AddFileSandboxRequestH\x00R\x0e\x61\x64\x64\x46ilePayload\x12k\n\x15retrieve_file_payload\x18\x05 \x01(\x0b\x32\x35.coreweave.sandbox.v1beta2.RetrieveFileSandboxRequestH\x00R\x13retrieveFilePayload\x12\x33\n\x13max_timeout_seconds\x18\x06 \x01(\x05\x42\x03\xe0\x41\x01R\x11maxTimeoutSecondsB\x10\n\x0e\x61\x63tion_payload\"\x97\x03\n\x12RawSandboxResponse\x12\x46\n\x0b\x61\x63tion_type\x18\x01 \x01(\x0e\x32%.coreweave.sandbox.v1beta2.ActionTypeR\nactionType\x12U\n\rexec_response\x18\x02 \x01(\x0b\x32..coreweave.sandbox.v1beta2.ExecSandboxResponseH\x00R\x0c\x65xecResponse\x12_\n\x11\x61\x64\x64_file_response\x18\x03 \x01(\x0b\x32\x31.coreweave.sandbox.v1beta2.AddFileSandboxResponseH\x00R\x0f\x61\x64\x64\x46ileResponse\x12n\n\x16retrieve_file_response\x18\x04 \x01(\x0b\x32\x36.coreweave.sandbox.v1beta2.RetrieveFileSandboxResponseH\x00R\x14retrieveFileResponseB\x11\n\x0f\x61\x63tion_response\"\x8f\x03\n\x16ObjectStorageWIFConfig\x12\x13\n\x02id\x18\x01 \x01(\tB\x03\xe0\x41\x03R\x02id\x12\'\n\rwif_config_id\x18\x02 \x01(\tB\x03\xe0\x41\x02R\x0bwifConfigId\x12\x1d\n\x07\x65nabled\x18\x03 \x01(\x08H\x00R\x07\x65nabled\x88\x01\x01\x12,\n\x0f\x61llowed_buckets\x18\x04 \x03(\tB\x03\xe0\x41\x01R\x0e\x61llowedBuckets\x12^\n\x0emax_permission\x18\x05 \x01(\x0e\x32\x32.coreweave.sandbox.v1beta2.ObjectStoragePermissionB\x03\xe0\x41\x02R\rmaxPermission\x12>\n\ncreated_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03R\tcreatedAt\x12>\n\nupdated_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x03\xe0\x41\x03R\tupdatedAtB\n\n\x08_enabled\"\"\n GetObjectStorageWIFConfigRequest\"\x84\x02\n SetObjectStorageWIFConfigRequest\x12\'\n\rwif_config_id\x18\x01 \x01(\tB\x03\xe0\x41\x02R\x0bwifConfigId\x12\x1d\n\x07\x65nabled\x18\x02 \x01(\x08H\x00R\x07\x65nabled\x88\x01\x01\x12,\n\x0f\x61llowed_buckets\x18\x03 \x03(\tB\x03\xe0\x41\x01R\x0e\x61llowedBuckets\x12^\n\x0emax_permission\x18\x04 \x01(\x0e\x32\x32.coreweave.sandbox.v1beta2.ObjectStoragePermissionB\x03\xe0\x41\x02R\rmaxPermissionB\n\n\x08_enabled\"%\n#DeleteObjectStorageWIFConfigRequest\"&\n$DeleteObjectStorageWIFConfigResponse\"\xee\x01\n\x1e\x46ileSystemSnapshotBucketConfig\x12$\n\x0b\x62ucket_name\x18\x01 \x01(\tB\x03\xe0\x41\x01R\nbucketName\x12\x1b\n\x06region\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x06region\x12P\n\x04mode\x18\x03 \x01(\x0e\x32\x37.coreweave.sandbox.v1beta2.FileSystemSnapshotBucketModeB\x03\xe0\x41\x03R\x04mode\x12\x37\n\x15\x65\x66\x66\x65\x63tive_bucket_name\x18\x04 \x01(\tB\x03\xe0\x41\x03R\x13\x65\x66\x66\x65\x63tiveBucketName\"*\n(GetFileSystemSnapshotBucketConfigRequest\"m\n(SetFileSystemSnapshotBucketConfigRequest\x12$\n\x0b\x62ucket_name\x18\x01 \x01(\tB\x03\xe0\x41\x01R\nbucketName\x12\x1b\n\x06region\x18\x02 \x01(\tB\x03\xe0\x41\x01R\x06region*\x84\x01\n\x0cOutputPolicy\x12\x1d\n\x19OUTPUT_POLICY_UNSPECIFIED\x10\x00\x12\x1a\n\x16OUTPUT_POLICY_BUFFERED\x10\x01\x12\x18\n\x14OUTPUT_POLICY_STREAM\x10\x02\x12\x19\n\x15OUTPUT_POLICY_DISCARD\x10\x03\"\x04\x08\x04\x10\x04*\x9b\x02\n\rSandboxStatus\x12\x1e\n\x1aSANDBOX_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n\x17SANDBOX_STATUS_CREATING\x10\x01\x12\x1a\n\x16SANDBOX_STATUS_RUNNING\x10\x02\x12\x1c\n\x18SANDBOX_STATUS_COMPLETED\x10\x03\x12\x19\n\x15SANDBOX_STATUS_FAILED\x10\x04\x12!\n\x19SANDBOX_STATUS_TERMINATED\x10\x05\x1a\x02\x08\x01\x12\x1a\n\x16SANDBOX_STATUS_PENDING\x10\x06\x12\x19\n\x15SANDBOX_STATUS_PAUSED\x10\x07\x12\x1e\n\x1aSANDBOX_STATUS_TERMINATING\x10\t*\xea\x01\n\x18\x46ileSystemSnapshotStatus\x12+\n\'FILE_SYSTEM_SNAPSHOT_STATUS_UNSPECIFIED\x10\x00\x12(\n$FILE_SYSTEM_SNAPSHOT_STATUS_CREATING\x10\x01\x12%\n!FILE_SYSTEM_SNAPSHOT_STATUS_READY\x10\x02\x12&\n\"FILE_SYSTEM_SNAPSHOT_STATUS_FAILED\x10\x03\x12(\n$FILE_SYSTEM_SNAPSHOT_STATUS_DELETING\x10\x04*\x99\x01\n\x19\x46ileSystemSnapshotTrigger\x12,\n(FILE_SYSTEM_SNAPSHOT_TRIGGER_UNSPECIFIED\x10\x00\x12%\n!FILE_SYSTEM_SNAPSHOT_TRIGGER_STOP\x10\x01\x12\'\n#FILE_SYSTEM_SNAPSHOT_TRIGGER_MANUAL\x10\x02*\xf3\x01\n\nActionType\x12\x1b\n\x17\x41\x43TION_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10\x41\x43TION_TYPE_EXEC\x10\x01\x12\x18\n\x14\x41\x43TION_TYPE_ADD_FILE\x10\x02\x12\x1d\n\x19\x41\x43TION_TYPE_RETRIEVE_FILE\x10\x03\x12\x18\n\x14\x41\x43TION_TYPE_GET_LOGS\x10\x04\x12\x18\n\x14\x41\x43TION_TYPE_SNAPSHOT\x10\x05\x12\x17\n\x13\x41\x43TION_TYPE_RESTORE\x10\x06\x12\x16\n\x12\x41\x43TION_TYPE_STATUS\x10\x07\x12\x14\n\x10\x41\x43TION_TYPE_STOP\x10\x08*\xb7\x01\n\nEgressType\x12\x1b\n\x17\x45GRESS_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10\x45GRESS_TYPE_NONE\x10\x01\x12\x18\n\x14\x45GRESS_TYPE_INTERNET\x10\x02\x12\x14\n\x10\x45GRESS_TYPE_USER\x10\x03\x12\x13\n\x0f\x45GRESS_TYPE_ORG\x10\x04\x12\x16\n\x12\x45GRESS_TYPE_RUNWAY\x10\x05\x12\x19\n\x15\x45GRESS_TYPE_ALLOWLIST\x10\x06*\x92\x01\n\x17ObjectStoragePermission\x12)\n%OBJECT_STORAGE_PERMISSION_UNSPECIFIED\x10\x00\x12\"\n\x1eOBJECT_STORAGE_PERMISSION_READ\x10\x01\x12(\n$OBJECT_STORAGE_PERMISSION_READ_WRITE\x10\x02*\xb6\x01\n\x1c\x46ileSystemSnapshotBucketMode\x12\x30\n,FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_UNSPECIFIED\x10\x00\x12/\n+FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_CW_MANAGED\x10\x01\x12\x33\n/FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_BRING_YOUR_OWN\x10\x02\x32\xd8\x1b\n\x0eGatewayService\x12\x87\x01\n\x05Start\x12..coreweave.sandbox.v1beta2.StartSandboxRequest\x1a/.coreweave.sandbox.v1beta2.StartSandboxResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\"\x12/v1beta2/sandboxes:\x01*\x12\x96\x01\n\x04Stop\x12-.coreweave.sandbox.v1beta2.StopSandboxRequest\x1a..coreweave.sandbox.v1beta2.StopSandboxResponse\"/\x82\xd3\xe4\x93\x02)\"$/v1beta2/sandboxes/{sandbox_id}/stop:\x01*\x12\xd5\x01\n\x18\x43reateFileSystemSnapshot\x12:.coreweave.sandbox.v1beta2.CreateFileSystemSnapshotRequest\x1a;.coreweave.sandbox.v1beta2.CreateFileSystemSnapshotResponse\"@\x82\xd3\xe4\x93\x02:\"5/v1beta2/sandboxes/{sandbox_id}/file-system-snapshots:\x01*\x12\xc1\x01\n\x15GetFileSystemSnapshot\x12\x37.coreweave.sandbox.v1beta2.GetFileSystemSnapshotRequest\x1a-.coreweave.sandbox.v1beta2.FileSystemSnapshot\"@\x82\xd3\xe4\x93\x02:\x12\x38/v1beta2/file-system-snapshots/{file_system_snapshot_id}\x12\xb8\x01\n\x17ListFileSystemSnapshots\x12\x39.coreweave.sandbox.v1beta2.ListFileSystemSnapshotsRequest\x1a:.coreweave.sandbox.v1beta2.ListFileSystemSnapshotsResponse\"&\x82\xd3\xe4\x93\x02 \x12\x1e/v1beta2/file-system-snapshots\x12\xd5\x01\n\x18\x44\x65leteFileSystemSnapshot\x12:.coreweave.sandbox.v1beta2.DeleteFileSystemSnapshotRequest\x1a;.coreweave.sandbox.v1beta2.DeleteFileSystemSnapshotResponse\"@\x82\xd3\xe4\x93\x02:*8/v1beta2/file-system-snapshots/{file_system_snapshot_id}\x12\x8b\x01\n\x03Get\x12,.coreweave.sandbox.v1beta2.GetSandboxRequest\x1a-.coreweave.sandbox.v1beta2.GetSandboxResponse\"\'\x82\xd3\xe4\x93\x02!\x12\x1f/v1beta2/sandboxes/{sandbox_id}\x12\x85\x01\n\x04List\x12/.coreweave.sandbox.v1beta2.ListSandboxesRequest\x1a\x30.coreweave.sandbox.v1beta2.ListSandboxesResponse\"\x1a\x82\xd3\xe4\x93\x02\x14\x12\x12/v1beta2/sandboxes\x12\x94\x01\n\x06\x44\x65lete\x12/.coreweave.sandbox.v1beta2.DeleteSandboxRequest\x1a\x30.coreweave.sandbox.v1beta2.DeleteSandboxResponse\"\'\x82\xd3\xe4\x93\x02!*\x1f/v1beta2/sandboxes/{sandbox_id}\x12\x96\x01\n\x04\x45xec\x12-.coreweave.sandbox.v1beta2.ExecSandboxRequest\x1a..coreweave.sandbox.v1beta2.ExecSandboxResponse\"/\x82\xd3\xe4\x93\x02)\"$/v1beta2/sandboxes/{sandbox_id}/exec:\x01*\x12\xa0\x01\n\x07\x41\x64\x64\x46ile\x12\x30.coreweave.sandbox.v1beta2.AddFileSandboxRequest\x1a\x31.coreweave.sandbox.v1beta2.AddFileSandboxResponse\"0\x82\xd3\xe4\x93\x02*\"%/v1beta2/sandboxes/{sandbox_id}/files:\x01*\x12\xb7\x01\n\x0cRetrieveFile\x12\x35.coreweave.sandbox.v1beta2.RetrieveFileSandboxRequest\x1a\x36.coreweave.sandbox.v1beta2.RetrieveFileSandboxResponse\"8\x82\xd3\xe4\x93\x02\x32\x12\x30/v1beta2/sandboxes/{sandbox_id}/files/{filepath}\x12\x9a\x01\n\x05Pause\x12..coreweave.sandbox.v1beta2.PauseSandboxRequest\x1a/.coreweave.sandbox.v1beta2.PauseSandboxResponse\"0\x82\xd3\xe4\x93\x02*\"%/v1beta2/sandboxes/{sandbox_id}/pause:\x01*\x12\x9e\x01\n\x06Resume\x12/.coreweave.sandbox.v1beta2.ResumeSandboxRequest\x1a\x30.coreweave.sandbox.v1beta2.ResumeSandboxResponse\"1\x82\xd3\xe4\x93\x02+\"&/v1beta2/sandboxes/{sandbox_id}/resume:\x01*\x12\x92\x01\n\x03Raw\x12,.coreweave.sandbox.v1beta2.RawSandboxRequest\x1a-.coreweave.sandbox.v1beta2.RawSandboxResponse\".\x82\xd3\xe4\x93\x02(\"#/v1beta2/sandboxes/{sandbox_id}/raw:\x01*\x12\xb7\x01\n\x19GetObjectStorageWIFConfig\x12;.coreweave.sandbox.v1beta2.GetObjectStorageWIFConfigRequest\x1a\x31.coreweave.sandbox.v1beta2.ObjectStorageWIFConfig\"*\x82\xd3\xe4\x93\x02$\x12\"/v1beta2/object-storage/wif-config\x12\xba\x01\n\x19SetObjectStorageWIFConfig\x12;.coreweave.sandbox.v1beta2.SetObjectStorageWIFConfigRequest\x1a\x31.coreweave.sandbox.v1beta2.ObjectStorageWIFConfig\"-\x82\xd3\xe4\x93\x02\'\x1a\"/v1beta2/object-storage/wif-config:\x01*\x12\xcb\x01\n\x1c\x44\x65leteObjectStorageWIFConfig\x12>.coreweave.sandbox.v1beta2.DeleteObjectStorageWIFConfigRequest\x1a?.coreweave.sandbox.v1beta2.DeleteObjectStorageWIFConfigResponse\"*\x82\xd3\xe4\x93\x02$*\"/v1beta2/object-storage/wif-config\x12\xd9\x01\n!GetFileSystemSnapshotBucketConfig\x12\x43.coreweave.sandbox.v1beta2.GetFileSystemSnapshotBucketConfigRequest\x1a\x39.coreweave.sandbox.v1beta2.FileSystemSnapshotBucketConfig\"4\x82\xd3\xe4\x93\x02.\x12,/v1beta2/file-system-snapshots/bucket-config\x12\xdc\x01\n!SetFileSystemSnapshotBucketConfig\x12\x43.coreweave.sandbox.v1beta2.SetFileSystemSnapshotBucketConfigRequest\x1a\x39.coreweave.sandbox.v1beta2.FileSystemSnapshotBucketConfig\"7\x82\xd3\xe4\x93\x02\x31\x1a,/v1beta2/file-system-snapshots/bucket-config:\x01*BKZIgithub.com/coreweave/sandbox/api/coreweave/sandbox/v1beta2;sandboxv1beta2b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -67,6 +67,12 @@ _globals['_S3MOUNT'].fields_by_name['directory']._serialized_options = b'\340A\002' _globals['_S3MOUNT'].fields_by_name['mount_path']._loaded_options = None _globals['_S3MOUNT'].fields_by_name['mount_path']._serialized_options = b'\340A\002' + _globals['_FILESYSTEMSNAPSHOTSOURCE'].fields_by_name['file_system_snapshot_id']._loaded_options = None + _globals['_FILESYSTEMSNAPSHOTSOURCE'].fields_by_name['file_system_snapshot_id']._serialized_options = b'\340A\001' + _globals['_SANDBOXFILESYSTEMMOUNT'].fields_by_name['mount_path']._loaded_options = None + _globals['_SANDBOXFILESYSTEMMOUNT'].fields_by_name['mount_path']._serialized_options = b'\340A\002' + _globals['_SANDBOXFILESYSTEMMOUNT'].fields_by_name['size']._loaded_options = None + _globals['_SANDBOXFILESYSTEMMOUNT'].fields_by_name['size']._serialized_options = b'\340A\001' _globals['_EXECPAYLOAD'].fields_by_name['command']._loaded_options = None _globals['_EXECPAYLOAD'].fields_by_name['command']._serialized_options = b'\340A\002' _globals['_EXECPAYLOAD'].fields_by_name['args']._loaded_options = None @@ -95,6 +101,8 @@ _globals['_STARTSANDBOXREQUEST'].fields_by_name['s3_mount']._serialized_options = b'\340A\001' _globals['_STARTSANDBOXREQUEST'].fields_by_name['network']._loaded_options = None _globals['_STARTSANDBOXREQUEST'].fields_by_name['network']._serialized_options = b'\340A\001' + _globals['_STARTSANDBOXREQUEST'].fields_by_name['file_system']._loaded_options = None + _globals['_STARTSANDBOXREQUEST'].fields_by_name['file_system']._serialized_options = b'\340A\001' _globals['_STARTSANDBOXREQUEST'].fields_by_name['profile_ids']._loaded_options = None _globals['_STARTSANDBOXREQUEST'].fields_by_name['profile_ids']._serialized_options = b'\340A\001' _globals['_STARTSANDBOXREQUEST'].fields_by_name['runner_ids']._loaded_options = None @@ -121,10 +129,38 @@ _globals['_STOPSANDBOXREQUEST'].fields_by_name['sandbox_id']._serialized_options = b'\340A\002' _globals['_STOPSANDBOXREQUEST'].fields_by_name['graceful_shutdown_seconds']._loaded_options = None _globals['_STOPSANDBOXREQUEST'].fields_by_name['graceful_shutdown_seconds']._serialized_options = b'\340A\001' - _globals['_STOPSANDBOXREQUEST'].fields_by_name['snapshot_on_stop']._loaded_options = None - _globals['_STOPSANDBOXREQUEST'].fields_by_name['snapshot_on_stop']._serialized_options = b'\340A\001' + _globals['_STOPSANDBOXREQUEST'].fields_by_name['file_system_snapshot_on_stop']._loaded_options = None + _globals['_STOPSANDBOXREQUEST'].fields_by_name['file_system_snapshot_on_stop']._serialized_options = b'\340A\001' _globals['_STOPSANDBOXREQUEST'].fields_by_name['max_timeout_seconds']._loaded_options = None _globals['_STOPSANDBOXREQUEST'].fields_by_name['max_timeout_seconds']._serialized_options = b'\340A\001' + _globals['_STOPSANDBOXREQUEST'].fields_by_name['idempotency_key']._loaded_options = None + _globals['_STOPSANDBOXREQUEST'].fields_by_name['idempotency_key']._serialized_options = b'\340A\001' + _globals['_STOPSANDBOXREQUEST'].fields_by_name['wait_for_ready']._loaded_options = None + _globals['_STOPSANDBOXREQUEST'].fields_by_name['wait_for_ready']._serialized_options = b'\340A\001' + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['sandbox_id']._loaded_options = None + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['sandbox_id']._serialized_options = b'\340A\002' + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['idempotency_key']._loaded_options = None + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['idempotency_key']._serialized_options = b'\340A\001' + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['wait_for_ready']._loaded_options = None + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['wait_for_ready']._serialized_options = b'\340A\001' + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['max_timeout_seconds']._loaded_options = None + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['max_timeout_seconds']._serialized_options = b'\340A\001' + _globals['_FILESYSTEMSNAPSHOT'].fields_by_name['object_bucket']._loaded_options = None + _globals['_FILESYSTEMSNAPSHOT'].fields_by_name['object_bucket']._serialized_options = b'\340A\003' + _globals['_GETFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['file_system_snapshot_id']._loaded_options = None + _globals['_GETFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['file_system_snapshot_id']._serialized_options = b'\340A\002' + _globals['_GETFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['max_timeout_seconds']._loaded_options = None + _globals['_GETFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['max_timeout_seconds']._serialized_options = b'\340A\001' + _globals['_LISTFILESYSTEMSNAPSHOTSREQUEST'].fields_by_name['page_size']._loaded_options = None + _globals['_LISTFILESYSTEMSNAPSHOTSREQUEST'].fields_by_name['page_size']._serialized_options = b'\340A\001' + _globals['_LISTFILESYSTEMSNAPSHOTSREQUEST'].fields_by_name['page_token']._loaded_options = None + _globals['_LISTFILESYSTEMSNAPSHOTSREQUEST'].fields_by_name['page_token']._serialized_options = b'\340A\001' + _globals['_LISTFILESYSTEMSNAPSHOTSREQUEST'].fields_by_name['max_timeout_seconds']._loaded_options = None + _globals['_LISTFILESYSTEMSNAPSHOTSREQUEST'].fields_by_name['max_timeout_seconds']._serialized_options = b'\340A\001' + _globals['_DELETEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['file_system_snapshot_id']._loaded_options = None + _globals['_DELETEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['file_system_snapshot_id']._serialized_options = b'\340A\002' + _globals['_DELETEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['max_timeout_seconds']._loaded_options = None + _globals['_DELETEFILESYSTEMSNAPSHOTREQUEST'].fields_by_name['max_timeout_seconds']._serialized_options = b'\340A\001' _globals['_GETSANDBOXREQUEST'].fields_by_name['sandbox_id']._loaded_options = None _globals['_GETSANDBOXREQUEST'].fields_by_name['sandbox_id']._serialized_options = b'\340A\002' _globals['_GETSANDBOXREQUEST'].fields_by_name['max_timeout_seconds']._loaded_options = None @@ -159,10 +195,14 @@ _globals['_EXECSANDBOXREQUEST'].fields_by_name['args']._serialized_options = b'\340A\001' _globals['_EXECSANDBOXREQUEST'].fields_by_name['max_timeout_seconds']._loaded_options = None _globals['_EXECSANDBOXREQUEST'].fields_by_name['max_timeout_seconds']._serialized_options = b'\340A\001' + _globals['_EXECSANDBOXREQUEST'].fields_by_name['output_handling']._loaded_options = None + _globals['_EXECSANDBOXREQUEST'].fields_by_name['output_handling']._serialized_options = b'\340A\001' + _globals['_EXECSANDBOXREQUEST'].fields_by_name['buffered_max_kib']._loaded_options = None + _globals['_EXECSANDBOXREQUEST'].fields_by_name['buffered_max_kib']._serialized_options = b'\340A\001' _globals['_ADDFILESANDBOXREQUEST'].fields_by_name['sandbox_id']._loaded_options = None _globals['_ADDFILESANDBOXREQUEST'].fields_by_name['sandbox_id']._serialized_options = b'\340A\002' _globals['_ADDFILESANDBOXREQUEST'].fields_by_name['file_contents']._loaded_options = None - _globals['_ADDFILESANDBOXREQUEST'].fields_by_name['file_contents']._serialized_options = b'\340A\002' + _globals['_ADDFILESANDBOXREQUEST'].fields_by_name['file_contents']._serialized_options = b'\340A\001' _globals['_ADDFILESANDBOXREQUEST'].fields_by_name['filepath']._loaded_options = None _globals['_ADDFILESANDBOXREQUEST'].fields_by_name['filepath']._serialized_options = b'\340A\002' _globals['_ADDFILESANDBOXREQUEST'].fields_by_name['max_timeout_seconds']._loaded_options = None @@ -205,10 +245,30 @@ _globals['_SETOBJECTSTORAGEWIFCONFIGREQUEST'].fields_by_name['allowed_buckets']._serialized_options = b'\340A\001' _globals['_SETOBJECTSTORAGEWIFCONFIGREQUEST'].fields_by_name['max_permission']._loaded_options = None _globals['_SETOBJECTSTORAGEWIFCONFIGREQUEST'].fields_by_name['max_permission']._serialized_options = b'\340A\002' + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG'].fields_by_name['bucket_name']._loaded_options = None + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG'].fields_by_name['bucket_name']._serialized_options = b'\340A\001' + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG'].fields_by_name['region']._loaded_options = None + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG'].fields_by_name['region']._serialized_options = b'\340A\001' + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG'].fields_by_name['mode']._loaded_options = None + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG'].fields_by_name['mode']._serialized_options = b'\340A\003' + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG'].fields_by_name['effective_bucket_name']._loaded_options = None + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG'].fields_by_name['effective_bucket_name']._serialized_options = b'\340A\003' + _globals['_SETFILESYSTEMSNAPSHOTBUCKETCONFIGREQUEST'].fields_by_name['bucket_name']._loaded_options = None + _globals['_SETFILESYSTEMSNAPSHOTBUCKETCONFIGREQUEST'].fields_by_name['bucket_name']._serialized_options = b'\340A\001' + _globals['_SETFILESYSTEMSNAPSHOTBUCKETCONFIGREQUEST'].fields_by_name['region']._loaded_options = None + _globals['_SETFILESYSTEMSNAPSHOTBUCKETCONFIGREQUEST'].fields_by_name['region']._serialized_options = b'\340A\001' _globals['_GATEWAYSERVICE'].methods_by_name['Start']._loaded_options = None _globals['_GATEWAYSERVICE'].methods_by_name['Start']._serialized_options = b'\202\323\344\223\002\027\"\022/v1beta2/sandboxes:\001*' _globals['_GATEWAYSERVICE'].methods_by_name['Stop']._loaded_options = None _globals['_GATEWAYSERVICE'].methods_by_name['Stop']._serialized_options = b'\202\323\344\223\002)\"$/v1beta2/sandboxes/{sandbox_id}/stop:\001*' + _globals['_GATEWAYSERVICE'].methods_by_name['CreateFileSystemSnapshot']._loaded_options = None + _globals['_GATEWAYSERVICE'].methods_by_name['CreateFileSystemSnapshot']._serialized_options = b'\202\323\344\223\002:\"5/v1beta2/sandboxes/{sandbox_id}/file-system-snapshots:\001*' + _globals['_GATEWAYSERVICE'].methods_by_name['GetFileSystemSnapshot']._loaded_options = None + _globals['_GATEWAYSERVICE'].methods_by_name['GetFileSystemSnapshot']._serialized_options = b'\202\323\344\223\002:\0228/v1beta2/file-system-snapshots/{file_system_snapshot_id}' + _globals['_GATEWAYSERVICE'].methods_by_name['ListFileSystemSnapshots']._loaded_options = None + _globals['_GATEWAYSERVICE'].methods_by_name['ListFileSystemSnapshots']._serialized_options = b'\202\323\344\223\002 \022\036/v1beta2/file-system-snapshots' + _globals['_GATEWAYSERVICE'].methods_by_name['DeleteFileSystemSnapshot']._loaded_options = None + _globals['_GATEWAYSERVICE'].methods_by_name['DeleteFileSystemSnapshot']._serialized_options = b'\202\323\344\223\002:*8/v1beta2/file-system-snapshots/{file_system_snapshot_id}' _globals['_GATEWAYSERVICE'].methods_by_name['Get']._loaded_options = None _globals['_GATEWAYSERVICE'].methods_by_name['Get']._serialized_options = b'\202\323\344\223\002!\022\037/v1beta2/sandboxes/{sandbox_id}' _globals['_GATEWAYSERVICE'].methods_by_name['List']._loaded_options = None @@ -233,14 +293,26 @@ _globals['_GATEWAYSERVICE'].methods_by_name['SetObjectStorageWIFConfig']._serialized_options = b'\202\323\344\223\002\'\032\"/v1beta2/object-storage/wif-config:\001*' _globals['_GATEWAYSERVICE'].methods_by_name['DeleteObjectStorageWIFConfig']._loaded_options = None _globals['_GATEWAYSERVICE'].methods_by_name['DeleteObjectStorageWIFConfig']._serialized_options = b'\202\323\344\223\002$*\"/v1beta2/object-storage/wif-config' - _globals['_SANDBOXSTATUS']._serialized_start=9342 - _globals['_SANDBOXSTATUS']._serialized_end=9625 - _globals['_ACTIONTYPE']._serialized_start=9628 - _globals['_ACTIONTYPE']._serialized_end=9871 - _globals['_EGRESSTYPE']._serialized_start=9874 - _globals['_EGRESSTYPE']._serialized_end=10057 - _globals['_OBJECTSTORAGEPERMISSION']._serialized_start=10060 - _globals['_OBJECTSTORAGEPERMISSION']._serialized_end=10206 + _globals['_GATEWAYSERVICE'].methods_by_name['GetFileSystemSnapshotBucketConfig']._loaded_options = None + _globals['_GATEWAYSERVICE'].methods_by_name['GetFileSystemSnapshotBucketConfig']._serialized_options = b'\202\323\344\223\002.\022,/v1beta2/file-system-snapshots/bucket-config' + _globals['_GATEWAYSERVICE'].methods_by_name['SetFileSystemSnapshotBucketConfig']._loaded_options = None + _globals['_GATEWAYSERVICE'].methods_by_name['SetFileSystemSnapshotBucketConfig']._serialized_options = b'\202\323\344\223\0021\032,/v1beta2/file-system-snapshots/bucket-config:\001*' + _globals['_OUTPUTPOLICY']._serialized_start=12455 + _globals['_OUTPUTPOLICY']._serialized_end=12587 + _globals['_SANDBOXSTATUS']._serialized_start=12590 + _globals['_SANDBOXSTATUS']._serialized_end=12873 + _globals['_FILESYSTEMSNAPSHOTSTATUS']._serialized_start=12876 + _globals['_FILESYSTEMSNAPSHOTSTATUS']._serialized_end=13110 + _globals['_FILESYSTEMSNAPSHOTTRIGGER']._serialized_start=13113 + _globals['_FILESYSTEMSNAPSHOTTRIGGER']._serialized_end=13266 + _globals['_ACTIONTYPE']._serialized_start=13269 + _globals['_ACTIONTYPE']._serialized_end=13512 + _globals['_EGRESSTYPE']._serialized_start=13515 + _globals['_EGRESSTYPE']._serialized_end=13698 + _globals['_OBJECTSTORAGEPERMISSION']._serialized_start=13701 + _globals['_OBJECTSTORAGEPERMISSION']._serialized_end=13847 + _globals['_FILESYSTEMSNAPSHOTBUCKETMODE']._serialized_start=13850 + _globals['_FILESYSTEMSNAPSHOTBUCKETMODE']._serialized_end=14032 _globals['_MOUNTEDFILE']._serialized_start=207 _globals['_MOUNTEDFILE']._serialized_end=296 _globals['_GPUREQUEST']._serialized_start=298 @@ -259,74 +331,100 @@ _globals['_RUNNERCLUSTERSECRETREFERENCE']._serialized_end=1120 _globals['_S3MOUNT']._serialized_start=1122 _globals['_S3MOUNT']._serialized_end=1231 - _globals['_EXECPAYLOAD']._serialized_start=1233 - _globals['_EXECPAYLOAD']._serialized_end=1302 - _globals['_EXECRESPONSE']._serialized_start=1304 - _globals['_EXECRESPONSE']._serialized_end=1395 - _globals['_RESOURCEUSAGE']._serialized_start=1398 - _globals['_RESOURCEUSAGE']._serialized_end=1537 - _globals['_OBJECTSTORAGEACCESS']._serialized_start=1540 - _globals['_OBJECTSTORAGEACCESS']._serialized_end=1671 - _globals['_STARTSANDBOXREQUEST']._serialized_start=1674 - _globals['_STARTSANDBOXREQUEST']._serialized_end=3292 - _globals['_STARTSANDBOXREQUEST_ENVIRONMENTVARIABLESENTRY']._serialized_start=3154 - _globals['_STARTSANDBOXREQUEST_ENVIRONMENTVARIABLESENTRY']._serialized_end=3225 - _globals['_STARTSANDBOXREQUEST_PODANNOTATIONSENTRY']._serialized_start=3227 - _globals['_STARTSANDBOXREQUEST_PODANNOTATIONSENTRY']._serialized_end=3292 - _globals['_STARTSANDBOXRESPONSE']._serialized_start=3295 - _globals['_STARTSANDBOXRESPONSE']._serialized_end=4071 - _globals['_STOPSANDBOXREQUEST']._serialized_start=4074 - _globals['_STOPSANDBOXREQUEST']._serialized_end=4295 - _globals['_STOPSANDBOXRESPONSE']._serialized_start=4297 - _globals['_STOPSANDBOXRESPONSE']._serialized_end=4381 - _globals['_GETSANDBOXREQUEST']._serialized_start=4383 - _globals['_GETSANDBOXREQUEST']._serialized_end=4491 - _globals['_GETSANDBOXRESPONSE']._serialized_start=4494 - _globals['_GETSANDBOXRESPONSE']._serialized_end=5099 - _globals['_LISTSANDBOXESREQUEST']._serialized_start=5102 - _globals['_LISTSANDBOXESREQUEST']._serialized_end=5505 - _globals['_LISTSANDBOXESRESPONSE']._serialized_start=5508 - _globals['_LISTSANDBOXESRESPONSE']._serialized_end=5641 - _globals['_SANDBOXINFO']._serialized_start=5644 - _globals['_SANDBOXINFO']._serialized_end=6242 - _globals['_DELETESANDBOXREQUEST']._serialized_start=6244 - _globals['_DELETESANDBOXREQUEST']._serialized_end=6355 - _globals['_DELETESANDBOXRESPONSE']._serialized_start=6357 - _globals['_DELETESANDBOXRESPONSE']._serialized_end=6443 - _globals['_EXECSANDBOXREQUEST']._serialized_start=6446 - _globals['_EXECSANDBOXREQUEST']._serialized_end=6611 - _globals['_EXECSANDBOXRESPONSE']._serialized_start=6613 - _globals['_EXECSANDBOXRESPONSE']._serialized_end=6699 - _globals['_ADDFILESANDBOXREQUEST']._serialized_start=6702 - _globals['_ADDFILESANDBOXREQUEST']._serialized_end=6889 - _globals['_ADDFILESANDBOXRESPONSE']._serialized_start=6891 - _globals['_ADDFILESANDBOXRESPONSE']._serialized_end=6978 - _globals['_RETRIEVEFILESANDBOXREQUEST']._serialized_start=6981 - _globals['_RETRIEVEFILESANDBOXREQUEST']._serialized_end=7131 - _globals['_RETRIEVEFILESANDBOXRESPONSE']._serialized_start=7134 - _globals['_RETRIEVEFILESANDBOXRESPONSE']._serialized_end=7263 - _globals['_PAUSESANDBOXREQUEST']._serialized_start=7265 - _globals['_PAUSESANDBOXREQUEST']._serialized_end=7375 - _globals['_PAUSESANDBOXRESPONSE']._serialized_start=7377 - _globals['_PAUSESANDBOXRESPONSE']._serialized_end=7462 - _globals['_RESUMESANDBOXREQUEST']._serialized_start=7464 - _globals['_RESUMESANDBOXREQUEST']._serialized_end=7575 - _globals['_RESUMESANDBOXRESPONSE']._serialized_start=7577 - _globals['_RESUMESANDBOXRESPONSE']._serialized_end=7663 - _globals['_RAWSANDBOXREQUEST']._serialized_start=7666 - _globals['_RAWSANDBOXREQUEST']._serialized_end=8149 - _globals['_RAWSANDBOXRESPONSE']._serialized_start=8152 - _globals['_RAWSANDBOXRESPONSE']._serialized_end=8559 - _globals['_OBJECTSTORAGEWIFCONFIG']._serialized_start=8562 - _globals['_OBJECTSTORAGEWIFCONFIG']._serialized_end=8961 - _globals['_GETOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_start=8963 - _globals['_GETOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_end=8997 - _globals['_SETOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_start=9000 - _globals['_SETOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_end=9260 - _globals['_DELETEOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_start=9262 - _globals['_DELETEOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_end=9299 - _globals['_DELETEOBJECTSTORAGEWIFCONFIGRESPONSE']._serialized_start=9301 - _globals['_DELETEOBJECTSTORAGEWIFCONFIGRESPONSE']._serialized_end=9339 - _globals['_GATEWAYSERVICE']._serialized_start=10209 - _globals['_GATEWAYSERVICE']._serialized_end=12495 + _globals['_FILESYSTEMSNAPSHOTSOURCE']._serialized_start=1233 + _globals['_FILESYSTEMSNAPSHOTSOURCE']._serialized_end=1319 + _globals['_SANDBOXFILESYSTEMMOUNT']._serialized_start=1322 + _globals['_SANDBOXFILESYSTEMMOUNT']._serialized_end=1546 + _globals['_EXECPAYLOAD']._serialized_start=1548 + _globals['_EXECPAYLOAD']._serialized_end=1617 + _globals['_EXECRESPONSE']._serialized_start=1620 + _globals['_EXECRESPONSE']._serialized_end=1901 + _globals['_RESOURCEUSAGE']._serialized_start=1904 + _globals['_RESOURCEUSAGE']._serialized_end=2043 + _globals['_OBJECTSTORAGEACCESS']._serialized_start=2046 + _globals['_OBJECTSTORAGEACCESS']._serialized_end=2177 + _globals['_STARTSANDBOXREQUEST']._serialized_start=2180 + _globals['_STARTSANDBOXREQUEST']._serialized_end=3887 + _globals['_STARTSANDBOXREQUEST_ENVIRONMENTVARIABLESENTRY']._serialized_start=3749 + _globals['_STARTSANDBOXREQUEST_ENVIRONMENTVARIABLESENTRY']._serialized_end=3820 + _globals['_STARTSANDBOXREQUEST_PODANNOTATIONSENTRY']._serialized_start=3822 + _globals['_STARTSANDBOXREQUEST_PODANNOTATIONSENTRY']._serialized_end=3887 + _globals['_STARTSANDBOXRESPONSE']._serialized_start=3890 + _globals['_STARTSANDBOXRESPONSE']._serialized_end=4666 + _globals['_STOPSANDBOXREQUEST']._serialized_start=4669 + _globals['_STOPSANDBOXREQUEST']._serialized_end=5025 + _globals['_STOPSANDBOXRESPONSE']._serialized_start=5028 + _globals['_STOPSANDBOXRESPONSE']._serialized_end=5167 + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST']._serialized_start=5170 + _globals['_CREATEFILESYSTEMSNAPSHOTREQUEST']._serialized_end=5434 + _globals['_CREATEFILESYSTEMSNAPSHOTRESPONSE']._serialized_start=5437 + _globals['_CREATEFILESYSTEMSNAPSHOTRESPONSE']._serialized_end=5589 + _globals['_FILESYSTEMSNAPSHOT']._serialized_start=5592 + _globals['_FILESYSTEMSNAPSHOT']._serialized_end=6200 + _globals['_GETFILESYSTEMSNAPSHOTREQUEST']._serialized_start=6203 + _globals['_GETFILESYSTEMSNAPSHOTREQUEST']._serialized_end=6346 + _globals['_LISTFILESYSTEMSNAPSHOTSREQUEST']._serialized_start=6349 + _globals['_LISTFILESYSTEMSNAPSHOTSREQUEST']._serialized_end=6504 + _globals['_LISTFILESYSTEMSNAPSHOTSRESPONSE']._serialized_start=6507 + _globals['_LISTFILESYSTEMSNAPSHOTSRESPONSE']._serialized_end=6679 + _globals['_DELETEFILESYSTEMSNAPSHOTREQUEST']._serialized_start=6682 + _globals['_DELETEFILESYSTEMSNAPSHOTREQUEST']._serialized_end=6828 + _globals['_DELETEFILESYSTEMSNAPSHOTRESPONSE']._serialized_start=6830 + _globals['_DELETEFILESYSTEMSNAPSHOTRESPONSE']._serialized_end=6927 + _globals['_GETSANDBOXREQUEST']._serialized_start=6929 + _globals['_GETSANDBOXREQUEST']._serialized_end=7037 + _globals['_GETSANDBOXRESPONSE']._serialized_start=7040 + _globals['_GETSANDBOXRESPONSE']._serialized_end=7682 + _globals['_LISTSANDBOXESREQUEST']._serialized_start=7685 + _globals['_LISTSANDBOXESREQUEST']._serialized_end=8088 + _globals['_LISTSANDBOXESRESPONSE']._serialized_start=8091 + _globals['_LISTSANDBOXESRESPONSE']._serialized_end=8224 + _globals['_SANDBOXINFO']._serialized_start=8227 + _globals['_SANDBOXINFO']._serialized_end=8825 + _globals['_DELETESANDBOXREQUEST']._serialized_start=8827 + _globals['_DELETESANDBOXREQUEST']._serialized_end=8938 + _globals['_DELETESANDBOXRESPONSE']._serialized_start=8940 + _globals['_DELETESANDBOXRESPONSE']._serialized_end=9026 + _globals['_EXECSANDBOXREQUEST']._serialized_start=9029 + _globals['_EXECSANDBOXREQUEST']._serialized_end=9328 + _globals['_EXECSANDBOXRESPONSE']._serialized_start=9330 + _globals['_EXECSANDBOXRESPONSE']._serialized_end=9416 + _globals['_ADDFILESANDBOXREQUEST']._serialized_start=9419 + _globals['_ADDFILESANDBOXREQUEST']._serialized_end=9606 + _globals['_ADDFILESANDBOXRESPONSE']._serialized_start=9608 + _globals['_ADDFILESANDBOXRESPONSE']._serialized_end=9695 + _globals['_RETRIEVEFILESANDBOXREQUEST']._serialized_start=9698 + _globals['_RETRIEVEFILESANDBOXREQUEST']._serialized_end=9848 + _globals['_RETRIEVEFILESANDBOXRESPONSE']._serialized_start=9851 + _globals['_RETRIEVEFILESANDBOXRESPONSE']._serialized_end=9980 + _globals['_PAUSESANDBOXREQUEST']._serialized_start=9982 + _globals['_PAUSESANDBOXREQUEST']._serialized_end=10092 + _globals['_PAUSESANDBOXRESPONSE']._serialized_start=10094 + _globals['_PAUSESANDBOXRESPONSE']._serialized_end=10179 + _globals['_RESUMESANDBOXREQUEST']._serialized_start=10181 + _globals['_RESUMESANDBOXREQUEST']._serialized_end=10292 + _globals['_RESUMESANDBOXRESPONSE']._serialized_start=10294 + _globals['_RESUMESANDBOXRESPONSE']._serialized_end=10380 + _globals['_RAWSANDBOXREQUEST']._serialized_start=10383 + _globals['_RAWSANDBOXREQUEST']._serialized_end=10866 + _globals['_RAWSANDBOXRESPONSE']._serialized_start=10869 + _globals['_RAWSANDBOXRESPONSE']._serialized_end=11276 + _globals['_OBJECTSTORAGEWIFCONFIG']._serialized_start=11279 + _globals['_OBJECTSTORAGEWIFCONFIG']._serialized_end=11678 + _globals['_GETOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_start=11680 + _globals['_GETOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_end=11714 + _globals['_SETOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_start=11717 + _globals['_SETOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_end=11977 + _globals['_DELETEOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_start=11979 + _globals['_DELETEOBJECTSTORAGEWIFCONFIGREQUEST']._serialized_end=12016 + _globals['_DELETEOBJECTSTORAGEWIFCONFIGRESPONSE']._serialized_start=12018 + _globals['_DELETEOBJECTSTORAGEWIFCONFIGRESPONSE']._serialized_end=12056 + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG']._serialized_start=12059 + _globals['_FILESYSTEMSNAPSHOTBUCKETCONFIG']._serialized_end=12297 + _globals['_GETFILESYSTEMSNAPSHOTBUCKETCONFIGREQUEST']._serialized_start=12299 + _globals['_GETFILESYSTEMSNAPSHOTBUCKETCONFIGREQUEST']._serialized_end=12341 + _globals['_SETFILESYSTEMSNAPSHOTBUCKETCONFIGREQUEST']._serialized_start=12343 + _globals['_SETFILESYSTEMSNAPSHOTBUCKETCONFIGREQUEST']._serialized_end=12452 + _globals['_GATEWAYSERVICE']._serialized_start=14035 + _globals['_GATEWAYSERVICE']._serialized_end=17579 # @@protoc_insertion_point(module_scope) diff --git a/src/cwsandbox/_proto/gateway_pb2.pyi b/src/cwsandbox/_proto/gateway_pb2.pyi index d9483da..2d8dfdc 100644 --- a/src/cwsandbox/_proto/gateway_pb2.pyi +++ b/src/cwsandbox/_proto/gateway_pb2.pyi @@ -13,6 +13,13 @@ from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Map DESCRIPTOR: _descriptor.FileDescriptor +class OutputPolicy(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + OUTPUT_POLICY_UNSPECIFIED: _ClassVar[OutputPolicy] + OUTPUT_POLICY_BUFFERED: _ClassVar[OutputPolicy] + OUTPUT_POLICY_STREAM: _ClassVar[OutputPolicy] + OUTPUT_POLICY_DISCARD: _ClassVar[OutputPolicy] + class SandboxStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () SANDBOX_STATUS_UNSPECIFIED: _ClassVar[SandboxStatus] @@ -25,6 +32,20 @@ class SandboxStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): SANDBOX_STATUS_PAUSED: _ClassVar[SandboxStatus] SANDBOX_STATUS_TERMINATING: _ClassVar[SandboxStatus] +class FileSystemSnapshotStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + FILE_SYSTEM_SNAPSHOT_STATUS_UNSPECIFIED: _ClassVar[FileSystemSnapshotStatus] + FILE_SYSTEM_SNAPSHOT_STATUS_CREATING: _ClassVar[FileSystemSnapshotStatus] + FILE_SYSTEM_SNAPSHOT_STATUS_READY: _ClassVar[FileSystemSnapshotStatus] + FILE_SYSTEM_SNAPSHOT_STATUS_FAILED: _ClassVar[FileSystemSnapshotStatus] + FILE_SYSTEM_SNAPSHOT_STATUS_DELETING: _ClassVar[FileSystemSnapshotStatus] + +class FileSystemSnapshotTrigger(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + FILE_SYSTEM_SNAPSHOT_TRIGGER_UNSPECIFIED: _ClassVar[FileSystemSnapshotTrigger] + FILE_SYSTEM_SNAPSHOT_TRIGGER_STOP: _ClassVar[FileSystemSnapshotTrigger] + FILE_SYSTEM_SNAPSHOT_TRIGGER_MANUAL: _ClassVar[FileSystemSnapshotTrigger] + class ActionType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () ACTION_TYPE_UNSPECIFIED: _ClassVar[ActionType] @@ -52,6 +73,16 @@ class ObjectStoragePermission(int, metaclass=_enum_type_wrapper.EnumTypeWrapper) OBJECT_STORAGE_PERMISSION_UNSPECIFIED: _ClassVar[ObjectStoragePermission] OBJECT_STORAGE_PERMISSION_READ: _ClassVar[ObjectStoragePermission] OBJECT_STORAGE_PERMISSION_READ_WRITE: _ClassVar[ObjectStoragePermission] + +class FileSystemSnapshotBucketMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_UNSPECIFIED: _ClassVar[FileSystemSnapshotBucketMode] + FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_CW_MANAGED: _ClassVar[FileSystemSnapshotBucketMode] + FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_BRING_YOUR_OWN: _ClassVar[FileSystemSnapshotBucketMode] +OUTPUT_POLICY_UNSPECIFIED: OutputPolicy +OUTPUT_POLICY_BUFFERED: OutputPolicy +OUTPUT_POLICY_STREAM: OutputPolicy +OUTPUT_POLICY_DISCARD: OutputPolicy SANDBOX_STATUS_UNSPECIFIED: SandboxStatus SANDBOX_STATUS_CREATING: SandboxStatus SANDBOX_STATUS_RUNNING: SandboxStatus @@ -61,6 +92,14 @@ SANDBOX_STATUS_TERMINATED: SandboxStatus SANDBOX_STATUS_PENDING: SandboxStatus SANDBOX_STATUS_PAUSED: SandboxStatus SANDBOX_STATUS_TERMINATING: SandboxStatus +FILE_SYSTEM_SNAPSHOT_STATUS_UNSPECIFIED: FileSystemSnapshotStatus +FILE_SYSTEM_SNAPSHOT_STATUS_CREATING: FileSystemSnapshotStatus +FILE_SYSTEM_SNAPSHOT_STATUS_READY: FileSystemSnapshotStatus +FILE_SYSTEM_SNAPSHOT_STATUS_FAILED: FileSystemSnapshotStatus +FILE_SYSTEM_SNAPSHOT_STATUS_DELETING: FileSystemSnapshotStatus +FILE_SYSTEM_SNAPSHOT_TRIGGER_UNSPECIFIED: FileSystemSnapshotTrigger +FILE_SYSTEM_SNAPSHOT_TRIGGER_STOP: FileSystemSnapshotTrigger +FILE_SYSTEM_SNAPSHOT_TRIGGER_MANUAL: FileSystemSnapshotTrigger ACTION_TYPE_UNSPECIFIED: ActionType ACTION_TYPE_EXEC: ActionType ACTION_TYPE_ADD_FILE: ActionType @@ -80,6 +119,9 @@ EGRESS_TYPE_ALLOWLIST: EgressType OBJECT_STORAGE_PERMISSION_UNSPECIFIED: ObjectStoragePermission OBJECT_STORAGE_PERMISSION_READ: ObjectStoragePermission OBJECT_STORAGE_PERMISSION_READ_WRITE: ObjectStoragePermission +FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_UNSPECIFIED: FileSystemSnapshotBucketMode +FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_CW_MANAGED: FileSystemSnapshotBucketMode +FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_BRING_YOUR_OWN: FileSystemSnapshotBucketMode class MountedFile(_message.Message): __slots__ = ("mount_path", "file_content") @@ -161,6 +203,22 @@ class S3Mount(_message.Message): mount_path: str def __init__(self, bucket: _Optional[str] = ..., directory: _Optional[str] = ..., mount_path: _Optional[str] = ...) -> None: ... +class FileSystemSnapshotSource(_message.Message): + __slots__ = ("file_system_snapshot_id",) + FILE_SYSTEM_SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + file_system_snapshot_id: str + def __init__(self, file_system_snapshot_id: _Optional[str] = ...) -> None: ... + +class SandboxFileSystemMount(_message.Message): + __slots__ = ("mount_path", "size", "file_system_snapshot") + MOUNT_PATH_FIELD_NUMBER: _ClassVar[int] + SIZE_FIELD_NUMBER: _ClassVar[int] + FILE_SYSTEM_SNAPSHOT_FIELD_NUMBER: _ClassVar[int] + mount_path: str + size: str + file_system_snapshot: FileSystemSnapshotSource + def __init__(self, mount_path: _Optional[str] = ..., size: _Optional[str] = ..., file_system_snapshot: _Optional[_Union[FileSystemSnapshotSource, _Mapping]] = ...) -> None: ... + class ExecPayload(_message.Message): __slots__ = ("command", "args") COMMAND_FIELD_NUMBER: _ClassVar[int] @@ -170,14 +228,22 @@ class ExecPayload(_message.Message): def __init__(self, command: _Optional[str] = ..., args: _Optional[_Iterable[str]] = ...) -> None: ... class ExecResponse(_message.Message): - __slots__ = ("stdout", "stderr", "exit_code") + __slots__ = ("stdout", "stderr", "exit_code", "stdout_truncated", "stderr_truncated", "stdout_bytes_produced", "stderr_bytes_produced") STDOUT_FIELD_NUMBER: _ClassVar[int] STDERR_FIELD_NUMBER: _ClassVar[int] EXIT_CODE_FIELD_NUMBER: _ClassVar[int] + STDOUT_TRUNCATED_FIELD_NUMBER: _ClassVar[int] + STDERR_TRUNCATED_FIELD_NUMBER: _ClassVar[int] + STDOUT_BYTES_PRODUCED_FIELD_NUMBER: _ClassVar[int] + STDERR_BYTES_PRODUCED_FIELD_NUMBER: _ClassVar[int] stdout: bytes stderr: bytes exit_code: int - def __init__(self, stdout: _Optional[bytes] = ..., stderr: _Optional[bytes] = ..., exit_code: _Optional[int] = ...) -> None: ... + stdout_truncated: bool + stderr_truncated: bool + stdout_bytes_produced: int + stderr_bytes_produced: int + def __init__(self, stdout: _Optional[bytes] = ..., stderr: _Optional[bytes] = ..., exit_code: _Optional[int] = ..., stdout_truncated: bool = ..., stderr_truncated: bool = ..., stdout_bytes_produced: _Optional[int] = ..., stderr_bytes_produced: _Optional[int] = ...) -> None: ... class ResourceUsage(_message.Message): __slots__ = ("cpu_millicores_used", "memory_mb_used", "gpu_count_used") @@ -198,7 +264,7 @@ class ObjectStorageAccess(_message.Message): def __init__(self, buckets: _Optional[_Iterable[str]] = ..., permission: _Optional[_Union[ObjectStoragePermission, str]] = ...) -> None: ... class StartSandboxRequest(_message.Message): - __slots__ = ("command", "args", "tags", "resources", "container_image", "environment_variables", "ports", "mounted_files", "s3_mount", "network", "profile_ids", "runner_ids", "profile_names", "max_lifetime_seconds", "max_timeout_seconds", "runner_cluster_secrets", "object_storage_access", "pod_annotations", "secret_stores", "resource_limits", "resource_requests") + __slots__ = ("command", "args", "tags", "resources", "container_image", "environment_variables", "ports", "mounted_files", "s3_mount", "network", "file_system", "profile_ids", "runner_ids", "profile_names", "max_lifetime_seconds", "max_timeout_seconds", "runner_cluster_secrets", "object_storage_access", "pod_annotations", "secret_stores", "resource_limits", "resource_requests") class EnvironmentVariablesEntry(_message.Message): __slots__ = ("key", "value") KEY_FIELD_NUMBER: _ClassVar[int] @@ -223,6 +289,7 @@ class StartSandboxRequest(_message.Message): MOUNTED_FILES_FIELD_NUMBER: _ClassVar[int] S3_MOUNT_FIELD_NUMBER: _ClassVar[int] NETWORK_FIELD_NUMBER: _ClassVar[int] + FILE_SYSTEM_FIELD_NUMBER: _ClassVar[int] PROFILE_IDS_FIELD_NUMBER: _ClassVar[int] RUNNER_IDS_FIELD_NUMBER: _ClassVar[int] PROFILE_NAMES_FIELD_NUMBER: _ClassVar[int] @@ -244,6 +311,7 @@ class StartSandboxRequest(_message.Message): mounted_files: _containers.RepeatedCompositeFieldContainer[MountedFile] s3_mount: S3Mount network: NetworkOptions + file_system: SandboxFileSystemMount profile_ids: _containers.RepeatedScalarFieldContainer[str] runner_ids: _containers.RepeatedScalarFieldContainer[str] profile_names: _containers.RepeatedScalarFieldContainer[str] @@ -255,7 +323,7 @@ class StartSandboxRequest(_message.Message): secret_stores: _containers.RepeatedCompositeFieldContainer[_secrets_pb2.SecretStoreReference] resource_limits: ResourceRequest resource_requests: ResourceRequest - def __init__(self, command: _Optional[str] = ..., args: _Optional[_Iterable[str]] = ..., tags: _Optional[_Iterable[str]] = ..., resources: _Optional[_Union[ResourceRequest, _Mapping]] = ..., container_image: _Optional[str] = ..., environment_variables: _Optional[_Mapping[str, str]] = ..., ports: _Optional[_Iterable[_Union[Port, _Mapping]]] = ..., mounted_files: _Optional[_Iterable[_Union[MountedFile, _Mapping]]] = ..., s3_mount: _Optional[_Union[S3Mount, _Mapping]] = ..., network: _Optional[_Union[NetworkOptions, _Mapping]] = ..., profile_ids: _Optional[_Iterable[str]] = ..., runner_ids: _Optional[_Iterable[str]] = ..., profile_names: _Optional[_Iterable[str]] = ..., max_lifetime_seconds: _Optional[int] = ..., max_timeout_seconds: _Optional[int] = ..., runner_cluster_secrets: _Optional[_Iterable[_Union[RunnerClusterSecretReference, _Mapping]]] = ..., object_storage_access: _Optional[_Union[ObjectStorageAccess, _Mapping]] = ..., pod_annotations: _Optional[_Mapping[str, str]] = ..., secret_stores: _Optional[_Iterable[_Union[_secrets_pb2.SecretStoreReference, _Mapping]]] = ..., resource_limits: _Optional[_Union[ResourceRequest, _Mapping]] = ..., resource_requests: _Optional[_Union[ResourceRequest, _Mapping]] = ...) -> None: ... + def __init__(self, command: _Optional[str] = ..., args: _Optional[_Iterable[str]] = ..., tags: _Optional[_Iterable[str]] = ..., resources: _Optional[_Union[ResourceRequest, _Mapping]] = ..., container_image: _Optional[str] = ..., environment_variables: _Optional[_Mapping[str, str]] = ..., ports: _Optional[_Iterable[_Union[Port, _Mapping]]] = ..., mounted_files: _Optional[_Iterable[_Union[MountedFile, _Mapping]]] = ..., s3_mount: _Optional[_Union[S3Mount, _Mapping]] = ..., network: _Optional[_Union[NetworkOptions, _Mapping]] = ..., file_system: _Optional[_Union[SandboxFileSystemMount, _Mapping]] = ..., profile_ids: _Optional[_Iterable[str]] = ..., runner_ids: _Optional[_Iterable[str]] = ..., profile_names: _Optional[_Iterable[str]] = ..., max_lifetime_seconds: _Optional[int] = ..., max_timeout_seconds: _Optional[int] = ..., runner_cluster_secrets: _Optional[_Iterable[_Union[RunnerClusterSecretReference, _Mapping]]] = ..., object_storage_access: _Optional[_Union[ObjectStorageAccess, _Mapping]] = ..., pod_annotations: _Optional[_Mapping[str, str]] = ..., secret_stores: _Optional[_Iterable[_Union[_secrets_pb2.SecretStoreReference, _Mapping]]] = ..., resource_limits: _Optional[_Union[ResourceRequest, _Mapping]] = ..., resource_requests: _Optional[_Union[ResourceRequest, _Mapping]] = ...) -> None: ... class StartSandboxResponse(_message.Message): __slots__ = ("sandbox_id", "started_at_time", "service_address", "exposed_ports", "requested_resources", "profile_id", "runner_id", "sandbox_status", "applied_ingress_mode", "applied_egress_mode", "requested_resource_limits", "requested_resource_requests") @@ -286,18 +354,114 @@ class StartSandboxResponse(_message.Message): def __init__(self, sandbox_id: _Optional[str] = ..., started_at_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., service_address: _Optional[str] = ..., exposed_ports: _Optional[_Iterable[_Union[Port, _Mapping]]] = ..., requested_resources: _Optional[_Union[ResourceRequest, _Mapping]] = ..., profile_id: _Optional[str] = ..., runner_id: _Optional[str] = ..., sandbox_status: _Optional[_Union[SandboxStatus, str]] = ..., applied_ingress_mode: _Optional[str] = ..., applied_egress_mode: _Optional[str] = ..., requested_resource_limits: _Optional[_Union[ResourceRequest, _Mapping]] = ..., requested_resource_requests: _Optional[_Union[ResourceRequest, _Mapping]] = ...) -> None: ... class StopSandboxRequest(_message.Message): - __slots__ = ("sandbox_id", "graceful_shutdown_seconds", "snapshot_on_stop", "max_timeout_seconds") + __slots__ = ("sandbox_id", "graceful_shutdown_seconds", "file_system_snapshot_on_stop", "max_timeout_seconds", "idempotency_key", "wait_for_ready") SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] GRACEFUL_SHUTDOWN_SECONDS_FIELD_NUMBER: _ClassVar[int] - SNAPSHOT_ON_STOP_FIELD_NUMBER: _ClassVar[int] + FILE_SYSTEM_SNAPSHOT_ON_STOP_FIELD_NUMBER: _ClassVar[int] MAX_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + IDEMPOTENCY_KEY_FIELD_NUMBER: _ClassVar[int] + WAIT_FOR_READY_FIELD_NUMBER: _ClassVar[int] sandbox_id: str graceful_shutdown_seconds: int - snapshot_on_stop: bool + file_system_snapshot_on_stop: bool max_timeout_seconds: int - def __init__(self, sandbox_id: _Optional[str] = ..., graceful_shutdown_seconds: _Optional[int] = ..., snapshot_on_stop: bool = ..., max_timeout_seconds: _Optional[int] = ...) -> None: ... + idempotency_key: str + wait_for_ready: bool + def __init__(self, sandbox_id: _Optional[str] = ..., graceful_shutdown_seconds: _Optional[int] = ..., file_system_snapshot_on_stop: bool = ..., max_timeout_seconds: _Optional[int] = ..., idempotency_key: _Optional[str] = ..., wait_for_ready: bool = ...) -> None: ... class StopSandboxResponse(_message.Message): + __slots__ = ("success", "error_message", "file_system_snapshot_id") + SUCCESS_FIELD_NUMBER: _ClassVar[int] + ERROR_MESSAGE_FIELD_NUMBER: _ClassVar[int] + FILE_SYSTEM_SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + success: bool + error_message: str + file_system_snapshot_id: str + def __init__(self, success: bool = ..., error_message: _Optional[str] = ..., file_system_snapshot_id: _Optional[str] = ...) -> None: ... + +class CreateFileSystemSnapshotRequest(_message.Message): + __slots__ = ("sandbox_id", "idempotency_key", "wait_for_ready", "max_timeout_seconds") + SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + IDEMPOTENCY_KEY_FIELD_NUMBER: _ClassVar[int] + WAIT_FOR_READY_FIELD_NUMBER: _ClassVar[int] + MAX_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + sandbox_id: str + idempotency_key: str + wait_for_ready: bool + max_timeout_seconds: int + def __init__(self, sandbox_id: _Optional[str] = ..., idempotency_key: _Optional[str] = ..., wait_for_ready: bool = ..., max_timeout_seconds: _Optional[int] = ...) -> None: ... + +class CreateFileSystemSnapshotResponse(_message.Message): + __slots__ = ("success", "error_message", "file_system_snapshot_id") + SUCCESS_FIELD_NUMBER: _ClassVar[int] + ERROR_MESSAGE_FIELD_NUMBER: _ClassVar[int] + FILE_SYSTEM_SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + success: bool + error_message: str + file_system_snapshot_id: str + def __init__(self, success: bool = ..., error_message: _Optional[str] = ..., file_system_snapshot_id: _Optional[str] = ...) -> None: ... + +class FileSystemSnapshot(_message.Message): + __slots__ = ("file_system_snapshot_id", "status", "status_reason", "size_bytes", "created_at", "updated_at", "completed_at", "source_sandbox_id", "trigger", "idempotency_key", "object_bucket") + FILE_SYSTEM_SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + STATUS_FIELD_NUMBER: _ClassVar[int] + STATUS_REASON_FIELD_NUMBER: _ClassVar[int] + SIZE_BYTES_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + UPDATED_AT_FIELD_NUMBER: _ClassVar[int] + COMPLETED_AT_FIELD_NUMBER: _ClassVar[int] + SOURCE_SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] + TRIGGER_FIELD_NUMBER: _ClassVar[int] + IDEMPOTENCY_KEY_FIELD_NUMBER: _ClassVar[int] + OBJECT_BUCKET_FIELD_NUMBER: _ClassVar[int] + file_system_snapshot_id: str + status: FileSystemSnapshotStatus + status_reason: str + size_bytes: int + created_at: _timestamp_pb2.Timestamp + updated_at: _timestamp_pb2.Timestamp + completed_at: _timestamp_pb2.Timestamp + source_sandbox_id: str + trigger: FileSystemSnapshotTrigger + idempotency_key: str + object_bucket: str + def __init__(self, file_system_snapshot_id: _Optional[str] = ..., status: _Optional[_Union[FileSystemSnapshotStatus, str]] = ..., status_reason: _Optional[str] = ..., size_bytes: _Optional[int] = ..., created_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., updated_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., completed_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., source_sandbox_id: _Optional[str] = ..., trigger: _Optional[_Union[FileSystemSnapshotTrigger, str]] = ..., idempotency_key: _Optional[str] = ..., object_bucket: _Optional[str] = ...) -> None: ... + +class GetFileSystemSnapshotRequest(_message.Message): + __slots__ = ("file_system_snapshot_id", "max_timeout_seconds") + FILE_SYSTEM_SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + MAX_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + file_system_snapshot_id: str + max_timeout_seconds: int + def __init__(self, file_system_snapshot_id: _Optional[str] = ..., max_timeout_seconds: _Optional[int] = ...) -> None: ... + +class ListFileSystemSnapshotsRequest(_message.Message): + __slots__ = ("page_size", "page_token", "max_timeout_seconds") + PAGE_SIZE_FIELD_NUMBER: _ClassVar[int] + PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + MAX_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + page_size: int + page_token: str + max_timeout_seconds: int + def __init__(self, page_size: _Optional[int] = ..., page_token: _Optional[str] = ..., max_timeout_seconds: _Optional[int] = ...) -> None: ... + +class ListFileSystemSnapshotsResponse(_message.Message): + __slots__ = ("file_system_snapshots", "next_page_token") + FILE_SYSTEM_SNAPSHOTS_FIELD_NUMBER: _ClassVar[int] + NEXT_PAGE_TOKEN_FIELD_NUMBER: _ClassVar[int] + file_system_snapshots: _containers.RepeatedCompositeFieldContainer[FileSystemSnapshot] + next_page_token: str + def __init__(self, file_system_snapshots: _Optional[_Iterable[_Union[FileSystemSnapshot, _Mapping]]] = ..., next_page_token: _Optional[str] = ...) -> None: ... + +class DeleteFileSystemSnapshotRequest(_message.Message): + __slots__ = ("file_system_snapshot_id", "max_timeout_seconds") + FILE_SYSTEM_SNAPSHOT_ID_FIELD_NUMBER: _ClassVar[int] + MAX_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + file_system_snapshot_id: str + max_timeout_seconds: int + def __init__(self, file_system_snapshot_id: _Optional[str] = ..., max_timeout_seconds: _Optional[int] = ...) -> None: ... + +class DeleteFileSystemSnapshotResponse(_message.Message): __slots__ = ("success", "error_message") SUCCESS_FIELD_NUMBER: _ClassVar[int] ERROR_MESSAGE_FIELD_NUMBER: _ClassVar[int] @@ -314,7 +478,7 @@ class GetSandboxRequest(_message.Message): def __init__(self, sandbox_id: _Optional[str] = ..., max_timeout_seconds: _Optional[int] = ...) -> None: ... class GetSandboxResponse(_message.Message): - __slots__ = ("sandbox_id", "started_at_time", "sandbox_status", "current_resource_usage", "runner_id", "runner_group_id", "profile_id", "service_address", "exposed_ports", "applied_ingress_mode", "applied_egress_mode") + __slots__ = ("sandbox_id", "started_at_time", "sandbox_status", "current_resource_usage", "runner_id", "runner_group_id", "profile_id", "service_address", "exposed_ports", "applied_ingress_mode", "applied_egress_mode", "status_reason") SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] STARTED_AT_TIME_FIELD_NUMBER: _ClassVar[int] SANDBOX_STATUS_FIELD_NUMBER: _ClassVar[int] @@ -326,6 +490,7 @@ class GetSandboxResponse(_message.Message): EXPOSED_PORTS_FIELD_NUMBER: _ClassVar[int] APPLIED_INGRESS_MODE_FIELD_NUMBER: _ClassVar[int] APPLIED_EGRESS_MODE_FIELD_NUMBER: _ClassVar[int] + STATUS_REASON_FIELD_NUMBER: _ClassVar[int] sandbox_id: str started_at_time: _timestamp_pb2.Timestamp sandbox_status: SandboxStatus @@ -337,7 +502,8 @@ class GetSandboxResponse(_message.Message): exposed_ports: _containers.RepeatedCompositeFieldContainer[Port] applied_ingress_mode: str applied_egress_mode: str - def __init__(self, sandbox_id: _Optional[str] = ..., started_at_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., sandbox_status: _Optional[_Union[SandboxStatus, str]] = ..., current_resource_usage: _Optional[_Union[ResourceUsage, _Mapping]] = ..., runner_id: _Optional[str] = ..., runner_group_id: _Optional[str] = ..., profile_id: _Optional[str] = ..., service_address: _Optional[str] = ..., exposed_ports: _Optional[_Iterable[_Union[Port, _Mapping]]] = ..., applied_ingress_mode: _Optional[str] = ..., applied_egress_mode: _Optional[str] = ...) -> None: ... + status_reason: str + def __init__(self, sandbox_id: _Optional[str] = ..., started_at_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., sandbox_status: _Optional[_Union[SandboxStatus, str]] = ..., current_resource_usage: _Optional[_Union[ResourceUsage, _Mapping]] = ..., runner_id: _Optional[str] = ..., runner_group_id: _Optional[str] = ..., profile_id: _Optional[str] = ..., service_address: _Optional[str] = ..., exposed_ports: _Optional[_Iterable[_Union[Port, _Mapping]]] = ..., applied_ingress_mode: _Optional[str] = ..., applied_egress_mode: _Optional[str] = ..., status_reason: _Optional[str] = ...) -> None: ... class ListSandboxesRequest(_message.Message): __slots__ = ("tags", "status", "profile_ids", "runner_ids", "profile_names", "max_timeout_seconds", "include_stopped", "page_size", "page_token") @@ -412,16 +578,20 @@ class DeleteSandboxResponse(_message.Message): def __init__(self, success: bool = ..., error_message: _Optional[str] = ...) -> None: ... class ExecSandboxRequest(_message.Message): - __slots__ = ("sandbox_id", "command", "args", "max_timeout_seconds") + __slots__ = ("sandbox_id", "command", "args", "max_timeout_seconds", "output_handling", "buffered_max_kib") SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] COMMAND_FIELD_NUMBER: _ClassVar[int] ARGS_FIELD_NUMBER: _ClassVar[int] MAX_TIMEOUT_SECONDS_FIELD_NUMBER: _ClassVar[int] + OUTPUT_HANDLING_FIELD_NUMBER: _ClassVar[int] + BUFFERED_MAX_KIB_FIELD_NUMBER: _ClassVar[int] sandbox_id: str command: _containers.RepeatedScalarFieldContainer[str] args: _containers.RepeatedScalarFieldContainer[str] max_timeout_seconds: int - def __init__(self, sandbox_id: _Optional[str] = ..., command: _Optional[_Iterable[str]] = ..., args: _Optional[_Iterable[str]] = ..., max_timeout_seconds: _Optional[int] = ...) -> None: ... + output_handling: OutputPolicy + buffered_max_kib: int + def __init__(self, sandbox_id: _Optional[str] = ..., command: _Optional[_Iterable[str]] = ..., args: _Optional[_Iterable[str]] = ..., max_timeout_seconds: _Optional[int] = ..., output_handling: _Optional[_Union[OutputPolicy, str]] = ..., buffered_max_kib: _Optional[int] = ...) -> None: ... class ExecSandboxResponse(_message.Message): __slots__ = ("result",) @@ -570,3 +740,27 @@ class DeleteObjectStorageWIFConfigRequest(_message.Message): class DeleteObjectStorageWIFConfigResponse(_message.Message): __slots__ = () def __init__(self) -> None: ... + +class FileSystemSnapshotBucketConfig(_message.Message): + __slots__ = ("bucket_name", "region", "mode", "effective_bucket_name") + BUCKET_NAME_FIELD_NUMBER: _ClassVar[int] + REGION_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] + EFFECTIVE_BUCKET_NAME_FIELD_NUMBER: _ClassVar[int] + bucket_name: str + region: str + mode: FileSystemSnapshotBucketMode + effective_bucket_name: str + def __init__(self, bucket_name: _Optional[str] = ..., region: _Optional[str] = ..., mode: _Optional[_Union[FileSystemSnapshotBucketMode, str]] = ..., effective_bucket_name: _Optional[str] = ...) -> None: ... + +class GetFileSystemSnapshotBucketConfigRequest(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class SetFileSystemSnapshotBucketConfigRequest(_message.Message): + __slots__ = ("bucket_name", "region") + BUCKET_NAME_FIELD_NUMBER: _ClassVar[int] + REGION_FIELD_NUMBER: _ClassVar[int] + bucket_name: str + region: str + def __init__(self, bucket_name: _Optional[str] = ..., region: _Optional[str] = ...) -> None: ... diff --git a/src/cwsandbox/_proto/gateway_pb2_grpc.py b/src/cwsandbox/_proto/gateway_pb2_grpc.py index 92d1e2d..d983ac4 100644 --- a/src/cwsandbox/_proto/gateway_pb2_grpc.py +++ b/src/cwsandbox/_proto/gateway_pb2_grpc.py @@ -32,6 +32,26 @@ def __init__(self, channel): request_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.StopSandboxRequest.SerializeToString, response_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.StopSandboxResponse.FromString, _registered_method=True) + self.CreateFileSystemSnapshot = channel.unary_unary( + '/coreweave.sandbox.v1beta2.GatewayService/CreateFileSystemSnapshot', + request_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.CreateFileSystemSnapshotRequest.SerializeToString, + response_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.CreateFileSystemSnapshotResponse.FromString, + _registered_method=True) + self.GetFileSystemSnapshot = channel.unary_unary( + '/coreweave.sandbox.v1beta2.GatewayService/GetFileSystemSnapshot', + request_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.GetFileSystemSnapshotRequest.SerializeToString, + response_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.FileSystemSnapshot.FromString, + _registered_method=True) + self.ListFileSystemSnapshots = channel.unary_unary( + '/coreweave.sandbox.v1beta2.GatewayService/ListFileSystemSnapshots', + request_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.ListFileSystemSnapshotsRequest.SerializeToString, + response_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.ListFileSystemSnapshotsResponse.FromString, + _registered_method=True) + self.DeleteFileSystemSnapshot = channel.unary_unary( + '/coreweave.sandbox.v1beta2.GatewayService/DeleteFileSystemSnapshot', + request_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteFileSystemSnapshotRequest.SerializeToString, + response_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteFileSystemSnapshotResponse.FromString, + _registered_method=True) self.Get = channel.unary_unary( '/coreweave.sandbox.v1beta2.GatewayService/Get', request_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.GetSandboxRequest.SerializeToString, @@ -92,6 +112,16 @@ def __init__(self, channel): request_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteObjectStorageWIFConfigRequest.SerializeToString, response_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteObjectStorageWIFConfigResponse.FromString, _registered_method=True) + self.GetFileSystemSnapshotBucketConfig = channel.unary_unary( + '/coreweave.sandbox.v1beta2.GatewayService/GetFileSystemSnapshotBucketConfig', + request_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.GetFileSystemSnapshotBucketConfigRequest.SerializeToString, + response_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.FileSystemSnapshotBucketConfig.FromString, + _registered_method=True) + self.SetFileSystemSnapshotBucketConfig = channel.unary_unary( + '/coreweave.sandbox.v1beta2.GatewayService/SetFileSystemSnapshotBucketConfig', + request_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.SetFileSystemSnapshotBucketConfigRequest.SerializeToString, + response_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.FileSystemSnapshotBucketConfig.FromString, + _registered_method=True) class GatewayServiceServicer(object): @@ -116,6 +146,34 @@ def Stop(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def CreateFileSystemSnapshot(self, request, context): + """CreateFileSystemSnapshot creates an FSS from a running sandbox without stopping it. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def GetFileSystemSnapshot(self, request, context): + """GetFileSystemSnapshot retrieves an org-scoped FSS by id. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ListFileSystemSnapshots(self, request, context): + """ListFileSystemSnapshots lists org-scoped FSS rows. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def DeleteFileSystemSnapshot(self, request, context): + """DeleteFileSystemSnapshot hides an FSS row from future customer Get/List calls. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def Get(self, request, context): """Get retrieves details about a specific sandbox. """ @@ -207,6 +265,26 @@ def DeleteObjectStorageWIFConfig(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def GetFileSystemSnapshotBucketConfig(self, request, context): + """--- File System Snapshot Bucket Config Management --- + + Returns the organization's FSS bucket configuration (org_id derived from the + authenticated caller). Open to any authenticated org member. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def SetFileSystemSnapshotBucketConfig(self, request, context): + """Creates or replaces the organization's FSS bucket configuration (idempotent + full-replace upsert; org_id derived from the caller). Requires the + sandbox_admin action: changing or clearing the bucket abandons access to + snapshots stored in the prior bucket. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_GatewayServiceServicer_to_server(servicer, server): rpc_method_handlers = { @@ -220,6 +298,26 @@ def add_GatewayServiceServicer_to_server(servicer, server): request_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.StopSandboxRequest.FromString, response_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.StopSandboxResponse.SerializeToString, ), + 'CreateFileSystemSnapshot': grpc.unary_unary_rpc_method_handler( + servicer.CreateFileSystemSnapshot, + request_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.CreateFileSystemSnapshotRequest.FromString, + response_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.CreateFileSystemSnapshotResponse.SerializeToString, + ), + 'GetFileSystemSnapshot': grpc.unary_unary_rpc_method_handler( + servicer.GetFileSystemSnapshot, + request_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.GetFileSystemSnapshotRequest.FromString, + response_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.FileSystemSnapshot.SerializeToString, + ), + 'ListFileSystemSnapshots': grpc.unary_unary_rpc_method_handler( + servicer.ListFileSystemSnapshots, + request_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.ListFileSystemSnapshotsRequest.FromString, + response_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.ListFileSystemSnapshotsResponse.SerializeToString, + ), + 'DeleteFileSystemSnapshot': grpc.unary_unary_rpc_method_handler( + servicer.DeleteFileSystemSnapshot, + request_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteFileSystemSnapshotRequest.FromString, + response_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteFileSystemSnapshotResponse.SerializeToString, + ), 'Get': grpc.unary_unary_rpc_method_handler( servicer.Get, request_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.GetSandboxRequest.FromString, @@ -280,6 +378,16 @@ def add_GatewayServiceServicer_to_server(servicer, server): request_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteObjectStorageWIFConfigRequest.FromString, response_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteObjectStorageWIFConfigResponse.SerializeToString, ), + 'GetFileSystemSnapshotBucketConfig': grpc.unary_unary_rpc_method_handler( + servicer.GetFileSystemSnapshotBucketConfig, + request_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.GetFileSystemSnapshotBucketConfigRequest.FromString, + response_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.FileSystemSnapshotBucketConfig.SerializeToString, + ), + 'SetFileSystemSnapshotBucketConfig': grpc.unary_unary_rpc_method_handler( + servicer.SetFileSystemSnapshotBucketConfig, + request_deserializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.SetFileSystemSnapshotBucketConfigRequest.FromString, + response_serializer=coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.FileSystemSnapshotBucketConfig.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'coreweave.sandbox.v1beta2.GatewayService', rpc_method_handlers) @@ -350,6 +458,114 @@ def Stop(request, metadata, _registered_method=True) + @staticmethod + def CreateFileSystemSnapshot(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/coreweave.sandbox.v1beta2.GatewayService/CreateFileSystemSnapshot', + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.CreateFileSystemSnapshotRequest.SerializeToString, + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.CreateFileSystemSnapshotResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def GetFileSystemSnapshot(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/coreweave.sandbox.v1beta2.GatewayService/GetFileSystemSnapshot', + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.GetFileSystemSnapshotRequest.SerializeToString, + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.FileSystemSnapshot.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def ListFileSystemSnapshots(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/coreweave.sandbox.v1beta2.GatewayService/ListFileSystemSnapshots', + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.ListFileSystemSnapshotsRequest.SerializeToString, + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.ListFileSystemSnapshotsResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def DeleteFileSystemSnapshot(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/coreweave.sandbox.v1beta2.GatewayService/DeleteFileSystemSnapshot', + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteFileSystemSnapshotRequest.SerializeToString, + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.DeleteFileSystemSnapshotResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def Get(request, target, @@ -673,3 +889,57 @@ def DeleteObjectStorageWIFConfig(request, timeout, metadata, _registered_method=True) + + @staticmethod + def GetFileSystemSnapshotBucketConfig(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/coreweave.sandbox.v1beta2.GatewayService/GetFileSystemSnapshotBucketConfig', + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.GetFileSystemSnapshotBucketConfigRequest.SerializeToString, + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.FileSystemSnapshotBucketConfig.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def SetFileSystemSnapshotBucketConfig(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/coreweave.sandbox.v1beta2.GatewayService/SetFileSystemSnapshotBucketConfig', + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.SetFileSystemSnapshotBucketConfigRequest.SerializeToString, + coreweave_dot_sandbox_dot_v1beta2_dot_gateway__pb2.FileSystemSnapshotBucketConfig.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/src/cwsandbox/_sandbox.py b/src/cwsandbox/_sandbox.py index 9112e5f..15c79ed 100644 --- a/src/cwsandbox/_sandbox.py +++ b/src/cwsandbox/_sandbox.py @@ -13,11 +13,13 @@ import shlex import threading import time +import uuid import warnings import weakref from collections.abc import ( AsyncIterable, AsyncIterator, + Awaitable, Callable, Generator, Iterable, @@ -26,9 +28,9 @@ Sequence, ) from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from enum import StrEnum -from typing import TYPE_CHECKING, Any, Literal, Protocol +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar import grpc import grpc.aio @@ -39,6 +41,10 @@ DEFAULT_BASE_URL, DEFAULT_CLIENT_TIMEOUT_BUFFER_SECONDS, DEFAULT_FILE_OPERATION_CAP_BYTES, + DEFAULT_FSS_RETRY_BUDGET_SECONDS, + DEFAULT_FSS_STOP_CLIENT_SLACK_SECONDS, + DEFAULT_FSS_STOP_GRACE_FALLBACK_SECONDS, + DEFAULT_FSS_STOP_TIMEOUT_SECONDS, DEFAULT_GRACEFUL_SHUTDOWN_SECONDS, DEFAULT_MAX_POLL_INTERVAL_SECONDS, DEFAULT_POLL_BACKOFF_FACTOR, @@ -72,8 +78,17 @@ CWSANDBOX_FILE_NOT_FOUND, CWSANDBOX_FILE_TOO_LARGE, CWSANDBOX_FILE_TRUNCATED, + CWSANDBOX_FSS_BUCKET_MISMATCH, + CWSANDBOX_FSS_NOT_FOUND, + CWSANDBOX_FSS_NOT_READY, + CWSANDBOX_FSS_NOT_SUPPORTED, + CWSANDBOX_FSS_QUOTA_EXCEEDED, + CWSANDBOX_FSS_SIZE_EXCEEDED, + CWSANDBOX_FSS_WAIT_TIMEOUT, CWSANDBOX_SANDBOX_NOT_FOUND, FILE_ERROR_REASONS, + SNAPSHOT_INTERNAL_REASONS, + SNAPSHOT_TRANSIENT_REASONS, STREAM_BACKPRESSURE, STREAM_TRUNCATED, UNAVAILABLE_REASONS, @@ -96,6 +111,12 @@ from cwsandbox._resources import normalize_resources from cwsandbox._types import ( ExecOutcome, + FileSystemSnapshot, + FileSystemSnapshotBucketConfig, + FileSystemSnapshotBucketMode, + FileSystemSnapshotOptions, + FileSystemSnapshotStatus, + FileSystemSnapshotTrigger, NetworkOptions, OperationRef, Process, @@ -118,12 +139,22 @@ SandboxNotRunningError, SandboxRequestTimeoutError, SandboxResourceExhaustedError, + SandboxSnapshotError, SandboxStreamBackpressureError, SandboxStreamTruncatedError, SandboxTerminalStateUnavailableError, SandboxTerminatedError, SandboxTimeoutError, SandboxUnavailableError, + SnapshotBackendThrottledError, + SnapshotBucketMismatchError, + SnapshotNotFoundError, + SnapshotNotReadyError, + SnapshotNotSupportedError, + SnapshotOnStopConflictError, + SnapshotQuotaExceededError, + SnapshotSizeExceededError, + SnapshotWaitTimeoutError, ) if TYPE_CHECKING: @@ -180,6 +211,165 @@ def to_proto(self) -> int: return gateway_pb2.SandboxStatus.Value(proto_name) +def _fss_status_from_proto(value: int) -> FileSystemSnapshotStatus: + """Convert a proto FileSystemSnapshotStatus enum to the SDK enum.""" + try: + name = gateway_pb2.FileSystemSnapshotStatus.Name(value).replace( + "FILE_SYSTEM_SNAPSHOT_STATUS_", "" + ) + return FileSystemSnapshotStatus[name] + except (ValueError, KeyError): + logger.warning("Unknown snapshot status %s, treating as UNSPECIFIED", value) + return FileSystemSnapshotStatus.UNSPECIFIED + + +def _fss_trigger_from_proto(value: int) -> FileSystemSnapshotTrigger: + """Convert a proto FileSystemSnapshotTrigger enum to the SDK enum.""" + try: + name = gateway_pb2.FileSystemSnapshotTrigger.Name(value).replace( + "FILE_SYSTEM_SNAPSHOT_TRIGGER_", "" + ) + return FileSystemSnapshotTrigger[name] + except (ValueError, KeyError): + logger.warning("Unknown snapshot trigger %s, treating as UNSPECIFIED", value) + return FileSystemSnapshotTrigger.UNSPECIFIED + + +def _fss_bucket_mode_from_proto(value: int) -> FileSystemSnapshotBucketMode: + """Convert a proto FileSystemSnapshotBucketMode enum to the SDK enum.""" + try: + name = gateway_pb2.FileSystemSnapshotBucketMode.Name(value).replace( + "FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_", "" + ) + return FileSystemSnapshotBucketMode[name] + except (ValueError, KeyError): + logger.warning("Unknown snapshot bucket mode %s, treating as UNSPECIFIED", value) + return FileSystemSnapshotBucketMode.UNSPECIFIED + + +def _proto_timestamp_to_datetime(message: Any, field_name: str) -> datetime | None: + """Return a UTC datetime for a set proto Timestamp field, else None.""" + if not message.HasField(field_name): + return None + result = getattr(message, field_name).ToDatetime(tzinfo=UTC) + return result if isinstance(result, datetime) else None + + +def _snapshot_from_proto(proto: gateway_pb2.FileSystemSnapshot) -> FileSystemSnapshot: + """Convert a proto FileSystemSnapshot to the SDK dataclass.""" + return FileSystemSnapshot( + file_system_snapshot_id=proto.file_system_snapshot_id, + status=_fss_status_from_proto(proto.status), + status_reason=proto.status_reason, + size_bytes=proto.size_bytes, + source_sandbox_id=proto.source_sandbox_id, + trigger=_fss_trigger_from_proto(proto.trigger), + idempotency_key=proto.idempotency_key, + object_bucket=proto.object_bucket, + created_at=_proto_timestamp_to_datetime(proto, "created_at"), + updated_at=_proto_timestamp_to_datetime(proto, "updated_at"), + completed_at=_proto_timestamp_to_datetime(proto, "completed_at"), + ) + + +def _bucket_config_from_proto( + proto: gateway_pb2.FileSystemSnapshotBucketConfig, +) -> FileSystemSnapshotBucketConfig: + """Convert a proto FileSystemSnapshotBucketConfig to the SDK dataclass.""" + return FileSystemSnapshotBucketConfig( + mode=_fss_bucket_mode_from_proto(proto.mode), + bucket_name=proto.bucket_name, + region=proto.region, + effective_bucket_name=proto.effective_bucket_name, + ) + + +def _coerce_file_system_snapshot( + value: FileSystemSnapshotOptions | Mapping[str, Any] | None, +) -> FileSystemSnapshotOptions | None: + """Coerce the ``file_system_snapshot`` argument to FileSystemSnapshotOptions. + + Accepts a FileSystemSnapshotOptions, a plain mapping with matching keys, or + None. Raises TypeError for anything else. + """ + if value is None: + return None + if isinstance(value, FileSystemSnapshotOptions): + return value + if isinstance(value, Mapping): + return FileSystemSnapshotOptions(**value) + raise TypeError( + "file_system_snapshot must be FileSystemSnapshotOptions, dict, or None, " + f"got {type(value).__name__}" + ) + + +def _file_system_mount_kwargs(opts: FileSystemSnapshotOptions) -> dict[str, Any]: + """Build StartSandboxRequest.file_system (SandboxFileSystemMount) kwargs.""" + mount: dict[str, Any] = {"mount_path": opts.mount_path} + if opts.size is not None: + mount["size"] = opts.size + if opts.file_system_snapshot_id is not None: + mount["file_system_snapshot"] = {"file_system_snapshot_id": opts.file_system_snapshot_id} + return mount + + +async def _create_snapshot_via_stub( + stub: gateway_pb2_grpc.GatewayServiceStub, + sandbox_id: str, + *, + idempotency_key: str | None, + wait_for_ready: bool, + auth_metadata: tuple[tuple[str, str], ...], + timeout: float, + max_timeout_seconds: int | None = None, +) -> str: + """Call CreateFileSystemSnapshot on ``stub``; return the new snapshot ID.""" + request = gateway_pb2.CreateFileSystemSnapshotRequest( + sandbox_id=sandbox_id, + wait_for_ready=wait_for_ready, + ) + if idempotency_key: + request.idempotency_key = idempotency_key + if max_timeout_seconds is not None: + request.max_timeout_seconds = max_timeout_seconds + try: + response = await stub.CreateFileSystemSnapshot( + request, timeout=timeout, metadata=auth_metadata + ) + except grpc.RpcError as e: + raise _translate_rpc_error( + e, sandbox_id=sandbox_id, operation="Create file-system snapshot" + ) from e + if not response.success: + raise SandboxSnapshotError( + f"Failed to create file-system snapshot: {response.error_message or 'unknown error'}" + ) + return str(response.file_system_snapshot_id) + + +async def _get_snapshot_via_stub( + stub: gateway_pb2_grpc.GatewayServiceStub, + file_system_snapshot_id: str, + *, + auth_metadata: tuple[tuple[str, str], ...], + timeout: float, +) -> FileSystemSnapshot: + """Call GetFileSystemSnapshot on ``stub``; return the snapshot record.""" + request = gateway_pb2.GetFileSystemSnapshotRequest( + file_system_snapshot_id=file_system_snapshot_id + ) + try: + proto = await stub.GetFileSystemSnapshot(request, timeout=timeout, metadata=auth_metadata) + except grpc.RpcError as e: + raise _translate_rpc_error( + e, + operation="Get file-system snapshot", + file_system_snapshot_id=file_system_snapshot_id, + ) from e + return _snapshot_from_proto(proto) + + def _validate_cwd(cwd: str | None) -> None: """Validate cwd parameter for exec(). @@ -281,12 +471,71 @@ def _wrap_command_with_cwd(command: Sequence[str], cwd: str) -> list[str]: return ["/bin/sh", "-c", f"cd {escaped_cwd} && exec {escaped_command}"] +def _translate_snapshot_reason( + reason: str, + *, + details: str, + operation: str, + file_system_snapshot_id: str | None, + metadata: Mapping[str, str] | None, + retry_delay: timedelta | None, +) -> CWSandboxError | None: + """Map a trusted FSS ``CWSANDBOX_FSS_*`` reason to a typed exception. + + Returns ``None`` when ``reason`` is not a known FSS reason, so the caller + can fall through to status-code mapping. The ``file_system_snapshot_id`` is attached + only to ``SandboxSnapshotError`` variants; the transient and wait-timeout + classes inherit non-snapshot parents and do not carry it. + """ + snapshot_classes: dict[str, type[SandboxSnapshotError]] = { + CWSANDBOX_FSS_NOT_FOUND: SnapshotNotFoundError, + CWSANDBOX_FSS_NOT_READY: SnapshotNotReadyError, + CWSANDBOX_FSS_NOT_SUPPORTED: SnapshotNotSupportedError, + CWSANDBOX_FSS_SIZE_EXCEEDED: SnapshotSizeExceededError, + CWSANDBOX_FSS_QUOTA_EXCEEDED: SnapshotQuotaExceededError, + CWSANDBOX_FSS_BUCKET_MISMATCH: SnapshotBucketMismatchError, + } + cls = snapshot_classes.get(reason) + if cls is not None: + return cls( + f"{operation} failed ({reason}): {details}", + file_system_snapshot_id=file_system_snapshot_id, + reason=reason, + metadata=metadata, + retry_delay=retry_delay, + ) + if reason in SNAPSHOT_INTERNAL_REASONS: + return SandboxSnapshotError( + f"{operation} failed ({reason}): {details}", + file_system_snapshot_id=file_system_snapshot_id, + reason=reason, + metadata=metadata, + retry_delay=retry_delay, + ) + if reason == CWSANDBOX_FSS_WAIT_TIMEOUT: + return SnapshotWaitTimeoutError( + f"{operation} timed out waiting for snapshot ready ({reason}): {details}", + reason=reason, + metadata=metadata, + retry_delay=retry_delay, + ) + if reason in SNAPSHOT_TRANSIENT_REASONS: + return SnapshotBackendThrottledError( + f"Snapshot backend throttled ({reason}): {details}", + reason=reason, + metadata=metadata, + retry_delay=retry_delay, + ) + return None + + def _translate_rpc_error( e: grpc.RpcError, *, sandbox_id: str | None = None, operation: str = "operation", filepath: str | None = None, + file_system_snapshot_id: str | None = None, ) -> CWSandboxError: """Translate gRPC RpcError to appropriate CWSandbox exception. @@ -371,8 +620,33 @@ def _translate_rpc_error( metadata=metadata, retry_delay=retry_delay, ) + # File-system snapshot (FSS) reasons. The transient ones subclass + # SandboxUnavailableError so the poll loop treats them as retryable; + # the rest are terminal SandboxSnapshotError variants. + snapshot_exc = _translate_snapshot_reason( + reason, + details=details, + operation=operation, + file_system_snapshot_id=file_system_snapshot_id, + metadata=metadata, + retry_delay=retry_delay, + ) + if snapshot_exc is not None: + return snapshot_exc if code == grpc.StatusCode.NOT_FOUND: + # An FSS operation carries a snapshot ID, not a sandbox ID. Map a bare + # NOT_FOUND (no AIP-193 FSS reason, e.g. an older backend or a proxy that + # dropped the metadata) to the documented SnapshotNotFoundError so callers + # catching it still work. + if file_system_snapshot_id is not None: + return SnapshotNotFoundError( + f"File-system snapshot '{file_system_snapshot_id}' not found", + file_system_snapshot_id=file_system_snapshot_id, + reason=reason, + metadata=metadata, + retry_delay=retry_delay, + ) return SandboxNotFoundError( f"Sandbox '{sandbox_id}' not found" if sandbox_id else details, sandbox_id=sandbox_id, @@ -584,6 +858,96 @@ def _classify_poll_error(exc: CWSandboxError) -> _PollErrorClassification: return "fatal" +_T = TypeVar("_T") + + +async def _retry_transient_rpc( + attempt: Callable[[], Awaitable[_T]], + *, + budget_seconds: float, + operation: str, + non_retryable: tuple[type[CWSandboxError], ...] = (), +) -> _T: + """Run ``attempt`` with bounded retry on transient CWSandbox errors. + + ``attempt`` performs exactly one RPC try and must raise a *translated* + ``CWSandboxError`` on failure (i.e. wrap the stub call and + ``_translate_rpc_error``). Only classes in ``_RETRYABLE_POLL_EXCEPTIONS`` + are retried - transient unavailability, request-deadline, resource + exhaustion, and FSS backend-throttling (which subclasses + ``SandboxUnavailableError``). Every other error, including ``NOT_FOUND`` + and ``FAILED_PRECONDITION``, is fatal and re-raised on the first attempt. + + ``non_retryable`` lists exception classes to treat as fatal even when they + would otherwise be retryable. Use it when the per-attempt timeout *is* the + operation's ceiling, so a deadline is the ceiling being hit rather than a + transient blip - retrying would just re-spend the full (large) timeout and + overrun the ceiling (the loop bounds the inter-attempt gap, not attempt + duration). + + ``budget_seconds`` caps wall-clock time spent *retrying*; it never delays + the first attempt. On exhaustion the last translated exception is re-raised + unchanged. AIP-193 ``RetryInfo`` hints are honored; otherwise the backoff + uses the same decorrelated jitter as the status-poll loop. + """ + retry_deadline: float | None = None + last_exc: CWSandboxError | None = None + prev_sleep = DEFAULT_POLL_INTERVAL_SECONDS + attempts = 0 + + while True: + try: + return await attempt() + except CWSandboxError as exc: + last_exc = exc + if ( + isinstance(exc, non_retryable) + or _classify_poll_error(exc) != "retryable" + or budget_seconds <= 0 + ): + raise + + # First retryable failure starts the budget timer. + if retry_deadline is None: + retry_deadline = time.monotonic() + budget_seconds + + attempts += 1 + now = time.monotonic() + if now >= retry_deadline: + logger.debug( + "FSS retry budget exhausted for %s after %d attempt(s)", + operation, + attempts, + ) + raise + remaining = retry_deadline - now + hinted_delay = exc.retry_delay.total_seconds() if exc.retry_delay else None + if hinted_delay is not None and hinted_delay > 0: + sleep_for = min(hinted_delay, remaining, MAX_POLL_RETRY_HINTED_DELAY_SECONDS) + else: + base = DEFAULT_POLL_INTERVAL_SECONDS + cap = DEFAULT_MAX_POLL_INTERVAL_SECONDS + jitter_ceiling = max( + base, min(cap, prev_sleep * DEFAULT_POLL_BACKOFF_FACTOR, remaining) + ) + sleep_for = min(random.uniform(base, jitter_ceiling), remaining) + logger.debug( + "FSS retry for %s: sleep=%.2fs remaining=%.2fs attempt=%d", + operation, + sleep_for, + remaining, + attempts, + ) + await asyncio.sleep(sleep_for) + prev_sleep = sleep_for + # A long hinted delay can exhaust the budget while we slept; re-raise + # rather than issuing an attempt guaranteed to overrun the ceiling. + assert retry_deadline is not None + if time.monotonic() >= retry_deadline: + assert last_exc is not None + raise last_exc + + # --------------------------------------------------------------------------- # Lifecycle state types # --------------------------------------------------------------------------- @@ -755,6 +1119,7 @@ def __init__( s3_mount: dict[str, Any] | None = None, ports: list[dict[str, Any]] | None = None, network: NetworkOptions | dict[str, Any] | None = None, + file_system_snapshot: FileSystemSnapshotOptions | dict[str, Any] | None = None, max_timeout_seconds: int | None = None, environment_variables: dict[str, str] | None = None, annotations: dict[str, str] | None = None, @@ -794,6 +1159,12 @@ def __init__( s3_mount: S3 bucket mount configuration ports: Port mappings for the sandbox network: Network configuration (NetworkOptions dataclass) + file_system_snapshot: File-system snapshot (FSS) mount configuration. + Accepts a FileSystemSnapshotOptions or a dict with ``mount_path``, + optional ``size``, and optional ``file_system_snapshot_id``. When + ``file_system_snapshot_id`` is set, the snapshot is restored into + ``mount_path`` at start (fork); otherwise the mount starts empty. + Requires the organization to be enabled for FSS. max_timeout_seconds: Maximum timeout for sandbox operations environment_variables: Environment variables to inject into the sandbox. Merges with and overrides matching keys from the session defaults. @@ -875,6 +1246,15 @@ def __init__( effective_network = network if network is not None else self._defaults.network if effective_network is not None: self._start_kwargs["network"] = effective_network + # Use explicit file-system snapshot mount or fall back to defaults. + effective_fss = ( + file_system_snapshot + if file_system_snapshot is not None + else self._defaults.file_system_snapshot + ) + effective_fss = _coerce_file_system_snapshot(effective_fss) + if effective_fss is not None: + self._start_kwargs["file_system_snapshot"] = effective_fss if max_timeout_seconds is not None: self._start_kwargs["max_timeout_seconds"] = max_timeout_seconds merged_secrets = list(self._defaults.secrets or ()) + [ @@ -924,6 +1304,10 @@ def __init__( self._stop_task: asyncio.Task[None] | None = None self._stop_lock = asyncio.Lock() self._stop_owned: bool = False + # Whether the in-flight shared stop task (when _stop_task is set) was + # created for a snapshot-on-stop. Read under _stop_lock to decide + # whether a later snapshot-on-stop caller can safely join it. + self._stop_snapshot_requested: bool = False # Set when a caller invokes stop(missing_ok=True) on a sandbox that # is already draining (observe-only path). Widens the NOT_FOUND # retry gate in _do_poll_complete so the observe-only waiter treats @@ -939,6 +1323,9 @@ def __init__( self._resource_limits: dict[str, str] | None = None self._resource_requests: dict[str, str] | None = None self._resource_gpu: dict[str, Any] | None = None + # Snapshot ID produced by stop(snapshot_on_stop=True), set when the + # Stop response reports it. None until then. + self._file_system_snapshot_id: str | None = None # Execution statistics for metrics (protected by _exec_stats_lock) self._exec_stats_lock = threading.Lock() @@ -973,6 +1360,7 @@ def run( s3_mount: dict[str, Any] | None = None, ports: list[dict[str, Any]] | None = None, network: NetworkOptions | dict[str, Any] | None = None, + file_system_snapshot: FileSystemSnapshotOptions | dict[str, Any] | None = None, max_timeout_seconds: int | None = None, environment_variables: dict[str, str] | None = None, annotations: dict[str, str] | None = None, @@ -1014,6 +1402,11 @@ def run( s3_mount: S3 bucket mount configuration ports: Port mappings for the sandbox network: Network configuration (NetworkOptions dataclass) + file_system_snapshot: File-system snapshot (FSS) mount configuration. + Accepts a FileSystemSnapshotOptions or a dict with ``mount_path``, + optional ``size``, and optional ``file_system_snapshot_id``. When + ``file_system_snapshot_id`` is set, the snapshot is restored into + ``mount_path`` at start (fork); otherwise the mount starts empty. max_timeout_seconds: Maximum timeout for sandbox operations environment_variables: Environment variables to inject into the sandbox. Merges with and overrides matching keys from the session defaults. @@ -1073,6 +1466,7 @@ def run( s3_mount=s3_mount, ports=ports, network=network, + file_system_snapshot=file_system_snapshot, max_timeout_seconds=max_timeout_seconds, environment_variables=environment_variables, annotations=annotations, @@ -1571,6 +1965,438 @@ async def _delete_async( finally: await channel.close(grace=None) + @classmethod + def get_snapshot( + cls, + file_system_snapshot_id: str, + *, + base_url: str | None = None, + timeout_seconds: float | None = None, + ) -> OperationRef[FileSystemSnapshot]: + """Fetch a file-system snapshot (FSS) record by ID. + + Snapshots are org-scoped: any snapshot owned by your organization is + visible, regardless of which sandbox created it. + + Args: + file_system_snapshot_id: The snapshot ID to fetch. + base_url: Override API URL (default: CWSANDBOX_BASE_URL env or default). + timeout_seconds: Request timeout (default: 300s). + + Returns: + OperationRef[FileSystemSnapshot]: Use .result() to block or await. + Raises SnapshotNotFoundError if the snapshot does not exist. + + Examples: + ```python + snap = Sandbox.get_snapshot("fss-abc123").result() + print(snap.status, snap.size_bytes) + ``` + """ + future = _LoopManager.get().run_async( + cls._get_snapshot_async( + file_system_snapshot_id, base_url=base_url, timeout_seconds=timeout_seconds + ) + ) + return OperationRef(future) + + @classmethod + async def _get_snapshot_async( + cls, + file_system_snapshot_id: str, + *, + base_url: str | None = None, + timeout_seconds: float | None = None, + ) -> FileSystemSnapshot: + """Internal async: fetch a snapshot record by ID.""" + effective_base_url = ( + base_url or os.environ.get("CWSANDBOX_BASE_URL") or DEFAULT_BASE_URL + ).rstrip("/") + timeout = ( + timeout_seconds if timeout_seconds is not None else DEFAULT_REQUEST_TIMEOUT_SECONDS + ) + auth_metadata = resolve_auth_metadata() + target, is_secure = parse_grpc_target(effective_base_url) + channel = create_channel(target, is_secure) + stub = gateway_pb2_grpc.GatewayServiceStub(channel) # type: ignore[no-untyped-call] + try: + return await _retry_transient_rpc( + lambda: _get_snapshot_via_stub( + stub, + file_system_snapshot_id, + auth_metadata=auth_metadata, + timeout=timeout, + ), + budget_seconds=DEFAULT_FSS_RETRY_BUDGET_SECONDS, + operation="Get file-system snapshot", + ) + finally: + await channel.close(grace=None) + + @classmethod + def list_snapshots( + cls, + *, + source_sandbox_id: str | None = None, + status: FileSystemSnapshotStatus | str | None = None, + base_url: str | None = None, + timeout_seconds: float | None = None, + ) -> OperationRef[builtins.list[FileSystemSnapshot]]: + """List file-system snapshots (FSS) for the organization. + + Snapshots are org-scoped and the listing is auto-paginated. The + ``source_sandbox_id`` and ``status`` filters are applied client-side + (the backend list RPC does not filter), so all snapshots are fetched + before filtering. + + Args: + source_sandbox_id: If set, only snapshots captured from this sandbox. + status: If set, only snapshots in this status (FileSystemSnapshotStatus + or its string value). + base_url: Override API URL (default: CWSANDBOX_BASE_URL env or default). + timeout_seconds: Request timeout (default: 300s). + + Returns: + OperationRef[list[FileSystemSnapshot]]: Use .result() to block or await. + + Examples: + ```python + # All ready snapshots from a given sandbox + snaps = Sandbox.list_snapshots( + source_sandbox_id=sb.sandbox_id, + status=FileSystemSnapshotStatus.READY, + ).result() + ``` + """ + future = _LoopManager.get().run_async( + cls._list_snapshots_async( + source_sandbox_id=source_sandbox_id, + status=status, + base_url=base_url, + timeout_seconds=timeout_seconds, + ) + ) + return OperationRef(future) + + @classmethod + async def _list_snapshots_async( + cls, + *, + source_sandbox_id: str | None = None, + status: FileSystemSnapshotStatus | str | None = None, + base_url: str | None = None, + timeout_seconds: float | None = None, + ) -> builtins.list[FileSystemSnapshot]: + """Internal async: list snapshots with optional client-side filters.""" + effective_base_url = ( + base_url or os.environ.get("CWSANDBOX_BASE_URL") or DEFAULT_BASE_URL + ).rstrip("/") + timeout = ( + timeout_seconds if timeout_seconds is not None else DEFAULT_REQUEST_TIMEOUT_SECONDS + ) + status_filter = FileSystemSnapshotStatus(status) if status is not None else None + + auth_metadata = resolve_auth_metadata() + target, is_secure = parse_grpc_target(effective_base_url) + channel = create_channel(target, is_secure) + stub = gateway_pb2_grpc.GatewayServiceStub(channel) # type: ignore[no-untyped-call] + try: + + async def _attempt() -> builtins.list[Any]: + # Build the request inside the attempt: paginate_async mutates + # page_token in place, so a retry must start from a fresh + # request (page 1) rather than resuming from the last token. + request = gateway_pb2.ListFileSystemSnapshotsRequest() + try: + return await paginate_async( + stub.ListFileSystemSnapshots, + request, + "file_system_snapshots", + auth_metadata, + timeout, + operation="List file-system snapshots", + ) + except grpc.RpcError as e: + raise _translate_rpc_error(e, operation="List file-system snapshots") from e + + protos = await _retry_transient_rpc( + _attempt, + budget_seconds=DEFAULT_FSS_RETRY_BUDGET_SECONDS, + operation="List file-system snapshots", + ) + + snapshots = [_snapshot_from_proto(p) for p in protos] + finally: + await channel.close(grace=None) + + if source_sandbox_id is not None: + snapshots = [s for s in snapshots if s.source_sandbox_id == source_sandbox_id] + if status_filter is not None: + snapshots = [s for s in snapshots if s.status == status_filter] + return snapshots + + @classmethod + def delete_snapshot( + cls, + file_system_snapshot_id: str, + *, + base_url: str | None = None, + timeout_seconds: float | None = None, + missing_ok: bool = False, + ) -> OperationRef[None]: + """Delete a file-system snapshot (FSS) by ID. + + Deleting a snapshot does not affect sandboxes already restored from it. + + Args: + file_system_snapshot_id: The snapshot ID to delete. + base_url: Override API URL (default: CWSANDBOX_BASE_URL env or default). + timeout_seconds: Request timeout (default: 300s). + missing_ok: If True, suppress SnapshotNotFoundError when the snapshot + doesn't exist (already deleted). + + Returns: + OperationRef[None]: Use .result() to block or await. + Raises SnapshotNotFoundError if not found (unless missing_ok=True). + + Examples: + ```python + Sandbox.delete_snapshot("fss-abc123").result() + Sandbox.delete_snapshot("fss-abc123", missing_ok=True).result() + ``` + """ + future = _LoopManager.get().run_async( + cls._delete_snapshot_async( + file_system_snapshot_id, + base_url=base_url, + timeout_seconds=timeout_seconds, + missing_ok=missing_ok, + ) + ) + return OperationRef(future) + + @classmethod + async def _delete_snapshot_async( + cls, + file_system_snapshot_id: str, + *, + base_url: str | None = None, + timeout_seconds: float | None = None, + missing_ok: bool = False, + ) -> None: + """Internal async: delete a snapshot by ID.""" + effective_base_url = ( + base_url or os.environ.get("CWSANDBOX_BASE_URL") or DEFAULT_BASE_URL + ).rstrip("/") + timeout = ( + timeout_seconds if timeout_seconds is not None else DEFAULT_REQUEST_TIMEOUT_SECONDS + ) + auth_metadata = resolve_auth_metadata() + target, is_secure = parse_grpc_target(effective_base_url) + channel = create_channel(target, is_secure) + stub = gateway_pb2_grpc.GatewayServiceStub(channel) # type: ignore[no-untyped-call] + try: + request = gateway_pb2.DeleteFileSystemSnapshotRequest( + file_system_snapshot_id=file_system_snapshot_id + ) + attempts = {"n": 0} + + async def _attempt() -> None: + attempts["n"] += 1 + try: + response = await stub.DeleteFileSystemSnapshot( + request, timeout=timeout, metadata=auth_metadata + ) + except grpc.RpcError as e: + parsed = parse_error_info(e) + # NOT_FOUND is success when missing_ok, or on a retry: an + # earlier attempt likely committed the delete before its + # response was lost to a transient failure. For DELETE the + # postcondition (snapshot gone) is satisfied either way. + if is_not_found(e, parsed, CWSANDBOX_FSS_NOT_FOUND) and ( + missing_ok or attempts["n"] > 1 + ): + return + raise _translate_rpc_error( + e, + operation="Delete file-system snapshot", + file_system_snapshot_id=file_system_snapshot_id, + ) from e + if not response.success: + raise SandboxSnapshotError( + f"Failed to delete file-system snapshot: " + f"{response.error_message or 'unknown error'}", + file_system_snapshot_id=file_system_snapshot_id, + ) + + await _retry_transient_rpc( + _attempt, + budget_seconds=DEFAULT_FSS_RETRY_BUDGET_SECONDS, + operation="Delete file-system snapshot", + ) + finally: + await channel.close(grace=None) + + @classmethod + def get_snapshot_bucket_config( + cls, + *, + base_url: str | None = None, + timeout_seconds: float | None = None, + ) -> OperationRef[FileSystemSnapshotBucketConfig]: + """Fetch the organization's FSS object-storage bucket configuration. + + Args: + base_url: Override API URL (default: CWSANDBOX_BASE_URL env or default). + timeout_seconds: Request timeout (default: 300s). + + Returns: + OperationRef[FileSystemSnapshotBucketConfig]: Use .result() or await. + + Examples: + ```python + cfg = Sandbox.get_snapshot_bucket_config().result() + print(cfg.mode, cfg.effective_bucket_name) + ``` + """ + future = _LoopManager.get().run_async( + cls._get_snapshot_bucket_config_async( + base_url=base_url, timeout_seconds=timeout_seconds + ) + ) + return OperationRef(future) + + @classmethod + async def _get_snapshot_bucket_config_async( + cls, + *, + base_url: str | None = None, + timeout_seconds: float | None = None, + ) -> FileSystemSnapshotBucketConfig: + """Internal async: fetch the org's FSS bucket configuration.""" + effective_base_url = ( + base_url or os.environ.get("CWSANDBOX_BASE_URL") or DEFAULT_BASE_URL + ).rstrip("/") + timeout = ( + timeout_seconds if timeout_seconds is not None else DEFAULT_REQUEST_TIMEOUT_SECONDS + ) + auth_metadata = resolve_auth_metadata() + target, is_secure = parse_grpc_target(effective_base_url) + channel = create_channel(target, is_secure) + stub = gateway_pb2_grpc.GatewayServiceStub(channel) # type: ignore[no-untyped-call] + try: + request = gateway_pb2.GetFileSystemSnapshotBucketConfigRequest() + + async def _attempt() -> FileSystemSnapshotBucketConfig: + try: + proto = await stub.GetFileSystemSnapshotBucketConfig( + request, timeout=timeout, metadata=auth_metadata + ) + except grpc.RpcError as e: + raise _translate_rpc_error( + e, operation="Get file-system snapshot bucket config" + ) from e + return _bucket_config_from_proto(proto) + + return await _retry_transient_rpc( + _attempt, + budget_seconds=DEFAULT_FSS_RETRY_BUDGET_SECONDS, + operation="Get file-system snapshot bucket config", + ) + finally: + await channel.close(grace=None) + + @classmethod + def set_snapshot_bucket_config( + cls, + *, + bucket_name: str, + region: str = "", + base_url: str | None = None, + timeout_seconds: float | None = None, + ) -> OperationRef[FileSystemSnapshotBucketConfig]: + """Set the organization's FSS object-storage bucket configuration. + + Provide a ``bucket_name`` to use a bring-your-own bucket; pass an empty + string to revert to the CoreWeave-managed bucket. This is an + admin-gated operation. + + Args: + bucket_name: Bucket to archive snapshots to. Empty string reverts to + the CoreWeave-managed bucket. + region: Bucket region (required by some providers for BYO buckets). + base_url: Override API URL (default: CWSANDBOX_BASE_URL env or default). + timeout_seconds: Request timeout (default: 300s). + + Returns: + OperationRef[FileSystemSnapshotBucketConfig]: The updated config. + + Examples: + ```python + # Bring-your-own bucket + Sandbox.set_snapshot_bucket_config( + bucket_name="my-org-fss", region="us-east-1" + ).result() + + # Revert to CoreWeave-managed + Sandbox.set_snapshot_bucket_config(bucket_name="").result() + ``` + """ + future = _LoopManager.get().run_async( + cls._set_snapshot_bucket_config_async( + bucket_name=bucket_name, + region=region, + base_url=base_url, + timeout_seconds=timeout_seconds, + ) + ) + return OperationRef(future) + + @classmethod + async def _set_snapshot_bucket_config_async( + cls, + *, + bucket_name: str, + region: str = "", + base_url: str | None = None, + timeout_seconds: float | None = None, + ) -> FileSystemSnapshotBucketConfig: + """Internal async: set the org's FSS bucket configuration.""" + effective_base_url = ( + base_url or os.environ.get("CWSANDBOX_BASE_URL") or DEFAULT_BASE_URL + ).rstrip("/") + timeout = ( + timeout_seconds if timeout_seconds is not None else DEFAULT_REQUEST_TIMEOUT_SECONDS + ) + auth_metadata = resolve_auth_metadata() + target, is_secure = parse_grpc_target(effective_base_url) + channel = create_channel(target, is_secure) + stub = gateway_pb2_grpc.GatewayServiceStub(channel) # type: ignore[no-untyped-call] + try: + request = gateway_pb2.SetFileSystemSnapshotBucketConfigRequest( + bucket_name=bucket_name, + region=region, + ) + + async def _attempt() -> FileSystemSnapshotBucketConfig: + try: + proto = await stub.SetFileSystemSnapshotBucketConfig( + request, timeout=timeout, metadata=auth_metadata + ) + except grpc.RpcError as e: + raise _translate_rpc_error( + e, operation="Set file-system snapshot bucket config" + ) from e + return _bucket_config_from_proto(proto) + + return await _retry_transient_rpc( + _attempt, + budget_seconds=DEFAULT_FSS_RETRY_BUDGET_SECONDS, + operation="Set file-system snapshot bucket config", + ) + finally: + await channel.close(grace=None) + @property def sandbox_id(self) -> str | None: """The unique sandbox ID, or None if not yet started.""" @@ -1704,6 +2530,17 @@ def resource_gpu(self) -> dict[str, Any] | None: """GPU config confirmed by the start response, or None for discovered sandboxes.""" return self._resource_gpu + @property + def file_system_snapshot_id(self) -> str | None: + """ID of the snapshot produced by ``stop(snapshot_on_stop=True)``. + + Populated once the stop OperationRef resolves and the backend reported a + snapshot ID. None when no snapshot-on-stop was requested (or it produced + none). Use ``snapshot()`` for mid-life snapshots, which return the record + directly. + """ + return self._file_system_snapshot_id + @property def exec_stats(self) -> dict[str, int]: """Execution statistics for this sandbox. @@ -2387,6 +3224,11 @@ async def _start_async(self) -> str: for store, mappings in grouped.items() ] + # Convert FileSystemSnapshotOptions to the proto file_system mount. + fss_opts = request_kwargs.pop("file_system_snapshot", None) + if fss_opts is not None: + request_kwargs["file_system"] = _file_system_mount_kwargs(fss_opts) + logger.debug("Starting sandbox with image %s", self._container_image) request = gateway_pb2.StartSandboxRequest(**request_kwargs) @@ -2882,11 +3724,14 @@ async def _do_stop( snapshot_on_stop: bool = False, graceful_shutdown_seconds: float = DEFAULT_GRACEFUL_SHUTDOWN_SECONDS, missing_ok: bool = False, + wait_for_ready: bool = True, + idempotency_key: str | None = None, ) -> None: """Body of the shared _stop_task: send Stop RPC, poll to terminal, cleanup. - Only the first caller's parameters (snapshot_on_stop, graceful_shutdown_seconds) - are used. Later stop() calls join the existing task. + Only the first caller's parameters (snapshot_on_stop, + graceful_shutdown_seconds, wait_for_ready, idempotency_key) are used. + Later stop() calls join the existing task. """ sent_rpc = False @@ -2917,21 +3762,50 @@ async def _do_stop( await self._ensure_client() assert self._stub is not None - max_timeout = int(graceful_shutdown_seconds) + int( - DEFAULT_CLIENT_TIMEOUT_BUFFER_SECONDS - ) + # The backend runs a snapshot-on-stop in two sequential phases: + # it archives the mount (bounded by max_timeout_seconds, the FSS + # default) and THEN deletes the pod (bounded by + # graceful_shutdown_seconds). max_timeout_seconds bounds only the + # archive; the client deadline must cover BOTH phases, so it is + # the sum (see client_deadline below) — not max_timeout alone. + # A plain stop has no archive phase and stays bounded by graceful + # shutdown. + if snapshot_on_stop: + max_timeout = int(DEFAULT_FSS_STOP_TIMEOUT_SECONDS) + # The backend substitutes its own grace default when 0 is + # sent, so budget that rather than zero. + effective_grace = ( + int(graceful_shutdown_seconds) + if int(graceful_shutdown_seconds) > 0 + else int(DEFAULT_FSS_STOP_GRACE_FALLBACK_SECONDS) + ) + client_deadline = ( + max_timeout + effective_grace + int(DEFAULT_FSS_STOP_CLIENT_SLACK_SECONDS) + ) + else: + max_timeout = int(graceful_shutdown_seconds) + int( + DEFAULT_CLIENT_TIMEOUT_BUFFER_SECONDS + ) + client_deadline = max_timeout + int(DEFAULT_CLIENT_TIMEOUT_BUFFER_SECONDS) + # The renamed proto field is file_system_snapshot_on_stop; + # wait_for_ready/idempotency_key are only valid alongside it, + # so only send them when a snapshot is requested. request = gateway_pb2.StopSandboxRequest( sandbox_id=sandbox_id, graceful_shutdown_seconds=int(graceful_shutdown_seconds), - snapshot_on_stop=snapshot_on_stop, + file_system_snapshot_on_stop=snapshot_on_stop, max_timeout_seconds=max_timeout, ) + if snapshot_on_stop: + request.wait_for_ready = wait_for_ready + if idempotency_key: + request.idempotency_key = idempotency_key # Send Stop RPC first, then update state on success try: response = await self._stub.Stop( request, - timeout=max_timeout + DEFAULT_CLIENT_TIMEOUT_BUFFER_SECONDS, + timeout=client_deadline, metadata=self._auth_metadata, ) except grpc.RpcError as e: @@ -2963,6 +3837,11 @@ async def _do_stop( error_msg = response.error_message or "unknown error" raise SandboxError(f"Failed to stop sandbox: {error_msg}") + # Capture the snapshot ID produced by snapshot-on-stop, if any. + # Only populated when file_system_snapshot_on_stop was accepted. + if response.file_system_snapshot_id: + self._file_system_snapshot_id = response.file_system_snapshot_id + # RPC succeeded: transition to _Stopping self._state = _Stopping( sandbox_id=sandbox_id, @@ -2998,18 +3877,66 @@ def _on_stop_task_done(self, task: asyncio.Task[None]) -> None: if self._stop_task is task: self._stop_task = None + def _reject_unsatisfiable_snapshot_on_stop(self) -> None: + """Raise if a ``snapshot_on_stop=True`` request cannot be honored. + + Must be called while holding ``_stop_lock``. ``stop()`` coalesces + callers onto one shared stop task, so a snapshot-on-stop is honorable + only when this caller will own a fresh stop (creating the task) or when + it joins a stop that is itself a snapshot-on-stop. Every other state + means the sandbox is, or is about to be, torn down without archiving + the mount — joining would silently drop the snapshot. A sandbox that + was never started has no mount to archive, so it is left to the normal + no-op path rather than raising here. + """ + # Already terminal: a snapshot captured by a prior snapshot-on-stop + # makes this idempotently satisfiable; otherwise the sandbox is gone + # with no archive. + if self._is_done: + if self._file_system_snapshot_id is not None: + return + if self._is_cancelled: + return + raise SnapshotOnStopConflictError( + "Cannot snapshot on stop: sandbox has already stopped." + ) + # A stop this caller would join is already in flight. Joining is safe + # only when that stop is itself capturing a snapshot. + if self._stop_task is not None and not self._stop_task.done(): + if self._stop_snapshot_requested: + return + raise SnapshotOnStopConflictError( + "Cannot snapshot on stop: a plain stop is already in progress for this sandbox." + ) + # Draining (TERMINATING) with no stop task we own: the backend is + # already tearing the sandbox down (external stop, TTL, or a + # poller-observed termination), so no snapshot RPC will be sent. + if self._is_stopping: + raise SnapshotOnStopConflictError( + "Cannot snapshot on stop: sandbox is already terminating." + ) + async def _stop_async( self, *, snapshot_on_stop: bool = False, graceful_shutdown_seconds: float = DEFAULT_GRACEFUL_SHUTDOWN_SECONDS, missing_ok: bool = False, + wait_for_ready: bool = True, + idempotency_key: str | None = None, ) -> None: """Internal async: Stop the sandbox using shared _stop_task pattern. First caller creates the task; later callers join it. """ async with self._stop_lock: + # A snapshot-on-stop request cannot be honored by joining (or + # observing) a stop that will not archive the mount. Detect that + # here and raise rather than silently coalescing into a no-snapshot + # stop, which would destroy the sandbox with no archive and still + # return success. Plain stops keep coalescing in every case. + if snapshot_on_stop: + self._reject_unsatisfiable_snapshot_on_stop() if self._is_done: logger.debug("stop() called on already-stopped sandbox %s", self._sandbox_id) return @@ -3018,11 +3945,14 @@ async def _stop_async( self._state = _NotStarted(cancelled=True) return if self._stop_task is None: + self._stop_snapshot_requested = snapshot_on_stop self._stop_task = asyncio.create_task( self._do_stop( snapshot_on_stop=snapshot_on_stop, graceful_shutdown_seconds=graceful_shutdown_seconds, missing_ok=missing_ok, + wait_for_ready=wait_for_ready, + idempotency_key=idempotency_key, ) ) self._stop_task.add_done_callback(self._on_stop_task_done) @@ -3052,6 +3982,8 @@ def stop( snapshot_on_stop: bool = False, graceful_shutdown_seconds: float = DEFAULT_GRACEFUL_SHUTDOWN_SECONDS, missing_ok: bool = False, + wait_for_ready: bool = True, + idempotency_key: str | None = None, ) -> OperationRef[None]: """Stop sandbox, return OperationRef immediately. @@ -3061,16 +3993,39 @@ def stop( just when the stop RPC succeeds. Multiple callers share the same underlying stop task: the first caller - creates it, subsequent callers join it. + creates it, subsequent callers join it. A ``snapshot_on_stop=True`` + request that would join (or observe) a stop that is not capturing a + snapshot — because the sandbox is already stopping, already stopped, or + a plain ``stop()`` is already in flight — raises + ``SnapshotOnStopConflictError`` instead of silently completing without + an archive. Plain stops always coalesce. The sandbox is deregistered from its session regardless of whether the stop was successful, since the sandbox is no longer usable. Args: - snapshot_on_stop: If True, capture sandbox state before shutdown. - graceful_shutdown_seconds: Time to wait for graceful shutdown. + snapshot_on_stop: If True, capture a file-system snapshot (FSS) of + the configured mount before shutdown. The resulting snapshot ID + is available via the ``file_system_snapshot_id`` property after + the returned OperationRef resolves. Requires the sandbox to have + been started with a ``file_system_snapshot`` mount and the org to + be enabled for FSS. Raises ``SnapshotOnStopConflictError`` if a + stop is already in progress that will not capture a snapshot. + graceful_shutdown_seconds: Time to wait for graceful shutdown. With + ``snapshot_on_stop=True`` this is the post-archive pod-delete + grace, applied *after* the snapshot completes, so the client + deadline covers the archive budget plus this grace. Passing 0 + does not mean "no grace": the backend substitutes its own + default (~30s), and the client deadline budgets that. The + backend caps this at 300s for snapshot-on-stop. missing_ok: If True, suppress SandboxNotFoundError when sandbox doesn't exist. + wait_for_ready: When ``snapshot_on_stop`` is True, block until the + snapshot reaches READY (or FAILED) before the stop completes. + Ignored when ``snapshot_on_stop`` is False. + idempotency_key: Optional client-supplied key to deduplicate the + snapshot-on-stop request on retries. Ignored when + ``snapshot_on_stop`` is False. Returns: OperationRef[None]: Use .result() to block until terminal. @@ -3084,6 +4039,10 @@ def stop( # Ignore if already deleted sb.stop(missing_ok=True).result() + # Snapshot the configured mount on stop, then read the ID + sb.stop(snapshot_on_stop=True).result() + file_system_snapshot_id = sb.file_system_snapshot_id + # wait_until_complete() after stop() resolves when terminal sb.stop() sb.wait_until_complete().result() # Polls through TERMINATING @@ -3094,6 +4053,115 @@ def stop( snapshot_on_stop=snapshot_on_stop, graceful_shutdown_seconds=graceful_shutdown_seconds, missing_ok=missing_ok, + wait_for_ready=wait_for_ready, + idempotency_key=idempotency_key, + ) + ) + return OperationRef(future) + + async def _snapshot_async( + self, + *, + wait_for_ready: bool, + idempotency_key: str | None, + ) -> str: + """Internal async: create a mid-life snapshot and return its ID.""" + await self._ensure_started_async() + # Snapshotting requires a running sandbox (the backend archives the live + # mount), so wait for RUNNING like exec/read_file/write_file do; calling + # on a just-started sandbox would otherwise race startup. + await self._wait_until_running_async() + await self._ensure_client() + assert self._stub is not None + assert self._sandbox_id is not None + stub = self._stub + sandbox_id = self._sandbox_id + + # wait_for_ready blocks on the runner archive, so the client deadline + # must be generous; otherwise the create RPC returns promptly. + create_timeout = ( + DEFAULT_FSS_STOP_TIMEOUT_SECONDS + DEFAULT_CLIENT_TIMEOUT_BUFFER_SECONDS + if wait_for_ready + else self._request_timeout_seconds + ) + # When blocking on the archive, also bound the server-side wait (mirror + # snapshot-on-stop) so the backend's own default request ceiling cannot + # cut a large snapshot short before the client's FSS deadline. + create_max_timeout = int(DEFAULT_FSS_STOP_TIMEOUT_SECONDS) if wait_for_ready else None + # Generate an idempotency key when the caller didn't supply one so a + # retried create (after a transient failure that may have already + # committed server-side) dedups instead of creating a second snapshot. + effective_idempotency_key = idempotency_key or uuid.uuid4().hex + return await _retry_transient_rpc( + lambda: _create_snapshot_via_stub( + stub, + sandbox_id, + idempotency_key=effective_idempotency_key, + wait_for_ready=wait_for_ready, + auth_metadata=self._auth_metadata, + timeout=create_timeout, + max_timeout_seconds=create_max_timeout, + ), + budget_seconds=DEFAULT_FSS_RETRY_BUDGET_SECONDS, + operation="Create file-system snapshot", + # The create timeout is the snapshot's ceiling (it blocks on the + # archive), so a client DEADLINE_EXCEEDED is the ceiling being hit, + # not a transient blip. Treat it as terminal — retrying would run a + # second full-length attempt and overrun the ceiling (~2x). Genuine + # transients (unavailability/resource-exhaustion/throttle) still + # retry. The snapshot may still finish server-side; the caller can + # poll get_snapshot(). + non_retryable=(SandboxRequestTimeoutError,), + ) + + def snapshot( + self, + *, + wait_for_ready: bool = True, + idempotency_key: str | None = None, + ) -> OperationRef[str]: + """Capture a file-system snapshot (FSS) of the configured mount. + + Snapshots the directory configured via ``file_system_snapshot`` on the + running sandbox, without stopping it. Starts the sandbox first if it has + not been started. Restore the snapshot into a new sandbox via + ``Sandbox.run(file_system_snapshot=FileSystemSnapshotOptions(..., + file_system_snapshot_id=))``. + + Requires the sandbox to have been started with a ``file_system_snapshot`` + mount and the organization to be enabled for FSS. + + Args: + wait_for_ready: Block until the snapshot reaches READY (or FAILED) + before returning. When False, returns once the snapshot is + created (likely still CREATING). + idempotency_key: Optional client-supplied key to deduplicate the + request on retries. + + Returns: + OperationRef[str]: Use .result() to block (or await) for the new + snapshot's ID. Call ``Sandbox.get_snapshot(id)`` for the full record + (status, size, timestamps). + + Raises: + SandboxSnapshotError: If the snapshot fails (see subclasses for + ``NOT_SUPPORTED`` when the org is not enabled, quota/size, etc.). + + Examples: + ```python + with Sandbox.run( + file_system_snapshot=FileSystemSnapshotOptions(mount_path="/workspace"), + ) as sb: + sb.exec(["sh", "-c", "echo hi > /workspace/note.txt"]).result() + snapshot_id = sb.snapshot().result() + # Inspect the full record if needed: + snap = Sandbox.get_snapshot(snapshot_id).result() + ``` + """ + future = self._loop_manager.run_async( + self._snapshot_async( + wait_for_ready=wait_for_ready, + idempotency_key=idempotency_key, ) ) return OperationRef(future) diff --git a/src/cwsandbox/_session.py b/src/cwsandbox/_session.py index 73e8a51..5bbbf8f 100644 --- a/src/cwsandbox/_session.py +++ b/src/cwsandbox/_session.py @@ -15,6 +15,7 @@ from cwsandbox._loop_manager import _LoopManager from cwsandbox._types import ( ExecOutcome, + FileSystemSnapshotOptions, NetworkOptions, OperationRef, ResourceOptions, @@ -330,6 +331,7 @@ def sandbox( s3_mount: dict[str, Any] | None = None, ports: list[dict[str, Any]] | None = None, network: NetworkOptions | dict[str, Any] | None = None, + file_system_snapshot: FileSystemSnapshotOptions | dict[str, Any] | None = None, max_timeout_seconds: int | None = None, environment_variables: dict[str, str] | None = None, annotations: dict[str, str] | None = None, @@ -362,6 +364,9 @@ def sandbox( s3_mount: S3 bucket mount configuration ports: Port mappings for the sandbox network: Network configuration (NetworkOptions dataclass) + file_system_snapshot: File-system snapshot (FSS) mount configuration. + Accepts a FileSystemSnapshotOptions or a dict with ``mount_path``, + optional ``size``, and optional ``file_system_snapshot_id`` (restore on start). max_timeout_seconds: Maximum timeout for sandbox operations environment_variables: Environment variables to inject into the sandbox. Merges with and overrides matching keys from the session defaults. @@ -421,6 +426,7 @@ def sandbox( s3_mount=s3_mount, ports=ports, network=network, + file_system_snapshot=file_system_snapshot, max_timeout_seconds=max_timeout_seconds, environment_variables=environment_variables, annotations=annotations, @@ -657,6 +663,7 @@ def function( s3_mount: dict[str, Any] | None = None, ports: Sequence[dict[str, Any]] | None = None, network: NetworkOptions | dict[str, Any] | None = None, + file_system_snapshot: FileSystemSnapshotOptions | dict[str, Any] | None = None, max_timeout_seconds: int | None = None, environment_variables: dict[str, str] | None = None, annotations: dict[str, str] | None = None, @@ -688,6 +695,9 @@ def function( s3_mount: S3 bucket mount configuration ports: Port mappings for the sandbox network: Network configuration (NetworkOptions dataclass) + file_system_snapshot: File-system snapshot (FSS) mount configuration. + Accepts a FileSystemSnapshotOptions or a dict with ``mount_path``, + optional ``size``, and optional ``file_system_snapshot_id`` (restore on start). max_timeout_seconds: Maximum timeout for sandbox operations environment_variables: Environment variables to inject into the sandbox. Merges with and overrides matching keys from the session defaults. @@ -744,6 +754,7 @@ def decorator(f: Callable[P, R]) -> RemoteFunction[P, R]: s3_mount=s3_mount, ports=list(ports) if ports else None, network=network, + file_system_snapshot=file_system_snapshot, max_timeout_seconds=max_timeout_seconds, environment_variables=environment_variables, annotations=annotations, diff --git a/src/cwsandbox/_types.py b/src/cwsandbox/_types.py index 857bee6..61364d2 100644 --- a/src/cwsandbox/_types.py +++ b/src/cwsandbox/_types.py @@ -9,6 +9,7 @@ import threading from collections.abc import Callable, Generator from dataclasses import dataclass, field +from datetime import datetime from enum import StrEnum from typing import TYPE_CHECKING, Any, Generic, TypeVar @@ -212,6 +213,176 @@ def __post_init__(self) -> None: object.__setattr__(self, "gpu", None) +class FileSystemSnapshotStatus(StrEnum): + """Lifecycle status of a file-system snapshot (FSS). + + Lifecycle: CREATING -> READY | FAILED. DELETING is reported while a + snapshot is being removed. + + Attributes: + UNSPECIFIED: Status not reported by the backend. + CREATING: Snapshot archive is being captured. + READY: Snapshot is complete and available for restore. + FAILED: Snapshot capture failed (see ``status_reason``). + DELETING: Snapshot is being deleted. + """ + + UNSPECIFIED = "unspecified" + CREATING = "creating" + READY = "ready" + FAILED = "failed" + DELETING = "deleting" + + +class FileSystemSnapshotTrigger(StrEnum): + """What triggered the creation of a file-system snapshot. + + Attributes: + UNSPECIFIED: Trigger not reported by the backend. + STOP: Captured during ``stop(snapshot_on_stop=True)``. + MANUAL: Captured via ``snapshot()`` (CreateFileSystemSnapshot). + """ + + UNSPECIFIED = "unspecified" + STOP = "stop" + MANUAL = "manual" + + +class FileSystemSnapshotBucketMode(StrEnum): + """Object-storage bucket ownership mode for FSS archives. + + Attributes: + UNSPECIFIED: Mode not reported by the backend. + CW_MANAGED: Snapshots are archived to a CoreWeave-managed bucket. + BRING_YOUR_OWN: Snapshots are archived to a customer-owned bucket. + """ + + UNSPECIFIED = "unspecified" + CW_MANAGED = "cw_managed" + BRING_YOUR_OWN = "bring_your_own" + + +@dataclass(frozen=True, kw_only=True) +class FileSystemSnapshotOptions: + """Configuration for the sandbox file-system mount used by snapshots. + + Passed to ``Sandbox.run(file_system_snapshot=...)`` (and + ``Session.sandbox`` / ``@session.function``). The mount at ``mount_path`` + is the directory captured by ``snapshot()`` and + ``stop(snapshot_on_stop=True)``, and the directory restored into when + ``file_system_snapshot_id`` is set. Plain dicts with matching keys are accepted and + converted automatically. + + Attributes: + mount_path: Absolute directory to mount (e.g. "/workspace"). This is + the directory captured and restored by FSS. Reserved system paths + (e.g. /proc, /sys, /dev, /etc) are rejected by the backend. + size: Mount size as a Kubernetes resource quantity (e.g. "10Gi"). + None lets the backend choose a default. + file_system_snapshot_id: When set, restore this snapshot's contents into + ``mount_path`` at start (fork). When None, the mount starts empty. + + Examples: + Fresh scratch mount:: + + FileSystemSnapshotOptions(mount_path="/workspace", size="10Gi") + + Restore from an existing snapshot:: + + FileSystemSnapshotOptions(mount_path="/workspace", file_system_snapshot_id="fss-...") + """ + + mount_path: str + size: str | None = None + file_system_snapshot_id: str | None = None + + def __post_init__(self) -> None: + if not self.mount_path: + raise ValueError("FileSystemSnapshotOptions.mount_path cannot be empty") + if not self.mount_path.startswith("/"): + raise ValueError( + f"FileSystemSnapshotOptions.mount_path must be an absolute path, " + f"got: {self.mount_path!r}" + ) + if self.mount_path == "/": + raise ValueError("FileSystemSnapshotOptions.mount_path cannot be '/'") + # Normalize empty optional strings to None. + if self.size is not None and not self.size: + object.__setattr__(self, "size", None) + if self.file_system_snapshot_id is not None and not self.file_system_snapshot_id: + object.__setattr__(self, "file_system_snapshot_id", None) + + +@dataclass(frozen=True, kw_only=True) +class FileSystemSnapshot: + """An immutable, org-scoped file-system snapshot record. + + Returned by ``Sandbox.get_snapshot()`` and ``Sandbox.list_snapshots()`` + (``Sandbox.snapshot()`` returns just the snapshot ID). To restore, pass the + ``file_system_snapshot_id`` to a ``FileSystemSnapshotOptions`` on + ``Sandbox.run(file_system_snapshot=...)``. + + Attributes: + file_system_snapshot_id: Unique snapshot identifier. + status: Current lifecycle status. + status_reason: Human-readable detail, typically set for FAILED snapshots. + size_bytes: Archive size in bytes (0 until READY). + source_sandbox_id: The sandbox the snapshot was captured from. + trigger: Whether the snapshot was taken on STOP or via a MANUAL request. + idempotency_key: Client-supplied idempotency key, if any. + object_bucket: The object-storage bucket the archive landed in. + created_at: When the snapshot record was created (UTC), or None. + updated_at: When the snapshot record was last updated (UTC), or None. + completed_at: When the snapshot reached a terminal status (UTC), or None. + """ + + file_system_snapshot_id: str + status: FileSystemSnapshotStatus + status_reason: str = "" + size_bytes: int = 0 + source_sandbox_id: str = "" + trigger: FileSystemSnapshotTrigger = FileSystemSnapshotTrigger.UNSPECIFIED + idempotency_key: str = "" + object_bucket: str = "" + created_at: datetime | None = None + updated_at: datetime | None = None + completed_at: datetime | None = None + + def __repr__(self) -> str: + parts = [ + f"file_system_snapshot_id={self.file_system_snapshot_id!r}", + f"status={self.status.value}", + ] + if self.size_bytes: + parts.append(f"size_bytes={self.size_bytes}") + if self.source_sandbox_id: + parts.append(f"source_sandbox_id={self.source_sandbox_id!r}") + if self.status_reason: + parts.append(f"status_reason={self.status_reason!r}") + return f"FileSystemSnapshot({', '.join(parts)})" + + +@dataclass(frozen=True, kw_only=True) +class FileSystemSnapshotBucketConfig: + """Per-organization object-storage bucket configuration for FSS archives. + + Returned by ``Sandbox.get_snapshot_bucket_config()`` and + ``Sandbox.set_snapshot_bucket_config()``. + + Attributes: + mode: Bucket ownership mode (CW-managed or bring-your-own). + bucket_name: The configured bucket name (empty when CW-managed). + region: The configured bucket region (empty when CW-managed). + effective_bucket_name: The bucket snapshots are actually archived to, + resolved server-side from ``mode`` and ``bucket_name``. + """ + + mode: FileSystemSnapshotBucketMode = FileSystemSnapshotBucketMode.UNSPECIFIED + bucket_name: str = "" + region: str = "" + effective_bucket_name: str = "" + + @dataclass class ProcessResult: """Result from a completed streaming exec operation. diff --git a/src/cwsandbox/exceptions.py b/src/cwsandbox/exceptions.py index fdd26e1..3e6e9f5 100644 --- a/src/cwsandbox/exceptions.py +++ b/src/cwsandbox/exceptions.py @@ -317,6 +317,110 @@ class SandboxTerminalStateUnavailableError(SandboxError): """ +class SandboxSnapshotError(SandboxError): + """Base exception for file-system snapshot (FSS) operation failures. + + Raised by ``snapshot()``, snapshot restore on ``run()``, + ``stop(snapshot_on_stop=True)``, and the snapshot management methods. + Also used directly for terminal internal failures (restore/create + failed, bucket unavailable, auth/transport failures). + + Attributes: + file_system_snapshot_id: The file-system snapshot ID involved, when known. + """ + + def __init__( + self, + message: str, + *, + file_system_snapshot_id: str | None = None, + reason: str | None = None, + metadata: Mapping[str, str] | None = None, + retry_delay: timedelta | None = None, + ) -> None: + super().__init__(message, reason=reason, metadata=metadata, retry_delay=retry_delay) + self.file_system_snapshot_id = file_system_snapshot_id + + +class SnapshotNotFoundError(SandboxSnapshotError): + """Raised when a snapshot ID is unknown, deleted, or owned by another org. + + Emitted for ``CWSANDBOX_FSS_NOT_FOUND``. + """ + + +class SnapshotNotReadyError(SandboxSnapshotError): + """Raised when a snapshot is not in the READY state for the operation. + + Emitted for ``CWSANDBOX_FSS_NOT_READY`` (e.g. restoring from a snapshot + that is still CREATING or has FAILED). + """ + + +class SnapshotNotSupportedError(SandboxSnapshotError): + """Raised when file-system snapshots are not enabled for the organization. + + Emitted for ``CWSANDBOX_FSS_NOT_SUPPORTED``. FSS is gated by a per-org + allowlist on the backend; an org not on the list cannot create, restore, + or manage snapshots. + """ + + +class SnapshotSizeExceededError(SandboxSnapshotError): + """Raised when the requested mount size exceeds the configured maximum. + + Emitted for ``CWSANDBOX_FSS_SIZE_EXCEEDED``. + """ + + +class SnapshotQuotaExceededError(SandboxSnapshotError): + """Raised when the organization's snapshot quota is exhausted. + + Emitted for ``CWSANDBOX_FSS_QUOTA_EXCEEDED``. + """ + + +class SnapshotBucketMismatchError(SandboxSnapshotError): + """Raised when a snapshot's bucket differs from the org's current bucket. + + Emitted for ``CWSANDBOX_FSS_BUCKET_MISMATCH``. Reversible by reverting the + org's snapshot bucket configuration to the one the snapshot was written to. + """ + + +class SnapshotWaitTimeoutError(SandboxTimeoutError): + """Raised when ``wait_for_ready`` exceeded its budget before the snapshot was READY. + + Emitted for ``CWSANDBOX_FSS_WAIT_TIMEOUT``. The snapshot may still complete + server-side; poll with ``Sandbox.get_snapshot()`` to check. + """ + + +class SnapshotBackendThrottledError(SandboxUnavailableError): + """Raised when the snapshot backend is transiently throttled or at capacity. + + Emitted for ``CWSANDBOX_FSS_BACKEND_THROTTLED`` and + ``CWSANDBOX_FSS_INFLIGHT_LIMIT`` (gRPC ``UNAVAILABLE``). Inherits the + retryable contract of ``SandboxUnavailableError``; callers should back off. + """ + + +class SnapshotOnStopConflictError(SandboxSnapshotError): + """Raised when ``stop(snapshot_on_stop=True)`` cannot capture a snapshot. + + Unlike the other snapshot errors this is raised client-side, not mapped + from a backend reason. ``stop()`` coalesces concurrent callers onto a + single shared stop, so a snapshot-on-stop request that arrives once the + sandbox is already stopping, already stopped, or already has a plain + ``stop()`` in flight cannot be honored: that stop will tear the sandbox + down without archiving the mount. Rather than silently coalescing the + request into a stop that produces no snapshot (destroying the sandbox with + no archive and no error), the client raises this. Mirrors the backend's + ``FailedPrecondition`` for a snapshot-on-stop that arrives after the + sandbox has begun terminating. + """ + + class DiscoveryError(CWSandboxError): """Base exception for discovery operations (runners, profiles). diff --git a/tests/AGENTS.md b/tests/AGENTS.md index bf30850..a7cf750 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -141,6 +141,7 @@ Any test path that may create a sandbox - directly via `Sandbox.run()`, indirect | `test_cleanup.py` | atexit handlers, signal handlers, re-entrancy guard | | `test_defaults.py` | SandboxDefaults configuration, merge_tags, with_overrides | | `test_exceptions.py` | Exception hierarchy, custom attributes | +| `test_file_system_snapshot.py` | FSS types, error mapping, start/stop wiring, snapshot/fork/management methods | | `test_function.py` | RemoteFunction class, decorator, .remote(), .map(), .local() | | `test_loop_manager.py` | _LoopManager singleton, run_sync, run_async | | `test_sandbox.py` | Sandbox class, status handling, exec, file ops | @@ -155,6 +156,7 @@ Any test path that may create a sandbox - directly via `Sandbox.run()`, indirect |------|----------| | `test_sandbox.py` | Sandbox lifecycle, file ops, exec. Uses `require_auth`. | | `test_session.py` | Session management, function execution. Uses `require_auth`. | +| `test_file_system_snapshot.py` | FSS snapshot/fork/restore/management. Uses `require_auth`; skips gracefully on `SnapshotNotSupportedError` when the org is not FSS-enabled. | | `test_wandb.py` | W&B metrics logging. Uses `require_auth`; live checks also need `WANDB_API_KEY`. | ## Parallel Execution diff --git a/tests/integration/cwsandbox/test_file_system_snapshot.py b/tests/integration/cwsandbox/test_file_system_snapshot.py new file mode 100644 index 0000000..acf69b6 --- /dev/null +++ b/tests/integration/cwsandbox/test_file_system_snapshot.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2025 CoreWeave, Inc. +# SPDX-License-Identifier: Apache-2.0 +# SPDX-PackageName: cwsandbox-client + +"""Integration tests for file-system snapshots (FSS). + +These tests require a running CWSandbox backend AND an organization that is +enabled for FSS (the feature is gated by a per-org allowlist). When the org is +not enabled, the backend returns ``CWSANDBOX_FSS_NOT_SUPPORTED`` and these tests +skip rather than fail — so they only error when the FSS logic is actually +exercised. + +Set CWSANDBOX_BASE_URL and CWSANDBOX_API_KEY before running. +""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager + +import pytest + +from cwsandbox import ( + FileSystemSnapshotOptions, + FileSystemSnapshotStatus, + Sandbox, + SandboxDefaults, +) +from cwsandbox.exceptions import SnapshotNotFoundError, SnapshotNotSupportedError + +MOUNT_PATH = "/workspace" + + +@contextmanager +def skip_if_fss_unsupported() -> Generator[None, None, None]: + """Skip the test if the org is not enabled for FSS. + + FSS is gated per-organization on the backend; an org not on the allowlist + gets ``SnapshotNotSupportedError``. Wrapping the FSS calls in this context + means the test only fails when FSS is actually enabled and the logic runs. + """ + try: + yield + except SnapshotNotSupportedError: + pytest.skip("Organization is not enabled for file-system snapshots (FSS)") + + +def _fss_options(**overrides: object) -> FileSystemSnapshotOptions: + kwargs: dict[str, object] = {"mount_path": MOUNT_PATH, "size": "1Gi"} + kwargs.update(overrides) + return FileSystemSnapshotOptions(**kwargs) # type: ignore[arg-type] + + +@pytest.fixture +def fss_defaults(sandbox_defaults: SandboxDefaults) -> SandboxDefaults: + """Defaults with a longer lifetime for snapshot workflows. + + The shared ``sandbox_defaults`` uses a 60s lifetime for fast cleanup, but a + snapshot needs the source sandbox to stay RUNNING through start + exec + + archive, which exceeds 60s. Each test still stops its sandboxes promptly + (via the context manager / finally), so the larger lifetime is only a + ceiling, not added runtime. The runner pin and tags are preserved. + """ + return sandbox_defaults.with_overrides(max_lifetime_seconds=600) + + +def test_snapshot_and_restore(fss_defaults: SandboxDefaults) -> None: + """Snapshot a seeded mount, then restore it into a fresh sandbox.""" + created_snapshot_id: str | None = None + with skip_if_fss_unsupported(): + with Sandbox.run( + "sleep", + "infinity", + defaults=fss_defaults, + file_system_snapshot=_fss_options(), + ) as source: + source.exec(["sh", "-c", f"echo restored-content > {MOUNT_PATH}/marker.txt"]).result() + created_snapshot_id = source.snapshot().result() + record = Sandbox.get_snapshot(created_snapshot_id).result() + assert record.status == FileSystemSnapshotStatus.READY + assert record.source_sandbox_id == source.sandbox_id + + try: + with Sandbox.run( + "sleep", + "infinity", + defaults=fss_defaults, + file_system_snapshot=_fss_options(file_system_snapshot_id=created_snapshot_id), + ) as restored: + result = restored.exec(["cat", f"{MOUNT_PATH}/marker.txt"]).result() + assert result.returncode == 0 + assert result.stdout.strip() == "restored-content" + finally: + if created_snapshot_id: + Sandbox.delete_snapshot(created_snapshot_id, missing_ok=True).result() + + +def test_snapshot_on_stop(fss_defaults: SandboxDefaults) -> None: + """stop(snapshot_on_stop=True) captures a snapshot and exposes its ID.""" + created_snapshot_id: str | None = None + with skip_if_fss_unsupported(): + sandbox = Sandbox.run( + "sleep", + "infinity", + defaults=fss_defaults, + file_system_snapshot=_fss_options(), + ) + try: + sandbox.exec(["sh", "-c", f"echo bye > {MOUNT_PATH}/farewell.txt"]).result() + sandbox.stop(snapshot_on_stop=True).result() + created_snapshot_id = sandbox.file_system_snapshot_id + assert created_snapshot_id, "expected a snapshot ID after snapshot-on-stop" + + fetched = Sandbox.get_snapshot(created_snapshot_id).result() + assert fetched.file_system_snapshot_id == created_snapshot_id + assert fetched.status == FileSystemSnapshotStatus.READY + finally: + sandbox.stop(missing_ok=True).result() + if created_snapshot_id: + Sandbox.delete_snapshot(created_snapshot_id, missing_ok=True).result() + + +def test_list_get_delete_snapshot(fss_defaults: SandboxDefaults) -> None: + """Snapshot management: list (filtered), get, and delete.""" + with skip_if_fss_unsupported(): + with Sandbox.run( + "sleep", + "infinity", + defaults=fss_defaults, + file_system_snapshot=_fss_options(), + ) as source: + source.exec(["sh", "-c", f"echo x > {MOUNT_PATH}/x.txt"]).result() + snapshot_id = source.snapshot().result() + + try: + # list_snapshots (client-side filter by source sandbox) finds it. + listed = Sandbox.list_snapshots(source_sandbox_id=source.sandbox_id).result() + assert any(s.file_system_snapshot_id == snapshot_id for s in listed) + + # get_snapshot returns the same record. + fetched = Sandbox.get_snapshot(snapshot_id).result() + assert fetched.file_system_snapshot_id == snapshot_id + finally: + Sandbox.delete_snapshot(snapshot_id, missing_ok=True).result() + + # After deletion, get raises SnapshotNotFoundError. + with pytest.raises(SnapshotNotFoundError): + Sandbox.get_snapshot(snapshot_id).result() diff --git a/tests/unit/cwsandbox/test_defaults.py b/tests/unit/cwsandbox/test_defaults.py index 051300c..b6c71bb 100644 --- a/tests/unit/cwsandbox/test_defaults.py +++ b/tests/unit/cwsandbox/test_defaults.py @@ -9,7 +9,13 @@ import pytest -from cwsandbox import NetworkOptions, ResourceOptions, SandboxDefaults, Secret +from cwsandbox import ( + FileSystemSnapshotOptions, + NetworkOptions, + ResourceOptions, + SandboxDefaults, + Secret, +) class TestSandboxDefaults: @@ -218,6 +224,34 @@ def test_with_overrides_network(self) -> None: assert defaults.network is network1 # original unchanged assert new_defaults.network is network2 + def test_file_system_snapshot_defaults_none(self) -> None: + """Test file_system_snapshot defaults to None.""" + assert SandboxDefaults().file_system_snapshot is None + + def test_file_system_snapshot_can_be_set(self) -> None: + """Test file_system_snapshot can be set in SandboxDefaults.""" + opts = FileSystemSnapshotOptions(mount_path="/workspace", size="10Gi") + defaults = SandboxDefaults(file_system_snapshot=opts) + assert defaults.file_system_snapshot is opts + + def test_file_system_snapshot_from_dict_coercion(self) -> None: + """Test from_dict coerces a dict to FileSystemSnapshotOptions.""" + defaults = SandboxDefaults.from_dict( + {"file_system_snapshot": {"mount_path": "/workspace", "size": "5Gi"}} + ) + assert isinstance(defaults.file_system_snapshot, FileSystemSnapshotOptions) + assert defaults.file_system_snapshot.mount_path == "/workspace" + assert defaults.file_system_snapshot.size == "5Gi" + + def test_with_overrides_file_system_snapshot(self) -> None: + """Test with_overrides can change file_system_snapshot.""" + opts1 = FileSystemSnapshotOptions(mount_path="/a") + opts2 = FileSystemSnapshotOptions(mount_path="/b") + defaults = SandboxDefaults(file_system_snapshot=opts1) + new_defaults = defaults.with_overrides(file_system_snapshot=opts2) + assert defaults.file_system_snapshot is opts1 # original unchanged + assert new_defaults.file_system_snapshot is opts2 + def test_resources_accepts_resource_options(self) -> None: """Test resources field accepts ResourceOptions.""" opts = ResourceOptions( diff --git a/tests/unit/cwsandbox/test_exceptions.py b/tests/unit/cwsandbox/test_exceptions.py index 09b8885..263fc24 100644 --- a/tests/unit/cwsandbox/test_exceptions.py +++ b/tests/unit/cwsandbox/test_exceptions.py @@ -18,10 +18,20 @@ SandboxFileError, SandboxNotFoundError, SandboxNotRunningError, + SandboxSnapshotError, SandboxStreamBackpressureError, SandboxStreamTruncatedError, SandboxTerminatedError, SandboxTimeoutError, + SandboxUnavailableError, + SnapshotBackendThrottledError, + SnapshotBucketMismatchError, + SnapshotNotFoundError, + SnapshotNotReadyError, + SnapshotNotSupportedError, + SnapshotQuotaExceededError, + SnapshotSizeExceededError, + SnapshotWaitTimeoutError, ) @@ -48,6 +58,32 @@ def test_function_error_is_base_for_function_exceptions(self) -> None: """Test FunctionError is the base for function-related exceptions.""" assert issubclass(AsyncFunctionError, FunctionError) + def test_snapshot_error_hierarchy(self) -> None: + """Test FSS exception hierarchy and parent classes.""" + assert issubclass(SandboxSnapshotError, SandboxError) + for cls in ( + SnapshotNotFoundError, + SnapshotNotReadyError, + SnapshotNotSupportedError, + SnapshotSizeExceededError, + SnapshotQuotaExceededError, + SnapshotBucketMismatchError, + ): + assert issubclass(cls, SandboxSnapshotError) + # Transient throttling is a retryable SandboxUnavailableError, NOT a + # SandboxSnapshotError; wait-timeout is a SandboxTimeoutError. + assert issubclass(SnapshotBackendThrottledError, SandboxUnavailableError) + assert not issubclass(SnapshotBackendThrottledError, SandboxSnapshotError) + assert issubclass(SnapshotWaitTimeoutError, SandboxTimeoutError) + + def test_snapshot_error_carries_snapshot_id(self) -> None: + """Test SandboxSnapshotError exposes a file_system_snapshot_id attribute.""" + exc = SnapshotNotFoundError("missing", file_system_snapshot_id="fss-9", reason="X") + assert exc.file_system_snapshot_id == "fss-9" + assert exc.reason == "X" + # Defaults to None when not provided. + assert SandboxSnapshotError("boom").file_system_snapshot_id is None + def test_cwsandbox_error_is_exception(self) -> None: """Test CWSandboxError inherits from Exception.""" assert issubclass(CWSandboxError, Exception) diff --git a/tests/unit/cwsandbox/test_file_system_snapshot.py b/tests/unit/cwsandbox/test_file_system_snapshot.py new file mode 100644 index 0000000..396dd09 --- /dev/null +++ b/tests/unit/cwsandbox/test_file_system_snapshot.py @@ -0,0 +1,988 @@ +# SPDX-FileCopyrightText: 2025 CoreWeave, Inc. +# SPDX-License-Identifier: Apache-2.0 +# SPDX-PackageName: cwsandbox-client + +"""Unit tests for file-system snapshot (FSS) functionality.""" + +from __future__ import annotations + +import asyncio +import dataclasses +from unittest.mock import AsyncMock, MagicMock, patch + +import grpc +import pytest +from google.protobuf import timestamp_pb2 +from google.rpc import error_details_pb2, status_pb2 + +import cwsandbox._sandbox as sandbox_module +from cwsandbox import ( + FileSystemSnapshotBucketConfig, + FileSystemSnapshotBucketMode, + FileSystemSnapshotOptions, + FileSystemSnapshotStatus, + FileSystemSnapshotTrigger, + Sandbox, + SandboxDefaults, + SandboxStatus, +) +from cwsandbox._defaults import ( + DEFAULT_FSS_STOP_CLIENT_SLACK_SECONDS, + DEFAULT_FSS_STOP_GRACE_FALLBACK_SECONDS, + DEFAULT_FSS_STOP_TIMEOUT_SECONDS, +) +from cwsandbox._error_info import ( + CWSANDBOX_FSS_BACKEND_THROTTLED, + CWSANDBOX_FSS_BUCKET_MISMATCH, + CWSANDBOX_FSS_INFLIGHT_LIMIT, + CWSANDBOX_FSS_NOT_FOUND, + CWSANDBOX_FSS_NOT_READY, + CWSANDBOX_FSS_NOT_SUPPORTED, + CWSANDBOX_FSS_QUOTA_EXCEEDED, + CWSANDBOX_FSS_RESTORE_FAILED, + CWSANDBOX_FSS_SIZE_EXCEEDED, + CWSANDBOX_FSS_WAIT_TIMEOUT, +) +from cwsandbox._proto import gateway_pb2 +from cwsandbox._sandbox import _NotStarted, _Running, _Starting, _Stopping, _Terminal +from cwsandbox.exceptions import ( + SandboxError, + SandboxRequestTimeoutError, + SandboxSnapshotError, + SandboxTimeoutError, + SandboxUnavailableError, + SnapshotBackendThrottledError, + SnapshotBucketMismatchError, + SnapshotNotFoundError, + SnapshotNotReadyError, + SnapshotNotSupportedError, + SnapshotOnStopConflictError, + SnapshotQuotaExceededError, + SnapshotSizeExceededError, + SnapshotWaitTimeoutError, +) + + +def _fss_rpc_error( + reason: str, code: grpc.StatusCode = grpc.StatusCode.FAILED_PRECONDITION +) -> grpc.RpcError: + """Build an RpcError carrying an AIP-193 ErrorInfo with the given FSS reason.""" + info = error_details_pb2.ErrorInfo(reason=reason, domain="cwsandbox.com") + status = status_pb2.Status(message="boom") + status.details.add().Pack(info) + status_bytes = status.SerializeToString() + + class _Err(grpc.RpcError): + def code(self) -> grpc.StatusCode: + return code + + def details(self) -> str: + return "boom" + + def trailing_metadata(self) -> list[tuple[str, bytes]]: # type: ignore[override] + return [("grpc-status-details-bin", status_bytes)] + + return _Err() + + +def _bare_rpc_error(code: grpc.StatusCode, details: str = "boom") -> grpc.RpcError: + """Build an RpcError with only a status code (no AIP-193 details).""" + + class _Err(grpc.RpcError): + def code(self) -> grpc.StatusCode: + return code + + def details(self) -> str: + return details + + def trailing_metadata(self) -> list[tuple[str, bytes]]: # type: ignore[override] + return [] + + return _Err() + + +# --------------------------------------------------------------------------- +# FileSystemSnapshotOptions +# --------------------------------------------------------------------------- + + +class TestFileSystemSnapshotOptions: + def test_minimal(self) -> None: + opts = FileSystemSnapshotOptions(mount_path="/workspace") + assert opts.mount_path == "/workspace" + assert opts.size is None + assert opts.file_system_snapshot_id is None + + def test_restore(self) -> None: + opts = FileSystemSnapshotOptions( + mount_path="/data", size="10Gi", file_system_snapshot_id="fss-1" + ) + assert opts.size == "10Gi" + assert opts.file_system_snapshot_id == "fss-1" + + def test_empty_optionals_normalized_to_none(self) -> None: + opts = FileSystemSnapshotOptions(mount_path="/data", size="", file_system_snapshot_id="") + assert opts.size is None + assert opts.file_system_snapshot_id is None + + @pytest.mark.parametrize("bad", ["", "relative/path", "/"]) + def test_invalid_mount_path_raises(self, bad: str) -> None: + with pytest.raises(ValueError): + FileSystemSnapshotOptions(mount_path=bad) + + def test_frozen(self) -> None: + opts = FileSystemSnapshotOptions(mount_path="/workspace") + with pytest.raises(dataclasses.FrozenInstanceError): + opts.mount_path = "/other" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Proto conversions +# --------------------------------------------------------------------------- + + +class TestProtoConversions: + @pytest.mark.parametrize( + ("proto_value", "expected"), + [ + (gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_CREATING, FileSystemSnapshotStatus.CREATING), + (gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_READY, FileSystemSnapshotStatus.READY), + (gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_FAILED, FileSystemSnapshotStatus.FAILED), + (gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_DELETING, FileSystemSnapshotStatus.DELETING), + ], + ) + def test_status_from_proto(self, proto_value: int, expected: FileSystemSnapshotStatus) -> None: + assert sandbox_module._fss_status_from_proto(proto_value) == expected + + def test_status_from_proto_unknown(self) -> None: + assert sandbox_module._fss_status_from_proto(999) == FileSystemSnapshotStatus.UNSPECIFIED + + def test_trigger_from_proto(self) -> None: + assert ( + sandbox_module._fss_trigger_from_proto(gateway_pb2.FILE_SYSTEM_SNAPSHOT_TRIGGER_STOP) + == FileSystemSnapshotTrigger.STOP + ) + assert ( + sandbox_module._fss_trigger_from_proto(gateway_pb2.FILE_SYSTEM_SNAPSHOT_TRIGGER_MANUAL) + == FileSystemSnapshotTrigger.MANUAL + ) + + def test_bucket_mode_from_proto(self) -> None: + assert ( + sandbox_module._fss_bucket_mode_from_proto( + gateway_pb2.FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_BRING_YOUR_OWN + ) + == FileSystemSnapshotBucketMode.BRING_YOUR_OWN + ) + + def test_snapshot_from_proto(self) -> None: + ts = timestamp_pb2.Timestamp() + ts.FromJsonString("2026-06-07T10:00:00Z") + proto = gateway_pb2.FileSystemSnapshot( + file_system_snapshot_id="fss-9", + status=gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_READY, + status_reason="", + size_bytes=4096, + source_sandbox_id="sb-1", + trigger=gateway_pb2.FILE_SYSTEM_SNAPSHOT_TRIGGER_MANUAL, + idempotency_key="k", + object_bucket="bucket-x", + created_at=ts, + ) + snap = sandbox_module._snapshot_from_proto(proto) + assert snap.file_system_snapshot_id == "fss-9" + assert snap.status == FileSystemSnapshotStatus.READY + assert snap.size_bytes == 4096 + assert snap.source_sandbox_id == "sb-1" + assert snap.trigger == FileSystemSnapshotTrigger.MANUAL + assert snap.object_bucket == "bucket-x" + assert snap.created_at is not None + # Unset timestamps stay None. + assert snap.updated_at is None + assert snap.completed_at is None + + def test_bucket_config_from_proto(self) -> None: + proto = gateway_pb2.FileSystemSnapshotBucketConfig( + bucket_name="my-bucket", + region="us-east-1", + mode=gateway_pb2.FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_BRING_YOUR_OWN, + effective_bucket_name="my-bucket", + ) + cfg = sandbox_module._bucket_config_from_proto(proto) + assert cfg.bucket_name == "my-bucket" + assert cfg.region == "us-east-1" + assert cfg.mode == FileSystemSnapshotBucketMode.BRING_YOUR_OWN + assert cfg.effective_bucket_name == "my-bucket" + + def test_mount_kwargs_fresh(self) -> None: + mount = sandbox_module._file_system_mount_kwargs( + FileSystemSnapshotOptions(mount_path="/data") + ) + assert mount == {"mount_path": "/data"} + + def test_mount_kwargs_restore(self) -> None: + mount = sandbox_module._file_system_mount_kwargs( + FileSystemSnapshotOptions( + mount_path="/data", size="5Gi", file_system_snapshot_id="fss-1" + ) + ) + assert mount == { + "mount_path": "/data", + "size": "5Gi", + "file_system_snapshot": {"file_system_snapshot_id": "fss-1"}, + } + + +# --------------------------------------------------------------------------- +# Error mapping +# --------------------------------------------------------------------------- + + +class TestSnapshotErrorMapping: + @pytest.mark.parametrize( + ("reason", "cls"), + [ + (CWSANDBOX_FSS_NOT_FOUND, SnapshotNotFoundError), + (CWSANDBOX_FSS_NOT_READY, SnapshotNotReadyError), + (CWSANDBOX_FSS_NOT_SUPPORTED, SnapshotNotSupportedError), + (CWSANDBOX_FSS_SIZE_EXCEEDED, SnapshotSizeExceededError), + (CWSANDBOX_FSS_QUOTA_EXCEEDED, SnapshotQuotaExceededError), + (CWSANDBOX_FSS_BUCKET_MISMATCH, SnapshotBucketMismatchError), + (CWSANDBOX_FSS_RESTORE_FAILED, SandboxSnapshotError), + ], + ) + def test_terminal_reasons(self, reason: str, cls: type[SandboxSnapshotError]) -> None: + exc = sandbox_module._translate_snapshot_reason( + reason, + details="d", + operation="op", + file_system_snapshot_id="fss-9", + metadata={}, + retry_delay=None, + ) + assert isinstance(exc, cls) + assert exc.reason == reason + assert exc.file_system_snapshot_id == "fss-9" + + def test_wait_timeout_is_non_retryable_timeout(self) -> None: + exc = sandbox_module._translate_snapshot_reason( + CWSANDBOX_FSS_WAIT_TIMEOUT, + details="d", + operation="op", + file_system_snapshot_id="fss-9", + metadata={}, + retry_delay=None, + ) + assert isinstance(exc, SnapshotWaitTimeoutError) + assert isinstance(exc, SandboxTimeoutError) + assert not isinstance(exc, sandbox_module._RETRYABLE_POLL_EXCEPTIONS) + + @pytest.mark.parametrize( + "reason", [CWSANDBOX_FSS_BACKEND_THROTTLED, CWSANDBOX_FSS_INFLIGHT_LIMIT] + ) + def test_transient_reasons_are_retryable(self, reason: str) -> None: + exc = sandbox_module._translate_snapshot_reason( + reason, + details="d", + operation="op", + file_system_snapshot_id=None, + metadata={}, + retry_delay=None, + ) + assert isinstance(exc, SnapshotBackendThrottledError) + assert isinstance(exc, SandboxUnavailableError) + assert isinstance(exc, sandbox_module._RETRYABLE_POLL_EXCEPTIONS) + + def test_unknown_reason_returns_none(self) -> None: + assert ( + sandbox_module._translate_snapshot_reason( + "CWSANDBOX_NOT_AN_FSS_REASON", + details="d", + operation="op", + file_system_snapshot_id=None, + metadata={}, + retry_delay=None, + ) + is None + ) + + def test_end_to_end_get_snapshot_not_supported(self, mock_api_key: str) -> None: + """An FSS reason on a real RPC maps through _translate_rpc_error.""" + mock_channel = MagicMock() + mock_channel.close = AsyncMock() + mock_stub = MagicMock() + mock_stub.GetFileSystemSnapshot = AsyncMock( + side_effect=_fss_rpc_error(CWSANDBOX_FSS_NOT_SUPPORTED) + ) + with ( + patch("cwsandbox._sandbox.parse_grpc_target", return_value=("t:443", True)), + patch("cwsandbox._sandbox.create_channel", return_value=mock_channel), + patch( + "cwsandbox._sandbox.gateway_pb2_grpc.GatewayServiceStub", + return_value=mock_stub, + ), + ): + with pytest.raises(SnapshotNotSupportedError): + Sandbox.get_snapshot("fss-1").result() + + +# --------------------------------------------------------------------------- +# Start / stop wiring +# --------------------------------------------------------------------------- + + +class TestStartWiring: + def test_run_param_builds_file_system_mount(self) -> None: + sandbox = Sandbox( + command="sleep", + args=["infinity"], + file_system_snapshot=FileSystemSnapshotOptions( + mount_path="/workspace", size="10Gi", file_system_snapshot_id="fss-7" + ), + ) + mock_response = MagicMock() + mock_response.sandbox_id = "sb-1" + with patch.object(sandbox, "_ensure_client", new_callable=AsyncMock): + sandbox._channel = MagicMock() + sandbox._stub = MagicMock() + sandbox._stub.Start = AsyncMock(return_value=mock_response) + sandbox.start().result() + request = sandbox._stub.Start.call_args[0][0] + assert request.file_system.mount_path == "/workspace" + assert request.file_system.size == "10Gi" + assert request.file_system.file_system_snapshot.file_system_snapshot_id == "fss-7" + + def test_dict_coercion(self) -> None: + sandbox = Sandbox( + command="sleep", + args=["infinity"], + file_system_snapshot={"mount_path": "/data"}, + ) + assert isinstance(sandbox._start_kwargs["file_system_snapshot"], FileSystemSnapshotOptions) + + def test_defaults_fallback(self) -> None: + defaults = SandboxDefaults( + file_system_snapshot=FileSystemSnapshotOptions(mount_path="/workspace") + ) + sandbox = Sandbox(command="sleep", args=["infinity"], defaults=defaults) + assert sandbox._start_kwargs["file_system_snapshot"].mount_path == "/workspace" + + def test_invalid_type_raises(self) -> None: + with pytest.raises(TypeError): + Sandbox(command="x", file_system_snapshot=123) # type: ignore[arg-type] + + +class TestStopWiring: + def test_snapshot_on_stop_sends_renamed_field_and_captures_id(self) -> None: + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Starting(sandbox_id="sb-1") + sandbox._channel = MagicMock() + sandbox._channel.close = AsyncMock() + sandbox._stub = MagicMock() + stub = sandbox._stub # stop() tears down _stub on completion; keep a ref. + mock_response = MagicMock() + mock_response.success = True + mock_response.file_system_snapshot_id = "fss-new" + sandbox._stub.Stop = AsyncMock(return_value=mock_response) + + with patch.object(sandbox, "_await_terminal_after_stop", new_callable=AsyncMock): + sandbox.stop(snapshot_on_stop=True, idempotency_key="k").result() + + request = stub.Stop.call_args[0][0] + assert request.file_system_snapshot_on_stop is True + assert request.wait_for_ready is True + assert request.idempotency_key == "k" + # Snapshot-on-stop uses the generous FSS ceiling, not graceful shutdown. + assert request.max_timeout_seconds == int(DEFAULT_FSS_STOP_TIMEOUT_SECONDS) + assert sandbox.file_system_snapshot_id == "fss-new" + + def test_plain_stop_does_not_request_snapshot(self) -> None: + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Starting(sandbox_id="sb-1") + sandbox._channel = MagicMock() + sandbox._channel.close = AsyncMock() + sandbox._stub = MagicMock() + stub = sandbox._stub # stop() tears down _stub on completion; keep a ref. + mock_response = MagicMock() + mock_response.success = True + mock_response.file_system_snapshot_id = "" + sandbox._stub.Stop = AsyncMock(return_value=mock_response) + + with patch.object(sandbox, "_await_terminal_after_stop", new_callable=AsyncMock): + sandbox.stop().result() + + request = stub.Stop.call_args[0][0] + assert request.file_system_snapshot_on_stop is False + # wait_for_ready is only set when snapshotting. + assert request.HasField("wait_for_ready") is False + assert sandbox.file_system_snapshot_id is None + + def test_snapshot_on_stop_deadline_covers_archive_plus_grace(self) -> None: + # The backend archives (max_timeout_seconds) THEN deletes the pod + # (graceful_shutdown_seconds). The client deadline must cover both, not + # just the archive — the proto archive budget stays the FSS default. + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Starting(sandbox_id="sb-1") + sandbox._channel = MagicMock() + sandbox._channel.close = AsyncMock() + sandbox._stub = MagicMock() + stub = sandbox._stub + mock_response = MagicMock() + mock_response.success = True + mock_response.file_system_snapshot_id = "fss-new" + sandbox._stub.Stop = AsyncMock(return_value=mock_response) + + with patch.object(sandbox, "_await_terminal_after_stop", new_callable=AsyncMock): + sandbox.stop(snapshot_on_stop=True, graceful_shutdown_seconds=30).result() + + request = stub.Stop.call_args[0][0] + assert request.max_timeout_seconds == int(DEFAULT_FSS_STOP_TIMEOUT_SECONDS) + assert request.graceful_shutdown_seconds == 30 + timeout = stub.Stop.call_args.kwargs["timeout"] + assert timeout == ( + int(DEFAULT_FSS_STOP_TIMEOUT_SECONDS) + 30 + int(DEFAULT_FSS_STOP_CLIENT_SLACK_SECONDS) + ) + + def test_snapshot_on_stop_deadline_budgets_backend_grace_default_when_zero(self) -> None: + # Sending graceful_shutdown_seconds=0 makes the backend substitute its + # own grace default, so the client deadline budgets that default — not 0. + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Starting(sandbox_id="sb-1") + sandbox._channel = MagicMock() + sandbox._channel.close = AsyncMock() + sandbox._stub = MagicMock() + stub = sandbox._stub + mock_response = MagicMock() + mock_response.success = True + mock_response.file_system_snapshot_id = "fss-new" + sandbox._stub.Stop = AsyncMock(return_value=mock_response) + + with patch.object(sandbox, "_await_terminal_after_stop", new_callable=AsyncMock): + sandbox.stop(snapshot_on_stop=True, graceful_shutdown_seconds=0).result() + + assert stub.Stop.call_args[0][0].graceful_shutdown_seconds == 0 + timeout = stub.Stop.call_args.kwargs["timeout"] + assert timeout == ( + int(DEFAULT_FSS_STOP_TIMEOUT_SECONDS) + + int(DEFAULT_FSS_STOP_GRACE_FALLBACK_SECONDS) + + int(DEFAULT_FSS_STOP_CLIENT_SLACK_SECONDS) + ) + + +class TestSnapshotOnStopConflict: + """stop(snapshot_on_stop=True) must not silently coalesce into a stop that + will not archive the mount. It raises SnapshotOnStopConflictError instead. + """ + + def test_raises_when_already_terminating(self) -> None: + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + # TERMINATING (e.g. an external stop / TTL the poller observed): no + # snapshot RPC will be sent for this drain. + sandbox._state = _Stopping(sandbox_id="sb-1") + with pytest.raises(SnapshotOnStopConflictError, match="already terminating"): + sandbox.stop(snapshot_on_stop=True).result() + + def test_raises_when_already_stopped_without_snapshot(self) -> None: + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Terminal(sandbox_id="sb-1", status=SandboxStatus.COMPLETED) + sandbox._file_system_snapshot_id = None + with pytest.raises(SnapshotOnStopConflictError, match="already stopped"): + sandbox.stop(snapshot_on_stop=True).result() + + def test_idempotent_when_snapshot_already_captured(self) -> None: + # A prior snapshot-on-stop already produced an ID; a repeat request is + # convergent (the snapshot the caller asked for exists), so it returns + # rather than raising. + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Terminal(sandbox_id="sb-1", status=SandboxStatus.COMPLETED) + sandbox._file_system_snapshot_id = "fss-prior" + sandbox.stop(snapshot_on_stop=True).result() + assert sandbox.file_system_snapshot_id == "fss-prior" + + def test_does_not_raise_when_cancelled_before_start(self) -> None: + # Never started: no mount to archive, so this is the normal no-op path, + # not a conflict. + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._state = _NotStarted(cancelled=True) + sandbox._reject_unsatisfiable_snapshot_on_stop() # does not raise + + def test_helper_raises_on_in_flight_plain_stop(self) -> None: + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Running(sandbox_id="sb-1") + # Simulate a plain stop already in flight (no snapshot). + sandbox._stop_task = MagicMock() + sandbox._stop_task.done.return_value = False + sandbox._stop_snapshot_requested = False + with pytest.raises(SnapshotOnStopConflictError, match="plain stop is already in progress"): + sandbox._reject_unsatisfiable_snapshot_on_stop() + + def test_helper_allows_join_of_in_flight_snapshot_stop(self) -> None: + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Running(sandbox_id="sb-1") + # The in-flight stop is itself a snapshot-on-stop: joining is safe. + sandbox._stop_task = MagicMock() + sandbox._stop_task.done.return_value = False + sandbox._stop_snapshot_requested = True + sandbox._reject_unsatisfiable_snapshot_on_stop() # does not raise + + async def test_in_flight_plain_stop_is_left_untouched_on_conflict(self) -> None: + # End-to-end through _stop_async: the conflict is raised before the + # join, so the in-flight plain stop is neither cancelled nor awaited. + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Running(sandbox_id="sb-1") + + async def _never() -> None: + await asyncio.sleep(3600) + + sandbox._stop_task = asyncio.create_task(_never()) + sandbox._stop_snapshot_requested = False + try: + with pytest.raises(SnapshotOnStopConflictError): + await sandbox._stop_async(snapshot_on_stop=True) + assert not sandbox._stop_task.done() # the plain stop kept running + finally: + sandbox._stop_task.cancel() + + def test_plain_stop_still_coalesces_when_terminating(self) -> None: + # Regression: a plain stop joining a TERMINATING sandbox must NOT raise + # (it observes the drain to terminal). Only snapshot requests conflict. + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._state = _Stopping(sandbox_id="sb-1") + with patch.object(sandbox, "_await_terminal_after_stop", new_callable=AsyncMock): + sandbox.stop().result() # no SnapshotOnStopConflictError + + +# --------------------------------------------------------------------------- +# snapshot() and management methods +# --------------------------------------------------------------------------- + + +def _ready_snapshot_proto(file_system_snapshot_id: str = "fss-1") -> gateway_pb2.FileSystemSnapshot: + return gateway_pb2.FileSystemSnapshot( + file_system_snapshot_id=file_system_snapshot_id, + status=gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_READY, + source_sandbox_id="sb-1", + trigger=gateway_pb2.FILE_SYSTEM_SNAPSHOT_TRIGGER_MANUAL, + ) + + +class TestSnapshotMethod: + def test_snapshot_returns_id(self) -> None: + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._stub = MagicMock() + create_resp = MagicMock() + create_resp.success = True + create_resp.file_system_snapshot_id = "fss-1" + sandbox._stub.CreateFileSystemSnapshot = AsyncMock(return_value=create_resp) + sandbox._stub.GetFileSystemSnapshot = AsyncMock() + with ( + patch.object(sandbox, "_ensure_client", new_callable=AsyncMock), + patch.object( + sandbox, "_wait_until_running_async", new_callable=AsyncMock + ) as wait_running, + ): + snapshot_id = sandbox.snapshot(idempotency_key="k").result() + # snapshot() waits for RUNNING before archiving the mount. + wait_running.assert_awaited() + # snapshot() returns just the ID; it does NOT fetch the full record. + assert snapshot_id == "fss-1" + sandbox._stub.GetFileSystemSnapshot.assert_not_called() + create_req = sandbox._stub.CreateFileSystemSnapshot.call_args[0][0] + assert create_req.sandbox_id == "sb-1" + assert create_req.wait_for_ready is True + assert create_req.idempotency_key == "k" + # wait_for_ready blocks on the archive, so the server-side ceiling is set. + assert create_req.max_timeout_seconds == int(DEFAULT_FSS_STOP_TIMEOUT_SECONDS) + + def test_snapshot_failure_raises(self) -> None: + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._stub = MagicMock() + create_resp = MagicMock() + create_resp.success = False + create_resp.error_message = "nope" + sandbox._stub.CreateFileSystemSnapshot = AsyncMock(return_value=create_resp) + with ( + patch.object(sandbox, "_ensure_client", new_callable=AsyncMock), + patch.object(sandbox, "_wait_until_running_async", new_callable=AsyncMock), + ): + with pytest.raises(SandboxSnapshotError, match="nope"): + sandbox.snapshot().result() + + +class TestManagementClassmethods: + def _patch_channel(self, mock_stub: MagicMock): # type: ignore[no-untyped-def] + mock_channel = MagicMock() + mock_channel.close = AsyncMock() + return ( + patch("cwsandbox._sandbox.parse_grpc_target", return_value=("t:443", True)), + patch("cwsandbox._sandbox.create_channel", return_value=mock_channel), + patch( + "cwsandbox._sandbox.gateway_pb2_grpc.GatewayServiceStub", + return_value=mock_stub, + ), + patch("cwsandbox._sandbox.resolve_auth_metadata", return_value=()), + ) + + def test_get_snapshot(self) -> None: + mock_stub = MagicMock() + mock_stub.GetFileSystemSnapshot = AsyncMock(return_value=_ready_snapshot_proto("fss-2")) + patches = self._patch_channel(mock_stub) + with patches[0], patches[1], patches[2], patches[3]: + snap = Sandbox.get_snapshot("fss-2").result() + assert snap.file_system_snapshot_id == "fss-2" + req = mock_stub.GetFileSystemSnapshot.call_args[0][0] + assert req.file_system_snapshot_id == "fss-2" + + def test_list_snapshots_with_client_side_filters(self) -> None: + protos = [ + gateway_pb2.FileSystemSnapshot( + file_system_snapshot_id="a", + status=gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_READY, + source_sandbox_id="sb-1", + ), + gateway_pb2.FileSystemSnapshot( + file_system_snapshot_id="b", + status=gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_CREATING, + source_sandbox_id="sb-1", + ), + gateway_pb2.FileSystemSnapshot( + file_system_snapshot_id="c", + status=gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_READY, + source_sandbox_id="sb-2", + ), + ] + mock_stub = MagicMock() + mock_stub.ListFileSystemSnapshots = AsyncMock( + return_value=gateway_pb2.ListFileSystemSnapshotsResponse( + file_system_snapshots=protos, next_page_token="" + ) + ) + patches = self._patch_channel(mock_stub) + with patches[0], patches[1], patches[2], patches[3]: + # No filter -> all three. + all_snaps = Sandbox.list_snapshots().result() + assert {s.file_system_snapshot_id for s in all_snaps} == {"a", "b", "c"} + # Filter by source sandbox. + sb1 = Sandbox.list_snapshots(source_sandbox_id="sb-1").result() + assert {s.file_system_snapshot_id for s in sb1} == {"a", "b"} + # Filter by status (string and enum both accepted). + ready = Sandbox.list_snapshots(status=FileSystemSnapshotStatus.READY).result() + assert {s.file_system_snapshot_id for s in ready} == {"a", "c"} + # Combined filter. + both = Sandbox.list_snapshots(source_sandbox_id="sb-1", status="ready").result() + assert {s.file_system_snapshot_id for s in both} == {"a"} + + def test_list_snapshots_retry_restarts_pagination(self) -> None: + """A mid-pagination transient retry restarts from page 1, no dropped pages. + + ``paginate_async`` mutates ``page_token`` in place, so the retried + attempt must build a fresh request (page 1) rather than resuming at the + last token; otherwise the already-collected first page is silently lost. + """ + page1 = gateway_pb2.ListFileSystemSnapshotsResponse( + file_system_snapshots=[ + gateway_pb2.FileSystemSnapshot( + file_system_snapshot_id="a", + status=gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_READY, + ) + ], + next_page_token="tok1", + ) + page2 = gateway_pb2.ListFileSystemSnapshotsResponse( + file_system_snapshots=[ + gateway_pb2.FileSystemSnapshot( + file_system_snapshot_id="b", + status=gateway_pb2.FILE_SYSTEM_SNAPSHOT_STATUS_READY, + ) + ], + next_page_token="", + ) + failed_once = {"v": False} + + async def fake_list(request, *, metadata, timeout): # type: ignore[no-untyped-def] + if request.page_token == "": + return page1 + # Reached page 2 (token "tok1"): fail transiently the first time. + if not failed_once["v"]: + failed_once["v"] = True + raise _bare_rpc_error(grpc.StatusCode.UNAVAILABLE) + return page2 + + mock_stub = MagicMock() + mock_stub.ListFileSystemSnapshots = fake_list + patches = self._patch_channel(mock_stub) + with ( + patches[0], + patches[1], + patches[2], + patches[3], + patch("cwsandbox._sandbox.asyncio.sleep", new_callable=AsyncMock), + ): + listed = Sandbox.list_snapshots().result() + # Both pages present: the retry restarted pagination from page 1 rather + # than resuming at "tok1" and dropping page 1. + assert {s.file_system_snapshot_id for s in listed} == {"a", "b"} + + def test_delete_snapshot_success(self) -> None: + mock_stub = MagicMock() + resp = MagicMock() + resp.success = True + mock_stub.DeleteFileSystemSnapshot = AsyncMock(return_value=resp) + patches = self._patch_channel(mock_stub) + with patches[0], patches[1], patches[2], patches[3]: + assert Sandbox.delete_snapshot("fss-3").result() is None + req = mock_stub.DeleteFileSystemSnapshot.call_args[0][0] + assert req.file_system_snapshot_id == "fss-3" + + def test_delete_snapshot_missing_ok_suppresses_not_found(self) -> None: + mock_stub = MagicMock() + mock_stub.DeleteFileSystemSnapshot = AsyncMock( + side_effect=_fss_rpc_error(CWSANDBOX_FSS_NOT_FOUND, code=grpc.StatusCode.NOT_FOUND) + ) + patches = self._patch_channel(mock_stub) + with patches[0], patches[1], patches[2], patches[3]: + assert Sandbox.delete_snapshot("fss-3", missing_ok=True).result() is None + + def test_delete_snapshot_not_found_raises(self) -> None: + mock_stub = MagicMock() + mock_stub.DeleteFileSystemSnapshot = AsyncMock( + side_effect=_fss_rpc_error(CWSANDBOX_FSS_NOT_FOUND, code=grpc.StatusCode.NOT_FOUND) + ) + patches = self._patch_channel(mock_stub) + with patches[0], patches[1], patches[2], patches[3]: + with pytest.raises(SnapshotNotFoundError): + Sandbox.delete_snapshot("fss-3").result() + + def test_get_snapshot_bare_not_found_maps_to_snapshot_not_found(self) -> None: + """A bare gRPC NOT_FOUND (no FSS reason) still raises SnapshotNotFoundError.""" + mock_stub = MagicMock() + mock_stub.GetFileSystemSnapshot = AsyncMock( + side_effect=_bare_rpc_error(grpc.StatusCode.NOT_FOUND) + ) + patches = self._patch_channel(mock_stub) + with patches[0], patches[1], patches[2], patches[3]: + with pytest.raises(SnapshotNotFoundError): + Sandbox.get_snapshot("fss-x").result() + + def test_delete_snapshot_not_found_on_retry_is_success(self) -> None: + """A committed delete whose response was lost: retry hits NOT_FOUND -> success. + + With missing_ok=False, a transient failure followed by NOT_FOUND on the + retry is treated as success, because the delete's postcondition (the + snapshot is gone) is satisfied. + """ + mock_stub = MagicMock() + mock_stub.DeleteFileSystemSnapshot = AsyncMock( + side_effect=[ + _bare_rpc_error(grpc.StatusCode.UNAVAILABLE), + _fss_rpc_error(CWSANDBOX_FSS_NOT_FOUND, code=grpc.StatusCode.NOT_FOUND), + ] + ) + patches = self._patch_channel(mock_stub) + with ( + patches[0], + patches[1], + patches[2], + patches[3], + patch("cwsandbox._sandbox.asyncio.sleep", new_callable=AsyncMock), + ): + assert Sandbox.delete_snapshot("fss-3").result() is None + assert mock_stub.DeleteFileSystemSnapshot.call_count == 2 + + +class TestBucketConfig: + def _patch_channel(self, mock_stub: MagicMock): # type: ignore[no-untyped-def] + mock_channel = MagicMock() + mock_channel.close = AsyncMock() + return ( + patch("cwsandbox._sandbox.parse_grpc_target", return_value=("t:443", True)), + patch("cwsandbox._sandbox.create_channel", return_value=mock_channel), + patch( + "cwsandbox._sandbox.gateway_pb2_grpc.GatewayServiceStub", + return_value=mock_stub, + ), + patch("cwsandbox._sandbox.resolve_auth_metadata", return_value=()), + ) + + def test_get_bucket_config(self) -> None: + mock_stub = MagicMock() + mock_stub.GetFileSystemSnapshotBucketConfig = AsyncMock( + return_value=gateway_pb2.FileSystemSnapshotBucketConfig( + mode=gateway_pb2.FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_CW_MANAGED, + effective_bucket_name="cw-bucket", + ) + ) + patches = self._patch_channel(mock_stub) + with patches[0], patches[1], patches[2], patches[3]: + cfg = Sandbox.get_snapshot_bucket_config().result() + assert isinstance(cfg, FileSystemSnapshotBucketConfig) + assert cfg.mode == FileSystemSnapshotBucketMode.CW_MANAGED + assert cfg.effective_bucket_name == "cw-bucket" + + def test_set_bucket_config(self) -> None: + mock_stub = MagicMock() + mock_stub.SetFileSystemSnapshotBucketConfig = AsyncMock( + return_value=gateway_pb2.FileSystemSnapshotBucketConfig( + bucket_name="byo", + region="us-east-1", + mode=gateway_pb2.FILE_SYSTEM_SNAPSHOT_BUCKET_MODE_BRING_YOUR_OWN, + effective_bucket_name="byo", + ) + ) + patches = self._patch_channel(mock_stub) + with patches[0], patches[1], patches[2], patches[3]: + cfg = Sandbox.set_snapshot_bucket_config(bucket_name="byo", region="us-east-1").result() + assert cfg.mode == FileSystemSnapshotBucketMode.BRING_YOUR_OWN + req = mock_stub.SetFileSystemSnapshotBucketConfig.call_args[0][0] + assert req.bucket_name == "byo" + assert req.region == "us-east-1" + + +# --------------------------------------------------------------------------- +# Client-side transient retry on FSS RPCs +# --------------------------------------------------------------------------- + + +class TestTransientRetry: + """The FSS RPCs retry transient backend errors with a bounded budget.""" + + def test_snapshot_retries_transient_then_succeeds(self) -> None: + """A transient UNAVAILABLE on create is retried; the same auto-key reused.""" + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._stub = MagicMock() + ok = MagicMock() + ok.success = True + ok.file_system_snapshot_id = "fss-1" + sandbox._stub.CreateFileSystemSnapshot = AsyncMock( + side_effect=[_bare_rpc_error(grpc.StatusCode.UNAVAILABLE), ok] + ) + with ( + patch.object(sandbox, "_ensure_client", new_callable=AsyncMock), + patch.object(sandbox, "_wait_until_running_async", new_callable=AsyncMock), + patch("cwsandbox._sandbox.asyncio.sleep", new_callable=AsyncMock), + ): + snapshot_id = sandbox.snapshot().result() + + assert snapshot_id == "fss-1" + assert sandbox._stub.CreateFileSystemSnapshot.call_count == 2 + # The caller passed no idempotency_key, so one was generated and the + # retried attempt reuses it (so a committed-but-lost create dedups). + calls = sandbox._stub.CreateFileSystemSnapshot.call_args_list + first_key = calls[0][0][0].idempotency_key + second_key = calls[1][0][0].idempotency_key + assert first_key + assert first_key == second_key + + def test_snapshot_does_not_retry_failed_precondition(self) -> None: + """A FAILED_PRECONDITION is fatal: one attempt, no retry.""" + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._stub = MagicMock() + sandbox._stub.CreateFileSystemSnapshot = AsyncMock( + side_effect=_bare_rpc_error( + grpc.StatusCode.FAILED_PRECONDITION, "requires a running sandbox" + ) + ) + with ( + patch.object(sandbox, "_ensure_client", new_callable=AsyncMock), + patch.object(sandbox, "_wait_until_running_async", new_callable=AsyncMock), + patch("cwsandbox._sandbox.asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + with pytest.raises(SandboxError, match="requires a running sandbox"): + sandbox.snapshot().result() + + assert sandbox._stub.CreateFileSystemSnapshot.call_count == 1 + sleep.assert_not_called() + + def test_snapshot_does_not_retry_not_supported(self) -> None: + """An org-not-enabled (NOT_SUPPORTED) reason is fatal: no retry.""" + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._stub = MagicMock() + sandbox._stub.CreateFileSystemSnapshot = AsyncMock( + side_effect=_fss_rpc_error(CWSANDBOX_FSS_NOT_SUPPORTED) + ) + with ( + patch.object(sandbox, "_ensure_client", new_callable=AsyncMock), + patch.object(sandbox, "_wait_until_running_async", new_callable=AsyncMock), + patch("cwsandbox._sandbox.asyncio.sleep", new_callable=AsyncMock), + ): + with pytest.raises(SnapshotNotSupportedError): + sandbox.snapshot().result() + assert sandbox._stub.CreateFileSystemSnapshot.call_count == 1 + + def test_snapshot_budget_zero_disables_retry(self) -> None: + """With the budget at 0, the first transient error re-raises immediately.""" + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._stub = MagicMock() + sandbox._stub.CreateFileSystemSnapshot = AsyncMock( + side_effect=_bare_rpc_error(grpc.StatusCode.UNAVAILABLE) + ) + with ( + patch.object(sandbox, "_ensure_client", new_callable=AsyncMock), + patch.object(sandbox, "_wait_until_running_async", new_callable=AsyncMock), + patch("cwsandbox._sandbox.DEFAULT_FSS_RETRY_BUDGET_SECONDS", 0.0), + ): + with pytest.raises(SandboxUnavailableError): + sandbox.snapshot().result() + assert sandbox._stub.CreateFileSystemSnapshot.call_count == 1 + + def test_snapshot_does_not_retry_deadline_exceeded(self) -> None: + """A client DEADLINE_EXCEEDED on a wait-for-ready create is the ceiling + being hit, not a transient blip: one attempt, no retry, so a wedged + backend cannot trigger a second full-length attempt (~2x overrun).""" + sandbox = Sandbox(command="sleep", args=["infinity"]) + sandbox._sandbox_id = "sb-1" + sandbox._stub = MagicMock() + sandbox._stub.CreateFileSystemSnapshot = AsyncMock( + side_effect=_bare_rpc_error(grpc.StatusCode.DEADLINE_EXCEEDED) + ) + with ( + patch.object(sandbox, "_ensure_client", new_callable=AsyncMock), + patch.object(sandbox, "_wait_until_running_async", new_callable=AsyncMock), + patch("cwsandbox._sandbox.asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + with pytest.raises(SandboxRequestTimeoutError): + sandbox.snapshot().result() + assert sandbox._stub.CreateFileSystemSnapshot.call_count == 1 + sleep.assert_not_called() + + def test_get_snapshot_retries_transient_then_succeeds(self) -> None: + """The management classmethods retry transient errors too.""" + mock_channel = MagicMock() + mock_channel.close = AsyncMock() + mock_stub = MagicMock() + mock_stub.GetFileSystemSnapshot = AsyncMock( + side_effect=[ + _bare_rpc_error(grpc.StatusCode.UNAVAILABLE), + _ready_snapshot_proto("fss-2"), + ] + ) + with ( + patch("cwsandbox._sandbox.parse_grpc_target", return_value=("t:443", True)), + patch("cwsandbox._sandbox.create_channel", return_value=mock_channel), + patch( + "cwsandbox._sandbox.gateway_pb2_grpc.GatewayServiceStub", + return_value=mock_stub, + ), + patch("cwsandbox._sandbox.resolve_auth_metadata", return_value=()), + patch("cwsandbox._sandbox.asyncio.sleep", new_callable=AsyncMock), + ): + snap = Sandbox.get_snapshot("fss-2").result() + assert snap.file_system_snapshot_id == "fss-2" + assert mock_stub.GetFileSystemSnapshot.call_count == 2