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.
@@ -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,7 @@
1
+ """Imports that isolate this package from changes in upstream OpenEnv."""
2
+
3
+ from openenv.core.containers.runtime.providers import ( # pyright: ignore[reportMissingTypeStubs]
4
+ ContainerProvider,
5
+ )
6
+
7
+ __all__ = ["ContainerProvider"]
@@ -0,0 +1,4 @@
1
+ """Shared limits for sandbox naming on the pinned gateway."""
2
+
3
+ # OpenShell 0.1.2 grpc::MAX_ROUTABLE_NAME_LEN.
4
+ MAX_SANDBOX_NAME_LENGTH = 19
@@ -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