openenv-openshell 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openenv_openshell/__init__.py +30 -0
- openenv_openshell/_adapter.py +139 -0
- openenv_openshell/_compat.py +7 -0
- openenv_openshell/_naming.py +4 -0
- openenv_openshell/_sdk.py +324 -0
- openenv_openshell/config.py +249 -0
- openenv_openshell/errors.py +37 -0
- openenv_openshell/metadata.py +50 -0
- openenv_openshell/policy.py +108 -0
- openenv_openshell/provider.py +495 -0
- openenv_openshell/py.typed +1 -0
- openenv_openshell-0.1.1.dist-info/METADATA +296 -0
- openenv_openshell-0.1.1.dist-info/RECORD +15 -0
- openenv_openshell-0.1.1.dist-info/WHEEL +4 -0
- openenv_openshell-0.1.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""OpenEnv container provider backed by NVIDIA OpenShell."""
|
|
2
|
+
|
|
3
|
+
from openenv_openshell.config import OpenShellProviderConfig, OpenShellResources
|
|
4
|
+
from openenv_openshell.errors import (
|
|
5
|
+
OpenEnvReadinessTimeout,
|
|
6
|
+
OpenShellConnectionError,
|
|
7
|
+
OpenShellProviderError,
|
|
8
|
+
PolicyConfigurationError,
|
|
9
|
+
SandboxCreationError,
|
|
10
|
+
SandboxDeletionError,
|
|
11
|
+
SandboxReadinessError,
|
|
12
|
+
ServiceAccessError,
|
|
13
|
+
)
|
|
14
|
+
from openenv_openshell.metadata import OpenShellRunMetadata
|
|
15
|
+
from openenv_openshell.provider import OpenShellProvider
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"OpenEnvReadinessTimeout",
|
|
19
|
+
"OpenShellConnectionError",
|
|
20
|
+
"OpenShellProvider",
|
|
21
|
+
"OpenShellProviderConfig",
|
|
22
|
+
"OpenShellProviderError",
|
|
23
|
+
"OpenShellResources",
|
|
24
|
+
"OpenShellRunMetadata",
|
|
25
|
+
"PolicyConfigurationError",
|
|
26
|
+
"SandboxCreationError",
|
|
27
|
+
"SandboxDeletionError",
|
|
28
|
+
"SandboxReadinessError",
|
|
29
|
+
"ServiceAccessError",
|
|
30
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""SDK-independent types for the private runtime boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
from typing import TYPE_CHECKING, Literal, Protocol
|
|
9
|
+
|
|
10
|
+
from openenv_openshell.errors import (
|
|
11
|
+
OpenShellConnectionError,
|
|
12
|
+
SandboxCreationError,
|
|
13
|
+
SandboxDeletionError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
|
|
19
|
+
from openenv_openshell.config import OpenShellResources
|
|
20
|
+
|
|
21
|
+
SDK_VERSION = "0.1.2"
|
|
22
|
+
_INSTALL_HINT = (
|
|
23
|
+
"Install the official hash-pinned OpenShell 0.1.2 wheel; "
|
|
24
|
+
"see https://github.com/rodrigosetti/openenv-openshell/blob/main/docs/releases.md "
|
|
25
|
+
"or use uv sync --locked in a source checkout."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CreateCollisionError(SandboxCreationError):
|
|
30
|
+
"""The gateway rejected create because the name was already occupied."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DeleteNotSentError(SandboxDeletionError):
|
|
34
|
+
"""The adapter proves no delete RPC was sent; ownership preflight may retry."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class CreateRequest:
|
|
39
|
+
"""Resolved inputs; policy uses normalized protobuf field names."""
|
|
40
|
+
|
|
41
|
+
workspace: str
|
|
42
|
+
name: str
|
|
43
|
+
image: str
|
|
44
|
+
command: Sequence[str] = field(repr=False)
|
|
45
|
+
environment: Mapping[str, str] = field(repr=False)
|
|
46
|
+
service_name: str
|
|
47
|
+
target_port: int
|
|
48
|
+
labels: Mapping[str, str]
|
|
49
|
+
providers: Sequence[str]
|
|
50
|
+
resources: OpenShellResources | None
|
|
51
|
+
policy: Mapping[str, object] | None = field(repr=False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class Sandbox:
|
|
56
|
+
"""Sandbox identity and create-time routes, independent of generated models."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
sandbox_id: str
|
|
60
|
+
service_urls: Mapping[str, str] = field(
|
|
61
|
+
default_factory=lambda: dict[str, str](), repr=False
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class Deletion:
|
|
67
|
+
"""Preserve deletion outcome and identity, including uncertain completion."""
|
|
68
|
+
|
|
69
|
+
sandbox_id: str | None
|
|
70
|
+
outcome: Literal["completed", "accepted", "already_absent", "unknown"] = "accepted"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SandboxAdapter(Protocol):
|
|
74
|
+
"""The small synchronous lifecycle surface consumed by the provider."""
|
|
75
|
+
|
|
76
|
+
def create(self, request: CreateRequest) -> Sandbox:
|
|
77
|
+
"""Create a workload and its service atomically."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
def wait_ready(
|
|
81
|
+
self, sandbox_name: str, *, workspace: str, timeout_s: float
|
|
82
|
+
) -> Sandbox:
|
|
83
|
+
"""Wait for sandbox readiness."""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
def service_url(self, sandbox: Sandbox, service_name: str) -> str | None:
|
|
87
|
+
"""Read a route from the create result, before readiness replaces it."""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
def delete(
|
|
91
|
+
self, sandbox_name: str, *, workspace: str, expected_sandbox_id: str
|
|
92
|
+
) -> Deletion:
|
|
93
|
+
"""Check identity before requesting deletion, tolerating absence.
|
|
94
|
+
|
|
95
|
+
The pinned API cannot make this check atomic with deletion.
|
|
96
|
+
Raise DeleteNotSentError only when no delete RPC was dispatched. All
|
|
97
|
+
other failures must be treated as potentially applied deletion.
|
|
98
|
+
"""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
def wait_deleted(
|
|
102
|
+
self,
|
|
103
|
+
sandbox_name: str,
|
|
104
|
+
*,
|
|
105
|
+
workspace: str,
|
|
106
|
+
expected_sandbox_id: str,
|
|
107
|
+
timeout_s: float,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Wait for deletion of the original identity."""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
def close(self) -> None:
|
|
113
|
+
"""Release client resources; does not delete sandboxes."""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ensure_sdk() -> None:
|
|
118
|
+
"""Validate and load the pinned model boundary without gateway access."""
|
|
119
|
+
try:
|
|
120
|
+
installed = version("openshell")
|
|
121
|
+
except PackageNotFoundError:
|
|
122
|
+
raise OpenShellConnectionError(_INSTALL_HINT) from None
|
|
123
|
+
if installed != SDK_VERSION:
|
|
124
|
+
raise OpenShellConnectionError(_INSTALL_HINT)
|
|
125
|
+
try:
|
|
126
|
+
import_module("openenv_openshell._sdk")
|
|
127
|
+
except Exception: # noqa: BLE001 - Translate arbitrary SDK import initialization failures.
|
|
128
|
+
# SDK initialization can fail beyond ImportError (e.g. protobuf errors).
|
|
129
|
+
raise OpenShellConnectionError(_INSTALL_HINT) from None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def connect(*, gateway: str | None = None) -> SandboxAdapter:
|
|
133
|
+
"""Load the pinned SDK lazily and connect to a registered gateway."""
|
|
134
|
+
ensure_sdk()
|
|
135
|
+
from openenv_openshell._sdk import ( # noqa: PLC0415 - Guarded lazy SDK boundary.
|
|
136
|
+
SDKAdapter,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return SDKAdapter.connect(gateway=gateway)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""OpenShell 0.1.2 imports and wire models confined to one private module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import TYPE_CHECKING, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
import grpc
|
|
10
|
+
from google.protobuf.descriptor import Descriptor, FieldDescriptor
|
|
11
|
+
from google.protobuf.json_format import MessageToDict, ParseDict, ParseError
|
|
12
|
+
from openshell import (
|
|
13
|
+
DeletionOutcome,
|
|
14
|
+
SandboxClient,
|
|
15
|
+
SandboxError,
|
|
16
|
+
SandboxRef,
|
|
17
|
+
ServiceExposure,
|
|
18
|
+
)
|
|
19
|
+
from openshell._proto.openshell_pb2 import SandboxSpec, SandboxTemplate
|
|
20
|
+
from openshell._proto.sandbox_pb2 import SandboxPolicy
|
|
21
|
+
|
|
22
|
+
from openenv_openshell._adapter import (
|
|
23
|
+
SDK_VERSION,
|
|
24
|
+
CreateCollisionError,
|
|
25
|
+
CreateRequest,
|
|
26
|
+
DeleteNotSentError,
|
|
27
|
+
Deletion,
|
|
28
|
+
Sandbox,
|
|
29
|
+
)
|
|
30
|
+
from openenv_openshell.config import validate_command
|
|
31
|
+
from openenv_openshell.errors import (
|
|
32
|
+
OpenShellConnectionError,
|
|
33
|
+
OpenShellProviderError,
|
|
34
|
+
PolicyConfigurationError,
|
|
35
|
+
SandboxCreationError,
|
|
36
|
+
SandboxDeletionError,
|
|
37
|
+
SandboxReadinessError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Callable, Mapping
|
|
42
|
+
|
|
43
|
+
_Result = TypeVar("_Result")
|
|
44
|
+
_CONNECTION_CODES = {
|
|
45
|
+
grpc.StatusCode.UNAVAILABLE,
|
|
46
|
+
grpc.StatusCode.UNAUTHENTICATED,
|
|
47
|
+
grpc.StatusCode.PERMISSION_DENIED,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_POLICY_ERROR = "Invalid explicit OpenShell policy; check the supported 0.1.2 schema."
|
|
51
|
+
_ENUM_PREFIXES = {
|
|
52
|
+
"tls": "NETWORK_TLS_MODE_",
|
|
53
|
+
"enforcement": "NETWORK_ENFORCEMENT_MODE_",
|
|
54
|
+
"access": "NETWORK_ACCESS_PRESET_",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _policy_value(value: object, field: FieldDescriptor) -> object:
|
|
59
|
+
if field.message_type is not None:
|
|
60
|
+
return _policy_fields(value, field.message_type)
|
|
61
|
+
if field.type == FieldDescriptor.TYPE_STRING:
|
|
62
|
+
valid = isinstance(value, str)
|
|
63
|
+
elif field.type == FieldDescriptor.TYPE_BOOL:
|
|
64
|
+
valid = isinstance(value, bool)
|
|
65
|
+
elif field.enum_type is not None:
|
|
66
|
+
# Authored YAML uses short lowercase enum spellings.
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
for name in field.enum_type.values_by_name:
|
|
69
|
+
if (
|
|
70
|
+
name.removeprefix(_ENUM_PREFIXES.get(field.name, ""))
|
|
71
|
+
== value.upper()
|
|
72
|
+
):
|
|
73
|
+
return name
|
|
74
|
+
valid = isinstance(value, str) and value in field.enum_type.values_by_name
|
|
75
|
+
else:
|
|
76
|
+
valid = type(value) is int
|
|
77
|
+
if not valid:
|
|
78
|
+
raise PolicyConfigurationError(_POLICY_ERROR)
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _policy_fields(value: object, descriptor: Descriptor) -> dict[str, object]:
|
|
83
|
+
if not isinstance(value, dict):
|
|
84
|
+
raise PolicyConfigurationError(_POLICY_ERROR)
|
|
85
|
+
result: dict[str, object] = {}
|
|
86
|
+
for key, child in cast("dict[str, object]", value).items():
|
|
87
|
+
field = descriptor.fields_by_name.get(key)
|
|
88
|
+
if field is None:
|
|
89
|
+
raise PolicyConfigurationError(_POLICY_ERROR)
|
|
90
|
+
if field.is_repeated:
|
|
91
|
+
if field.message_type and field.message_type.GetOptions().map_entry:
|
|
92
|
+
if not isinstance(child, dict):
|
|
93
|
+
raise PolicyConfigurationError(_POLICY_ERROR)
|
|
94
|
+
item_field = field.message_type.fields_by_name["value"]
|
|
95
|
+
result[key] = {
|
|
96
|
+
name: _policy_value(item, item_field)
|
|
97
|
+
for name, item in cast("dict[str, object]", child).items()
|
|
98
|
+
}
|
|
99
|
+
else:
|
|
100
|
+
if not isinstance(child, list):
|
|
101
|
+
raise PolicyConfigurationError(_POLICY_ERROR)
|
|
102
|
+
result[key] = [
|
|
103
|
+
_policy_value(item, field) for item in cast("list[object]", child)
|
|
104
|
+
]
|
|
105
|
+
else:
|
|
106
|
+
result[key] = _policy_value(child, field)
|
|
107
|
+
return dict(sorted(result.items()))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def normalize_policy(policy: Mapping[str, object]) -> dict[str, object]:
|
|
111
|
+
"""Validate exact field shapes before strict conversion, without a gateway."""
|
|
112
|
+
if type(policy.get("version")) is not int or policy["version"] != 1:
|
|
113
|
+
raise PolicyConfigurationError(_POLICY_ERROR)
|
|
114
|
+
fields = _policy_fields(dict(policy), cast("Descriptor", SandboxPolicy.DESCRIPTOR))
|
|
115
|
+
landlock = fields.get("landlock")
|
|
116
|
+
if isinstance(landlock, dict) and cast("dict[str, object]", landlock).get(
|
|
117
|
+
"compatibility"
|
|
118
|
+
) not in {
|
|
119
|
+
"best_effort",
|
|
120
|
+
"hard_requirement",
|
|
121
|
+
}:
|
|
122
|
+
raise PolicyConfigurationError(_POLICY_ERROR)
|
|
123
|
+
try:
|
|
124
|
+
model = ParseDict(fields, SandboxPolicy(), ignore_unknown_fields=False)
|
|
125
|
+
except (ParseError, ValueError, TypeError):
|
|
126
|
+
raise PolicyConfigurationError(_POLICY_ERROR) from None
|
|
127
|
+
# JSON output uses exact snake_case fields and canonical enum names.
|
|
128
|
+
return MessageToDict(model, preserving_proto_field_name=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _call(
|
|
132
|
+
operation: Callable[[], _Result], error: type[OpenShellProviderError]
|
|
133
|
+
) -> _Result:
|
|
134
|
+
try:
|
|
135
|
+
return operation()
|
|
136
|
+
except grpc.RpcError as failure:
|
|
137
|
+
# GatewayError is a public RpcError subtype. Never copy details/metadata.
|
|
138
|
+
if (
|
|
139
|
+
error is SandboxCreationError
|
|
140
|
+
and failure.code() == grpc.StatusCode.ALREADY_EXISTS
|
|
141
|
+
):
|
|
142
|
+
msg = "OpenShell sandbox name is already in use; choose another name."
|
|
143
|
+
raise CreateCollisionError(msg) from None
|
|
144
|
+
if failure.code() in _CONNECTION_CODES:
|
|
145
|
+
msg = (
|
|
146
|
+
"Cannot reach or authenticate to OpenShell; "
|
|
147
|
+
"check gateway and credentials."
|
|
148
|
+
)
|
|
149
|
+
raise OpenShellConnectionError(msg) from None
|
|
150
|
+
msg = "OpenShell operation failed; check gateway diagnostics."
|
|
151
|
+
raise error(msg) from None
|
|
152
|
+
except (SandboxError, OSError, ValueError):
|
|
153
|
+
msg = "OpenShell operation failed; check gateway diagnostics."
|
|
154
|
+
raise error(msg) from None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _sandbox(result: SandboxRef) -> Sandbox:
|
|
158
|
+
return Sandbox(result.name, result.id, MappingProxyType(dict(result.service_urls)))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SDKAdapter:
|
|
162
|
+
"""Translate stable project inputs to the pinned public SDK lifecycle calls."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, client: SandboxClient) -> None:
|
|
165
|
+
"""Wrap a client; the connection factory verifies gateway compatibility."""
|
|
166
|
+
self._client = client
|
|
167
|
+
self._closed = False
|
|
168
|
+
self._gateway_checked = False
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def connect(cls, *, gateway: str | None = None) -> SDKAdapter:
|
|
172
|
+
"""Connect through the CLI gateway registry without insecure overrides."""
|
|
173
|
+
client = _call(
|
|
174
|
+
lambda: SandboxClient.from_active_cluster(cluster=gateway),
|
|
175
|
+
OpenShellConnectionError,
|
|
176
|
+
)
|
|
177
|
+
adapter = cls(client)
|
|
178
|
+
try:
|
|
179
|
+
adapter._check_gateway()
|
|
180
|
+
except OpenShellProviderError:
|
|
181
|
+
# Failure to close must not replace the actionable connection error.
|
|
182
|
+
with suppress(OpenShellProviderError):
|
|
183
|
+
adapter.close()
|
|
184
|
+
raise
|
|
185
|
+
return adapter
|
|
186
|
+
|
|
187
|
+
def _check_gateway(self) -> None:
|
|
188
|
+
if self._closed:
|
|
189
|
+
msg = "OpenShell client is closed; create a new adapter."
|
|
190
|
+
raise OpenShellConnectionError(msg)
|
|
191
|
+
if not self._gateway_checked:
|
|
192
|
+
health = _call(self._client.health, OpenShellConnectionError)
|
|
193
|
+
if health.version != SDK_VERSION:
|
|
194
|
+
msg = "Use an OpenShell 0.1.2 gateway with the pinned SDK."
|
|
195
|
+
raise OpenShellConnectionError(msg)
|
|
196
|
+
self._gateway_checked = True
|
|
197
|
+
|
|
198
|
+
def create(self, request: CreateRequest) -> Sandbox:
|
|
199
|
+
"""Build private models and request an atomic service exposure."""
|
|
200
|
+
spec = SandboxSpec(template=SandboxTemplate(image=request.image))
|
|
201
|
+
spec.command.extend(validate_command(request.command))
|
|
202
|
+
spec.environment.update(request.environment)
|
|
203
|
+
spec.providers.extend(request.providers)
|
|
204
|
+
resources = request.resources
|
|
205
|
+
if resources is not None:
|
|
206
|
+
limits: dict[str, str] = {}
|
|
207
|
+
if resources.cpu is not None:
|
|
208
|
+
limits["cpu"] = str(resources.cpu)
|
|
209
|
+
if resources.memory is not None:
|
|
210
|
+
limits["memory"] = resources.memory
|
|
211
|
+
if limits:
|
|
212
|
+
spec.template.resources.update({"limits": limits})
|
|
213
|
+
if resources.gpu_count is not None:
|
|
214
|
+
spec.resource_requirements.gpu.count = resources.gpu_count
|
|
215
|
+
if request.policy is not None:
|
|
216
|
+
try:
|
|
217
|
+
policy = ParseDict(
|
|
218
|
+
normalize_policy(request.policy),
|
|
219
|
+
SandboxPolicy(),
|
|
220
|
+
ignore_unknown_fields=False,
|
|
221
|
+
)
|
|
222
|
+
except (ParseError, ValueError, TypeError):
|
|
223
|
+
msg = "Invalid normalized OpenShell policy; check the 0.1.2 schema."
|
|
224
|
+
raise PolicyConfigurationError(msg) from None
|
|
225
|
+
spec.policy.CopyFrom(policy) # pyright: ignore[reportUnknownMemberType] - Upstream cross-module stub.
|
|
226
|
+
# Explicit policy errors must precede even gateway access. Never create
|
|
227
|
+
# with an omitted/default policy after a conversion failure.
|
|
228
|
+
self._check_gateway()
|
|
229
|
+
return _sandbox(
|
|
230
|
+
_call(
|
|
231
|
+
lambda: self._client.create(
|
|
232
|
+
workspace=request.workspace,
|
|
233
|
+
name=request.name,
|
|
234
|
+
spec=spec,
|
|
235
|
+
labels=request.labels,
|
|
236
|
+
service_exposures=[
|
|
237
|
+
ServiceExposure(
|
|
238
|
+
target_port=request.target_port,
|
|
239
|
+
service=request.service_name,
|
|
240
|
+
)
|
|
241
|
+
],
|
|
242
|
+
),
|
|
243
|
+
SandboxCreationError,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def wait_ready(
|
|
248
|
+
self, sandbox_name: str, *, workspace: str, timeout_s: float
|
|
249
|
+
) -> Sandbox:
|
|
250
|
+
"""Translate readiness timeouts and detach the returned SDK reference."""
|
|
251
|
+
return _sandbox(
|
|
252
|
+
_call(
|
|
253
|
+
lambda: self._client.wait_ready(
|
|
254
|
+
sandbox_name, workspace=workspace, timeout_seconds=timeout_s
|
|
255
|
+
),
|
|
256
|
+
SandboxReadinessError,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def service_url(self, sandbox: Sandbox, service_name: str) -> str | None:
|
|
261
|
+
"""Use only the captured create-time route; never query get()."""
|
|
262
|
+
return sandbox.service_urls.get(service_name)
|
|
263
|
+
|
|
264
|
+
def delete(
|
|
265
|
+
self, sandbox_name: str, *, workspace: str, expected_sandbox_id: str
|
|
266
|
+
) -> Deletion:
|
|
267
|
+
"""Reject observed replacements before issuing the name-based delete.
|
|
268
|
+
|
|
269
|
+
This preflight is not an atomic identity condition: OpenShell 0.1.2
|
|
270
|
+
has no public conditional delete. See docs/cleanup-tests.md.
|
|
271
|
+
"""
|
|
272
|
+
if not expected_sandbox_id:
|
|
273
|
+
msg = "Sandbox ownership is unconfirmed; inspect gateway diagnostics."
|
|
274
|
+
raise DeleteNotSentError(msg)
|
|
275
|
+
try:
|
|
276
|
+
current = self._client.get(sandbox_name, workspace=workspace)
|
|
277
|
+
except grpc.RpcError as failure:
|
|
278
|
+
if failure.code() == grpc.StatusCode.NOT_FOUND:
|
|
279
|
+
return Deletion(expected_sandbox_id, "already_absent")
|
|
280
|
+
msg = "Cannot verify sandbox ownership; retry cleanup."
|
|
281
|
+
raise DeleteNotSentError(msg) from None
|
|
282
|
+
except (SandboxError, OSError, ValueError):
|
|
283
|
+
msg = "Cannot verify sandbox ownership; retry cleanup."
|
|
284
|
+
raise DeleteNotSentError(msg) from None
|
|
285
|
+
if current.id != expected_sandbox_id:
|
|
286
|
+
return Deletion(expected_sandbox_id, "already_absent")
|
|
287
|
+
result = _call(
|
|
288
|
+
lambda: self._client.delete(
|
|
289
|
+
sandbox_name, workspace=workspace, allow_missing=True
|
|
290
|
+
),
|
|
291
|
+
SandboxDeletionError,
|
|
292
|
+
)
|
|
293
|
+
if result.outcome == DeletionOutcome.COMPLETED:
|
|
294
|
+
return Deletion(result.sandbox_id, "completed")
|
|
295
|
+
if result.outcome == DeletionOutcome.ACCEPTED:
|
|
296
|
+
return Deletion(result.sandbox_id, "accepted")
|
|
297
|
+
if result.outcome == DeletionOutcome.ALREADY_ABSENT:
|
|
298
|
+
return Deletion(result.sandbox_id, "already_absent")
|
|
299
|
+
return Deletion(result.sandbox_id, "unknown")
|
|
300
|
+
|
|
301
|
+
def wait_deleted(
|
|
302
|
+
self,
|
|
303
|
+
sandbox_name: str,
|
|
304
|
+
*,
|
|
305
|
+
workspace: str,
|
|
306
|
+
expected_sandbox_id: str,
|
|
307
|
+
timeout_s: float,
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Pass the original sandbox identity to the public deletion waiter."""
|
|
310
|
+
_call(
|
|
311
|
+
lambda: self._client.wait_deleted(
|
|
312
|
+
sandbox_name,
|
|
313
|
+
workspace=workspace,
|
|
314
|
+
expected_sandbox_id=expected_sandbox_id,
|
|
315
|
+
timeout_seconds=timeout_s,
|
|
316
|
+
),
|
|
317
|
+
SandboxDeletionError,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def close(self) -> None:
|
|
321
|
+
"""Close the client idempotently, allowing retries after a close failure."""
|
|
322
|
+
if not self._closed:
|
|
323
|
+
_call(self._client.close, OpenShellConnectionError)
|
|
324
|
+
self._closed = True
|