From 64e3d3f82f754891d81fb219350692c75e355cb1 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 2 Nov 2026 16:36:22 -0500 Subject: [PATCH] feat(python): add provider attachment and sandbox configuration APIs Signed-off-by: Max Dubrinsky --- docs/sdk/python.mdx | 37 ++ python/openshell/async_client_test.py | 150 ++++++++ python/openshell/sandbox.py | 505 +++++++++++++++++++++++++- 3 files changed, 676 insertions(+), 16 deletions(-) diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 4e74fe0728..87aa4d5279 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -251,6 +251,43 @@ from creation to pin the target before the first poll. Its `timeout_seconds` bounds polling RPCs and sleeps; `poll_interval` controls the polling frequency. Use `exec()` to wait for a separately executed command instead. +## Manage Provider Attachments and Configuration + +Use `attach_provider()`, `detach_provider()`, and `list_providers()` on either +sandbox client or a bound session. Attachment mutations return the complete +response, including the updated sandbox and a receipt. Persisting an attachment +does not mean its credentials are installed: + +```python +with SandboxClient.from_active_cluster() as client: + sandbox = client.get("worker", workspace="default") + result = client.attach_provider( + sandbox.name, + "github", + workspace="default", + expected_resource_version=sandbox.resource_version, + ) + status = client.get_provider_status( + sandbox.name, + "github", + workspace="default", + receipt_id=result.receipt.receipt_id, + ) + print(status.state) +``` + +Use `get_config()` to inspect effective configuration and admission state. +`set_policy()` and `set_setting()` return the submitted revision details; +`get_policy_status(version=...)` and `list_policies()` expose application status +and history. A successful update RPC does not establish that the policy is loaded. +Sandbox policy changes may alter network policies only; static fields must match +the create-time policy. These helpers always use sandbox scope. + +Mutation helpers accept `expected_resource_version` where the protocol supports +it. Read it from `SandboxRef.resource_version`; a conflicting change raises +`GatewayError` with `ABORTED`. The SDK never retries a conflict automatically. +`SandboxRef.annotations` exposes the gateway's current annotations. + ## Call Generated RPCs The generated gRPC stubs include concrete request and response types for synchronous `grpc.Channel` and asynchronous `grpc.aio.Channel` connections. Install the dependency type stubs in your development environment for editor signatures and type checking: diff --git a/python/openshell/async_client_test.py b/python/openshell/async_client_test.py index 03a9eabcad..9ac1c3479e 100644 --- a/python/openshell/async_client_test.py +++ b/python/openshell/async_client_test.py @@ -23,6 +23,7 @@ from openshell._proto import datamodel_pb2 as dm from openshell._proto import openshell_pb2 as pb from openshell._proto import openshell_pb2_grpc as rpc +from openshell._proto import sandbox_pb2 as sb class Gateway(rpc.OpenShellServicer): @@ -53,6 +54,8 @@ async def GetSandbox(self, request, context): id=identity, name=request.name, workspace=request.workspace_scope.workspace, + resource_version=7, + annotations={"owner": "helix"}, ), status=pb.SandboxStatus(phase=phase, exit_code=0), ) @@ -150,6 +153,71 @@ async def WatchSandbox(self, request, context): finally: self.cancelled.set() + async def AttachSandboxProvider(self, request, context): + self.requests.append((request, context.invocation_metadata())) + if request.expected_resource_version not in (0, 7): + await context.abort(grpc.StatusCode.ABORTED, "resource version changed") + return pb.AttachSandboxProviderResponse( + attached=True, + receipt=pb.ProviderMutationReceipt( + receipt_id="receipt-1", mutation_id="mutation-1" + ), + sandbox=pb.Sandbox(metadata=dm.ObjectMeta(resource_version=8)), + ) + + async def DetachSandboxProvider(self, request, context): + self.requests.append((request, context.invocation_metadata())) + return pb.DetachSandboxProviderResponse( + detached=True, receipt=pb.ProviderMutationReceipt(receipt_id="detach-1") + ) + + async def ListSandboxProviders(self, request, context): + self.requests.append((request, context.invocation_metadata())) + return pb.ListSandboxProvidersResponse( + providers=[dm.Provider(metadata=dm.ObjectMeta(name="github"))] + ) + + async def GetSandboxProviderStatus(self, request, context): + self.requests.append((request, context.invocation_metadata())) + return pb.GetSandboxProviderStatusResponse( + status=pb.ProviderReadinessStatus( + receipt=pb.ProviderMutationReceipt(receipt_id=request.receipt_id), + state=pb.PROVIDER_READINESS_STATE_PENDING, + ) + ) + + async def GetSandboxConfig(self, request, context): + self.requests.append((request, context.invocation_metadata())) + return sb.GetSandboxConfigResponse( + config_revision=9, configuration_admitted=True + ) + + async def UpdateConfig(self, request, context): + self.requests.append((request, context.invocation_metadata())) + return pb.UpdateConfigResponse( + version=3, + policy_hash="policy-hash", + settings_revision=4, + annotations=request.annotations, + ) + + async def GetSandboxPolicyStatus(self, request, context): + self.requests.append((request, context.invocation_metadata())) + return pb.GetSandboxPolicyStatusResponse( + revision=pb.SandboxPolicyRevision( + version=request.version, status=pb.POLICY_STATUS_PENDING + ), + active_version=2, + ) + + async def ListSandboxPolicies(self, request, context): + self.requests.append((request, context.invocation_metadata())) + return pb.ListSandboxPoliciesResponse( + revisions=[ + pb.SandboxPolicyRevision(version=1, status=pb.POLICY_STATUS_FAILED) + ] + ) + @asynccontextmanager async def gateway(): @@ -396,3 +464,85 @@ async def test_wait_completed_pins_identity_and_checks_terminal_outcomes(): "blocked", workspace="team", timeout_seconds=0.05 ) assert failure.value.code() == grpc.StatusCode.DEADLINE_EXCEEDED + + +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_provider_receipts_versions_and_configuration(asynchronous): + from openshell import SandboxClient + from openshell.types import SandboxPolicy, SettingValue + + async with gateway() as (service, target): + client = AsyncSandboxClient(target) if asynchronous else SandboxClient(target) + + async def invoke(method, *args, **kwargs): + if asynchronous: + return await method(*args, **kwargs) + return await asyncio.to_thread(method, *args, **kwargs) + + try: + session = await invoke(client.get_session, "worker", workspace="team") + assert session.sandbox.resource_version == 7 + assert session.sandbox.annotations == {"owner": "helix"} + attached = await invoke( + session.attach_provider, + "github", + expected_resource_version=7, + request_id="attach-1", + ) + assert attached.receipt.receipt_id == "receipt-1" + assert attached.receipt.mutation_id == "mutation-1" + assert attached.sandbox.metadata.resource_version == 8 + status = await invoke( + session.get_provider_status, + "github", + receipt_id=attached.receipt.receipt_id, + ) + assert status.state == pb.PROVIDER_READINESS_STATE_PENDING + assert status.receipt.receipt_id == "receipt-1" + assert [ + p.metadata.name for p in await invoke(session.list_all_providers) + ] == ["github"] + detached = await invoke( + session.detach_provider, "github", expected_resource_version=8 + ) + assert detached.receipt.receipt_id == "detach-1" + count = len(service.requests) + with pytest.raises(GatewayError) as failure: + await invoke( + session.attach_provider, "github", expected_resource_version=1 + ) + assert failure.value.code() == grpc.StatusCode.ABORTED + assert len(service.requests) == count + 1 + config = await invoke(session.get_config) + assert config.configuration_admitted + result = await invoke( + session.set_policy, + SandboxPolicy(version=1), + expected_resource_version=8, + annotations={"review": "approved"}, + request_id="policy-1", + ) + assert result.policy_hash == "policy-hash" + request = service.requests[-1][0] + assert not getattr(request, "global") + assert request.request_id == "policy-1" + assert request.expected_resource_version == 8 + assert dict(result.annotations) == {"review": "approved"} + await invoke( + session.set_setting, + "example", + SettingValue(bool_value=False), + request_id="setting-1", + ) + request = service.requests[-1][0] + assert request.setting_value.WhichOneof("value") == "bool_value" + assert not request.setting_value.bool_value + status = await invoke(session.get_policy_status, version=result.version) + assert status.revision.status == pb.POLICY_STATUS_PENDING + assert status.active_version == 2 + revisions = await invoke(session.list_all_policies) + assert revisions[0].status == pb.POLICY_STATUS_FAILED + for request, _ in service.requests: + assert request.workspace_scope.workspace == "team" + finally: + await invoke(client.close) diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index c40b4712fb..3574652d0e 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -28,6 +28,7 @@ datamodel_pb2, openshell_pb2, openshell_pb2_grpc, + sandbox_pb2, ) from .errors import _error_mapping_channel, from_grpc_error from .mutations import DeletionOutcome, DeletionResult @@ -590,10 +591,15 @@ class SandboxRef: ) # Stable SSH host identity; absent on older runtimes. host_key_fingerprint: str | None = None + resource_version: int = 0 + annotations: Mapping[str, str] = field( + default_factory=_ImmutableLabels, compare=False + ) def __post_init__(self) -> None: object.__setattr__(self, "labels", _ImmutableLabels(self.labels)) object.__setattr__(self, "service_urls", _ImmutableLabels(self.service_urls)) + object.__setattr__(self, "annotations", _ImmutableLabels(self.annotations)) @property def phase(self) -> int: @@ -645,6 +651,8 @@ def exec( stdin: bytes | None = None, timeout_seconds: int | None = None, no_login_shell: bool = False, + request_id: str | None = None, + timeout: float | None = None, ) -> ExecResult: return self._client.exec( self.sandbox.name, @@ -656,6 +664,8 @@ def exec( stdin=stdin, timeout_seconds=timeout_seconds, no_login_shell=no_login_shell, + request_id=request_id, + timeout=timeout, ) def exec_python( @@ -668,6 +678,8 @@ def exec_python( workdir: str | None = None, env: Mapping[str, str] | None = None, timeout_seconds: int | None = None, + request_id: str | None = None, + timeout: float | None = None, ) -> ExecResult: return self._client.exec_python( self.sandbox.name, @@ -679,32 +691,61 @@ def exec_python( workdir=workdir, env=env, timeout_seconds=timeout_seconds, + request_id=request_id, + timeout=timeout, ) - def delete(self, *, allow_missing: bool = False) -> DeletionResult: + def delete( + self, + *, + allow_missing: bool = False, + request_id: str | None = None, + timeout: float | None = None, + ) -> DeletionResult: return self._client.delete( - self.sandbox.name, workspace=self._workspace, allow_missing=allow_missing + self.sandbox.name, + workspace=self._workspace, + allow_missing=allow_missing, + request_id=request_id, + timeout=timeout, ) - def stop(self) -> SandboxRef: - self.sandbox = self._client.stop(self.sandbox.name, workspace=self._workspace) + def stop( + self, *, request_id: str | None = None, timeout: float | None = None + ) -> SandboxRef: + self.sandbox = self._client.stop( + self.sandbox.name, + workspace=self._workspace, + request_id=request_id, + timeout=timeout, + ) return self.sandbox - def start(self) -> SandboxRef: - self.sandbox = self._client.start(self.sandbox.name, workspace=self._workspace) + def start( + self, *, request_id: str | None = None, timeout: float | None = None + ) -> SandboxRef: + self.sandbox = self._client.start( + self.sandbox.name, + workspace=self._workspace, + request_id=request_id, + timeout=timeout, + ) return self.sandbox - def suspend(self) -> SandboxRef: + def suspend(self, *, timeout: float | None = None) -> SandboxRef: self.sandbox = self._client.suspend( - self.sandbox.name, workspace=self._workspace + self.sandbox.name, workspace=self._workspace, timeout=timeout ) return self.sandbox - def resume(self, *, restart_if_restore_fails: bool = False) -> SandboxRef: + def resume( + self, *, restart_if_restore_fails: bool = False, timeout: float | None = None + ) -> SandboxRef: self.sandbox = self._client.resume( self.sandbox.name, workspace=self._workspace, restart_if_restore_fails=restart_if_restore_fails, + timeout=timeout, ) return self.sandbox @@ -881,6 +922,168 @@ def wait_completed( expected_sandbox_id=expected_sandbox_id, ) + def attach_provider( + self, + provider: str, + *, + expected_resource_version: int = 0, + request_id: str | None = None, + timeout: float | None = None, + ) -> openshell_pb2.AttachSandboxProviderResponse: + return self._client.attach_provider( + self.sandbox.name, + provider, + workspace=self._workspace, + expected_resource_version=expected_resource_version, + request_id=request_id, + timeout=timeout, + ) + + def detach_provider( + self, + provider: str, + *, + expected_resource_version: int = 0, + request_id: str | None = None, + timeout: float | None = None, + ) -> openshell_pb2.DetachSandboxProviderResponse: + return self._client.detach_provider( + self.sandbox.name, + provider, + workspace=self._workspace, + expected_resource_version=expected_resource_version, + request_id=request_id, + timeout=timeout, + ) + + def list_providers( + self, + *, + page_size: int = 100, + page_token: str = "", + timeout: float | None = None, + ) -> Pager[datamodel_pb2.Provider]: + return self._client.list_providers( + self.sandbox.name, + workspace=self._workspace, + page_size=page_size, + page_token=page_token, + timeout=timeout, + ) + + def list_all_providers( + self, + *, + page_size: int = 100, + page_token: str = "", + timeout: float | None = None, + ) -> builtins.list[datamodel_pb2.Provider]: + return self._client.list_all_providers( + self.sandbox.name, + workspace=self._workspace, + page_size=page_size, + page_token=page_token, + timeout=timeout, + ) + + def get_provider_status( + self, provider: str, *, receipt_id: str = "", timeout: float | None = None + ) -> openshell_pb2.ProviderReadinessStatus: + return self._client.get_provider_status( + self.sandbox.name, + provider, + workspace=self._workspace, + receipt_id=receipt_id, + timeout=timeout, + ) + + def get_config( + self, *, timeout: float | None = None + ) -> sandbox_pb2.GetSandboxConfigResponse: + return self._client.get_config( + self.sandbox.name, workspace=self._workspace, timeout=timeout + ) + + def set_policy( + self, + policy: sandbox_pb2.SandboxPolicy, + *, + expected_resource_version: int = 0, + annotations: Mapping[str, str] | None = None, + request_id: str | None = None, + timeout: float | None = None, + ) -> openshell_pb2.UpdateConfigResponse: + return self._client.set_policy( + self.sandbox.name, + policy, + workspace=self._workspace, + expected_resource_version=expected_resource_version, + annotations=annotations, + request_id=request_id, + timeout=timeout, + ) + + def set_setting( + self, + key: str, + value: sandbox_pb2.SettingValue, + *, + expected_resource_version: int = 0, + annotations: Mapping[str, str] | None = None, + request_id: str | None = None, + timeout: float | None = None, + ) -> openshell_pb2.UpdateConfigResponse: + return self._client.set_setting( + self.sandbox.name, + key, + value, + workspace=self._workspace, + expected_resource_version=expected_resource_version, + annotations=annotations, + request_id=request_id, + timeout=timeout, + ) + + def get_policy_status( + self, *, version: int = 0, timeout: float | None = None + ) -> openshell_pb2.GetSandboxPolicyStatusResponse: + return self._client.get_policy_status( + self.sandbox.name, + workspace=self._workspace, + version=version, + timeout=timeout, + ) + + def list_policies( + self, + *, + page_size: int = 100, + page_token: str = "", + timeout: float | None = None, + ) -> Pager[openshell_pb2.SandboxPolicyRevision]: + return self._client.list_policies( + self.sandbox.name, + workspace=self._workspace, + page_size=page_size, + page_token=page_token, + timeout=timeout, + ) + + def list_all_policies( + self, + *, + page_size: int = 100, + page_token: str = "", + timeout: float | None = None, + ) -> builtins.list[openshell_pb2.SandboxPolicyRevision]: + return self._client.list_all_policies( + self.sandbox.name, + workspace=self._workspace, + page_size=page_size, + page_token=page_token, + timeout=timeout, + ) + class SandboxClient: """gRPC client for sandbox CRUD and command execution.""" @@ -1176,6 +1379,8 @@ def create_session( name: str | None = None, labels: Mapping[str, str] | None = None, service_exposures: Sequence[ServiceExposure] | None = None, + request_id: str | None = None, + timeout: float | None = None, ) -> SandboxSession: return SandboxSession( self, @@ -1185,6 +1390,8 @@ def create_session( name=name, labels=labels, service_exposures=service_exposures, + request_id=request_id, + timeout=timeout, ), ) @@ -1197,6 +1404,8 @@ def create_session_from_template( name: str | None = None, labels: Mapping[str, str] | None = None, service_exposures: Sequence[ServiceExposure] | None = None, + request_id: str | None = None, + timeout: float | None = None, ) -> SandboxSession: return SandboxSession( self, @@ -1207,6 +1416,8 @@ def create_session_from_template( name=name, labels=labels, service_exposures=service_exposures, + request_id=request_id, + timeout=timeout, ), ) @@ -1228,8 +1439,12 @@ def get( ) return _sandbox_ref(response.sandbox) - def get_session(self, name: str, *, workspace: str) -> SandboxSession: - return SandboxSession(self, self.get(name, workspace=workspace)) + def get_session( + self, name: str, *, workspace: str, timeout: float | None = None + ) -> SandboxSession: + return SandboxSession( + self, self.get(name, workspace=workspace, timeout=timeout) + ) def list( self, @@ -1264,12 +1479,14 @@ def list_all( page_size: int = 100, page_token: str = "", label_selector: str | None = None, + timeout: float | None = None, ) -> builtins.list[SandboxRef]: return self.list( workspace=workspace, page_size=page_size, page_token=page_token, label_selector=label_selector, + timeout=timeout, ).all() def list_for_all_workspaces( @@ -1303,11 +1520,13 @@ def list_all_for_all_workspaces( page_size: int = 100, page_token: str = "", label_selector: str | None = None, + timeout: float | None = None, ) -> builtins.list[SandboxRef]: return self.list_for_all_workspaces( page_size=page_size, page_token=page_token, label_selector=label_selector, + timeout=timeout, ).all() def list_ids( @@ -1316,6 +1535,7 @@ def list_ids( workspace: str, page_size: int = 100, label_selector: str | None = None, + timeout: float | None = None, ) -> builtins.list[str]: return [ item.id @@ -1323,6 +1543,7 @@ def list_ids( workspace=workspace, page_size=page_size, label_selector=label_selector, + timeout=timeout, ) ] @@ -1331,12 +1552,12 @@ def list_ids_for_all_workspaces( *, page_size: int = 100, label_selector: str | None = None, + timeout: float | None = None, ) -> builtins.list[str]: return [ item.id for item in self.list_all_for_all_workspaces( - page_size=page_size, - label_selector=label_selector, + page_size=page_size, label_selector=label_selector, timeout=timeout ) ] @@ -1655,6 +1876,8 @@ def exec( stdin: bytes | None = None, timeout_seconds: int | None = None, no_login_shell: bool = False, + request_id: str | None = None, + timeout: float | None = None, ) -> ExecResult: result: ExecResult | None = None stream = self.exec_stream( @@ -1666,6 +1889,8 @@ def exec( stdin=stdin, timeout_seconds=timeout_seconds, no_login_shell=no_login_shell, + request_id=request_id, + timeout=timeout, ) try: for item in stream: @@ -1696,12 +1921,12 @@ def exec_python( workdir: str | None = None, env: Mapping[str, str] | None = None, timeout_seconds: int | None = None, + request_id: str | None = None, + timeout: float | None = None, ) -> ExecResult: exec_env = dict(env or {}) exec_env["OPENSHELL_PYFUNC_B64"] = _serialize_python_callable( - function, - args=args, - kwargs=kwargs, + function, args=args, kwargs=kwargs ) return self.exec( sandbox, @@ -1711,6 +1936,8 @@ def exec_python( workdir=workdir, env=exec_env, timeout_seconds=timeout_seconds, + request_id=request_id, + timeout=timeout, ) def expose_service( @@ -1942,6 +2169,244 @@ def wait_completed( time.sleep(max(0.0, min(poll_interval, deadline - time.monotonic()))) raise SandboxError(f"sandbox {name} did not complete within timeout") + def attach_provider( + self, + sandbox: str, + provider: str, + *, + workspace: str, + expected_resource_version: int = 0, + request_id: str | None = None, + timeout: float | None = None, + ) -> openshell_pb2.AttachSandboxProviderResponse: + """Persist an attachment and return its receipt; this does not establish readiness.""" + return self._stub.AttachSandboxProvider( + openshell_pb2.AttachSandboxProviderRequest( + workspace_scope=_workspace_scope(workspace), + sandbox=sandbox, + provider=provider, + expected_resource_version=expected_resource_version, + request_id=request_id or "", + ), + timeout=self._timeout if timeout is None else timeout, + ) + + def detach_provider( + self, + sandbox: str, + provider: str, + *, + workspace: str, + expected_resource_version: int = 0, + request_id: str | None = None, + timeout: float | None = None, + ) -> openshell_pb2.DetachSandboxProviderResponse: + """Persist detachment; use the returned receipt to observe completed revocation.""" + return self._stub.DetachSandboxProvider( + openshell_pb2.DetachSandboxProviderRequest( + workspace_scope=_workspace_scope(workspace), + sandbox=sandbox, + provider=provider, + expected_resource_version=expected_resource_version, + request_id=request_id or "", + ), + timeout=self._timeout if timeout is None else timeout, + ) + + def list_providers( + self, + sandbox: str, + *, + workspace: str, + page_size: int = 100, + page_token: str = "", + timeout: float | None = None, + ) -> Pager[datamodel_pb2.Provider]: + """List the providers attached to this sandbox.""" + scope = _workspace_scope(workspace) + + def fetch(token: str) -> Page[datamodel_pb2.Provider]: + response = self._stub.ListSandboxProviders( + openshell_pb2.ListSandboxProvidersRequest( + workspace_scope=scope, + sandbox=sandbox, + page_size=page_size, + page_token=token, + ), + timeout=self._timeout if timeout is None else timeout, + ) + return Page(list(response.providers), response.next_page_token) + + return Pager(fetch, page_token) + + def list_all_providers( + self, + sandbox: str, + *, + workspace: str, + page_size: int = 100, + page_token: str = "", + timeout: float | None = None, + ) -> builtins.list[datamodel_pb2.Provider]: + return self.list_providers( + sandbox, + workspace=workspace, + page_size=page_size, + page_token=page_token, + timeout=timeout, + ).all() + + def get_provider_status( + self, + sandbox: str, + provider: str, + *, + workspace: str, + receipt_id: str = "", + timeout: float | None = None, + ) -> openshell_pb2.ProviderReadinessStatus: + """Query a mutation receipt or the current desired state when no receipt is supplied.""" + response = self._stub.GetSandboxProviderStatus( + openshell_pb2.GetSandboxProviderStatusRequest( + workspace_scope=_workspace_scope(workspace), + sandbox=sandbox, + provider=provider, + receipt_id=receipt_id, + ), + timeout=self._timeout if timeout is None else timeout, + ) + return response.status + + def get_config( + self, + name: str, + *, + workspace: str, + timeout: float | None = None, + ) -> sandbox_pb2.GetSandboxConfigResponse: + """Read effective configuration, including admission and provider revisions.""" + return self._stub.GetSandboxConfig( + sandbox_pb2.GetSandboxConfigRequest( + workspace_scope=_workspace_scope(workspace), name=name + ), + timeout=self._timeout if timeout is None else timeout, + ) + + def set_policy( + self, + sandbox: str, + policy: sandbox_pb2.SandboxPolicy, + *, + workspace: str, + expected_resource_version: int = 0, + annotations: Mapping[str, str] | None = None, + request_id: str | None = None, + timeout: float | None = None, + ) -> openshell_pb2.UpdateConfigResponse: + """Submit a sandbox policy revision; inspect get_policy_status() for application. + + Only network_policies may differ from the create-time policy. This + method never performs a global policy update or retries a conflict. + """ + return self._stub.UpdateConfig( + openshell_pb2.UpdateConfigRequest( + workspace_scope=_workspace_scope(workspace), + sandbox=sandbox, + policy=policy, + expected_resource_version=expected_resource_version, + annotations=dict(annotations or {}), + request_id=request_id or "", + ), + timeout=self._timeout if timeout is None else timeout, + ) + + def set_setting( + self, + sandbox: str, + key: str, + value: sandbox_pb2.SettingValue, + *, + workspace: str, + expected_resource_version: int = 0, + annotations: Mapping[str, str] | None = None, + request_id: str | None = None, + timeout: float | None = None, + ) -> openshell_pb2.UpdateConfigResponse: + """Upsert a sandbox-scoped setting, retaining typed false/zero/empty values.""" + return self._stub.UpdateConfig( + openshell_pb2.UpdateConfigRequest( + workspace_scope=_workspace_scope(workspace), + sandbox=sandbox, + setting_key=key, + setting_value=value, + expected_resource_version=expected_resource_version, + annotations=dict(annotations or {}), + request_id=request_id or "", + ), + timeout=self._timeout if timeout is None else timeout, + ) + + def get_policy_status( + self, + sandbox: str, + *, + workspace: str, + version: int = 0, + timeout: float | None = None, + ) -> openshell_pb2.GetSandboxPolicyStatusResponse: + """Inspect a particular revision, or the latest when version is zero.""" + return self._stub.GetSandboxPolicyStatus( + openshell_pb2.GetSandboxPolicyStatusRequest( + workspace_scope=_workspace_scope(workspace), + sandbox=sandbox, + version=version, + ), + timeout=self._timeout if timeout is None else timeout, + ) + + def list_policies( + self, + sandbox: str, + *, + workspace: str, + page_size: int = 100, + page_token: str = "", + timeout: float | None = None, + ) -> Pager[openshell_pb2.SandboxPolicyRevision]: + """List revision history without discarding failed historical projections.""" + scope = _workspace_scope(workspace) + + def fetch(token: str) -> Page[openshell_pb2.SandboxPolicyRevision]: + response = self._stub.ListSandboxPolicies( + openshell_pb2.ListSandboxPoliciesRequest( + workspace_scope=scope, + sandbox=sandbox, + page_size=page_size, + page_token=token, + ), + timeout=self._timeout if timeout is None else timeout, + ) + return Page(list(response.revisions), response.next_page_token) + + return Pager(fetch, page_token) + + def list_all_policies( + self, + sandbox: str, + *, + workspace: str, + page_size: int = 100, + page_token: str = "", + timeout: float | None = None, + ) -> builtins.list[openshell_pb2.SandboxPolicyRevision]: + return self.list_policies( + sandbox, + workspace=workspace, + page_size=page_size, + page_token=page_token, + timeout=timeout, + ).all() + class SandboxTemplateClient: """gRPC client for reusable sandbox template lifecycle operations.""" @@ -2052,12 +2517,14 @@ def list_all( page_size: int = 100, page_token: str = "", label_selector: str = "", + timeout: float | None = None, ) -> builtins.list[openshell_pb2.SandboxWorkloadTemplate]: return self.list( workspace=workspace, page_size=page_size, page_token=page_token, label_selector=label_selector, + timeout=timeout, ).all() def list_for_all_workspaces( @@ -2091,11 +2558,13 @@ def list_all_for_all_workspaces( page_size: int = 100, page_token: str = "", label_selector: str = "", + timeout: float | None = None, ) -> builtins.list[openshell_pb2.SandboxWorkloadTemplate]: return self.list_for_all_workspaces( page_size=page_size, page_token=page_token, label_selector=label_selector, + timeout=timeout, ).all() def delete( @@ -2201,11 +2670,13 @@ def list_all( page_size: int = 100, page_token: str = "", label_selector: str | None = None, + timeout: float | None = None, ) -> builtins.list[WorkspaceRef]: return self.list( page_size=page_size, page_token=page_token, label_selector=label_selector, + timeout=timeout, ).all() def delete( @@ -2507,6 +2978,8 @@ def _sandbox_ref( created_from_workload_template=provenance, service_urls=service_urls or {}, host_key_fingerprint=sandbox.host_key_fingerprint or None, + resource_version=sandbox.metadata.resource_version, + annotations=sandbox.metadata.annotations, )