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,495 @@
|
|
|
1
|
+
"""OpenEnv provider lifecycle entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import ssl
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from http import HTTPStatus
|
|
13
|
+
from math import isfinite
|
|
14
|
+
from time import monotonic, sleep
|
|
15
|
+
from types import MappingProxyType
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from openenv_openshell._adapter import (
|
|
21
|
+
CreateCollisionError,
|
|
22
|
+
CreateRequest,
|
|
23
|
+
DeleteNotSentError,
|
|
24
|
+
SandboxAdapter,
|
|
25
|
+
connect,
|
|
26
|
+
)
|
|
27
|
+
from openenv_openshell._compat import ContainerProvider
|
|
28
|
+
from openenv_openshell._naming import MAX_SANDBOX_NAME_LENGTH
|
|
29
|
+
from openenv_openshell.config import (
|
|
30
|
+
OpenShellProviderConfig,
|
|
31
|
+
OpenShellResources,
|
|
32
|
+
Policy,
|
|
33
|
+
validate_command,
|
|
34
|
+
)
|
|
35
|
+
from openenv_openshell.errors import (
|
|
36
|
+
OpenEnvReadinessTimeout,
|
|
37
|
+
OpenShellProviderError,
|
|
38
|
+
SandboxCreationError,
|
|
39
|
+
SandboxDeletionError,
|
|
40
|
+
SandboxReadinessError,
|
|
41
|
+
ServiceAccessError,
|
|
42
|
+
)
|
|
43
|
+
from openenv_openshell.metadata import (
|
|
44
|
+
OpenShellRunMetadata,
|
|
45
|
+
ProviderState,
|
|
46
|
+
package_version,
|
|
47
|
+
)
|
|
48
|
+
from openenv_openshell.policy import load_policy, policy_digest
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from collections.abc import Mapping, Sequence
|
|
52
|
+
|
|
53
|
+
_MAX_PORT = 65535
|
|
54
|
+
# Reserve "openenv-" and a six-hex suffix within the configured name limit.
|
|
55
|
+
_GENERATED_IMAGE_PREFIX_LENGTH = MAX_SANDBOX_NAME_LENGTH - len("openenv--") - 6
|
|
56
|
+
_LOGGER = logging.getLogger("openenv_openshell")
|
|
57
|
+
# TLS alerts for a route that demands a client certificate (TLS 1.3 and 1.2).
|
|
58
|
+
_CLIENT_CERT_ALERTS = frozenset(
|
|
59
|
+
{"TLSV13_ALERT_CERTIFICATE_REQUIRED", "SSLV3_ALERT_HANDSHAKE_FAILURE"}
|
|
60
|
+
)
|
|
61
|
+
_RESTRICTED_ALPHA_CLEANUP_MESSAGE = (
|
|
62
|
+
"Restricted-alpha automatic cleanup requires "
|
|
63
|
+
"restricted_alpha_cleanup_acknowledged=True and an exclusive, "
|
|
64
|
+
"single-tenant OpenShell gateway/workspace"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _service_access_failure(error: BaseException) -> str | None:
|
|
69
|
+
"""Describe a non-retryable TLS rejection without echoing transport text."""
|
|
70
|
+
seen: set[int] = set()
|
|
71
|
+
current: BaseException | None = error
|
|
72
|
+
while current is not None and id(current) not in seen:
|
|
73
|
+
seen.add(id(current))
|
|
74
|
+
if isinstance(current, ssl.SSLCertVerificationError):
|
|
75
|
+
return "OpenShell service route certificate is not trusted"
|
|
76
|
+
if isinstance(current, ssl.SSLError) and current.reason in _CLIENT_CERT_ALERTS:
|
|
77
|
+
return "OpenShell service route requires a TLS client certificate"
|
|
78
|
+
current = current.__cause__ or current.__context__
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OpenShellProvider(ContainerProvider):
|
|
83
|
+
"""Run one OpenEnv environment server in an OpenShell sandbox."""
|
|
84
|
+
|
|
85
|
+
def __init__( # noqa: PLR0913 - Mirrors the specified public constructor.
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
workspace: str = "default",
|
|
89
|
+
sandbox_name: str | None = None,
|
|
90
|
+
policy: Policy = None,
|
|
91
|
+
command: Sequence[str] | None = None,
|
|
92
|
+
service_port: int = 8000,
|
|
93
|
+
service_name: str = "",
|
|
94
|
+
startup_timeout_s: float = 120.0,
|
|
95
|
+
deletion_timeout_s: float = 60.0,
|
|
96
|
+
health_poll_interval_s: float = 0.5,
|
|
97
|
+
health_request_timeout_s: float = 2.0,
|
|
98
|
+
gateway: str | None = None,
|
|
99
|
+
keep_sandbox: bool = False,
|
|
100
|
+
restricted_alpha_cleanup_acknowledged: bool = False,
|
|
101
|
+
labels: Mapping[str, str] | None = None,
|
|
102
|
+
providers: Sequence[str] | None = None,
|
|
103
|
+
resources: OpenShellResources | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Create a provider without contacting an OpenShell gateway."""
|
|
106
|
+
self.config = OpenShellProviderConfig(
|
|
107
|
+
workspace=workspace,
|
|
108
|
+
sandbox_name=sandbox_name,
|
|
109
|
+
policy=policy,
|
|
110
|
+
command=command,
|
|
111
|
+
service_port=service_port,
|
|
112
|
+
service_name=service_name,
|
|
113
|
+
startup_timeout_s=startup_timeout_s,
|
|
114
|
+
deletion_timeout_s=deletion_timeout_s,
|
|
115
|
+
health_poll_interval_s=health_poll_interval_s,
|
|
116
|
+
health_request_timeout_s=health_request_timeout_s,
|
|
117
|
+
gateway=gateway,
|
|
118
|
+
keep_sandbox=keep_sandbox,
|
|
119
|
+
restricted_alpha_cleanup_acknowledged=(
|
|
120
|
+
restricted_alpha_cleanup_acknowledged
|
|
121
|
+
),
|
|
122
|
+
labels={} if labels is None else labels,
|
|
123
|
+
providers=() if providers is None else providers,
|
|
124
|
+
resources=resources,
|
|
125
|
+
)
|
|
126
|
+
self.state = ProviderState()
|
|
127
|
+
self._adapter: SandboxAdapter | None = None
|
|
128
|
+
self._metadata: OpenShellRunMetadata | None = None
|
|
129
|
+
self._deletion_attempted = False
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def metadata(self) -> OpenShellRunMetadata | None:
|
|
133
|
+
"""Return the latest immutable run snapshot, retained after cleanup."""
|
|
134
|
+
return self._metadata
|
|
135
|
+
|
|
136
|
+
def _sandbox_name_for_image(self, image: str) -> str:
|
|
137
|
+
"""Return the configured name or generate a safe, readable unique name."""
|
|
138
|
+
if self.config.sandbox_name is not None:
|
|
139
|
+
return self.config.sandbox_name
|
|
140
|
+
image_without_digest = image.rsplit("@", maxsplit=1)[0]
|
|
141
|
+
image_basename = image_without_digest.rsplit("/", maxsplit=1)[-1]
|
|
142
|
+
image_basename = image_basename.split(":", maxsplit=1)[0]
|
|
143
|
+
prefix = re.sub(r"[^a-z0-9]+", "-", image_basename.casefold()).strip("-")
|
|
144
|
+
prefix = prefix[:_GENERATED_IMAGE_PREFIX_LENGTH].rstrip("-") or "env"
|
|
145
|
+
return f"openenv-{prefix}-{secrets.token_hex(3)}"
|
|
146
|
+
|
|
147
|
+
def _connect_adapter(self) -> SandboxAdapter:
|
|
148
|
+
"""Select the registered gateway only when lifecycle work needs a client."""
|
|
149
|
+
return connect(gateway=self.config.gateway)
|
|
150
|
+
|
|
151
|
+
def _require_restricted_alpha_cleanup_acknowledgement(
|
|
152
|
+
self, error: type[OpenShellProviderError]
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Fail locally unless automatic deletion has the alpha's risk acceptance."""
|
|
155
|
+
if (
|
|
156
|
+
not self.config.keep_sandbox
|
|
157
|
+
and not self.config.restricted_alpha_cleanup_acknowledged
|
|
158
|
+
):
|
|
159
|
+
raise error(_RESTRICTED_ALPHA_CLEANUP_MESSAGE)
|
|
160
|
+
|
|
161
|
+
def _create_request(
|
|
162
|
+
self,
|
|
163
|
+
image: str,
|
|
164
|
+
*,
|
|
165
|
+
policy: Mapping[str, object] | None,
|
|
166
|
+
port: int | None = None,
|
|
167
|
+
env_vars: Mapping[str, str] | None = None,
|
|
168
|
+
**kwargs: object,
|
|
169
|
+
) -> CreateRequest:
|
|
170
|
+
"""Prepare adapter inputs with an already resolved, explicit policy.
|
|
171
|
+
|
|
172
|
+
Policy loading belongs to the policy boundary. None requests OpenShell's
|
|
173
|
+
image/default policy only when no explicit policy was configured.
|
|
174
|
+
"""
|
|
175
|
+
if kwargs:
|
|
176
|
+
msg = "Unsupported start_container options"
|
|
177
|
+
raise ValueError(msg)
|
|
178
|
+
if not isinstance(image, str) or not image.strip() or "\x00" in image: # pyright: ignore[reportUnnecessaryIsInstance]
|
|
179
|
+
msg = "image must be a non-empty OCI image reference"
|
|
180
|
+
raise ValueError(msg)
|
|
181
|
+
target_port = self.config.service_port if port is None else port
|
|
182
|
+
if (
|
|
183
|
+
isinstance(target_port, bool)
|
|
184
|
+
or not isinstance(target_port, int) # pyright: ignore[reportUnnecessaryIsInstance]
|
|
185
|
+
or not 1 <= target_port <= _MAX_PORT
|
|
186
|
+
):
|
|
187
|
+
msg = "port must be an integer from 1 through 65535"
|
|
188
|
+
raise ValueError(msg)
|
|
189
|
+
command = validate_command(self.config.command)
|
|
190
|
+
environment = {} if env_vars is None else dict(env_vars)
|
|
191
|
+
for key, value in environment.items():
|
|
192
|
+
if (
|
|
193
|
+
not isinstance(key, str) # pyright: ignore[reportUnnecessaryIsInstance]
|
|
194
|
+
or not isinstance(value, str) # pyright: ignore[reportUnnecessaryIsInstance]
|
|
195
|
+
or not key
|
|
196
|
+
or "=" in key
|
|
197
|
+
or "\x00" in key
|
|
198
|
+
or "\x00" in value
|
|
199
|
+
):
|
|
200
|
+
msg = "env_vars must contain valid environment names and string values"
|
|
201
|
+
raise ValueError(msg)
|
|
202
|
+
labels = dict(self.config.labels)
|
|
203
|
+
labels.update(
|
|
204
|
+
{"managed-by": "openenv-openshell", "openenv.provider": "openshell"}
|
|
205
|
+
)
|
|
206
|
+
return CreateRequest(
|
|
207
|
+
workspace=self.config.workspace,
|
|
208
|
+
name=self._sandbox_name_for_image(image),
|
|
209
|
+
image=image,
|
|
210
|
+
command=command,
|
|
211
|
+
environment=MappingProxyType(environment),
|
|
212
|
+
service_name=self.config.service_name,
|
|
213
|
+
target_port=target_port,
|
|
214
|
+
labels=MappingProxyType(labels),
|
|
215
|
+
providers=tuple(self.config.providers),
|
|
216
|
+
resources=self.config.resources,
|
|
217
|
+
policy=None if policy is None else MappingProxyType(deepcopy(dict(policy))),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _prepare_create_request(
|
|
221
|
+
self,
|
|
222
|
+
image: str,
|
|
223
|
+
*,
|
|
224
|
+
port: int | None,
|
|
225
|
+
env_vars: Mapping[str, str] | None,
|
|
226
|
+
**kwargs: object,
|
|
227
|
+
) -> tuple[CreateRequest, str | None]:
|
|
228
|
+
"""Resolve policy and preserve only its digest alongside startup inputs."""
|
|
229
|
+
policy = None if self.config.policy is None else load_policy(self.config.policy)
|
|
230
|
+
request = self._create_request(
|
|
231
|
+
image, policy=policy, port=port, env_vars=env_vars, **kwargs
|
|
232
|
+
)
|
|
233
|
+
digest = None if request.policy is None else policy_digest(request.policy)
|
|
234
|
+
return request, digest
|
|
235
|
+
|
|
236
|
+
def start_container(
|
|
237
|
+
self,
|
|
238
|
+
image: str,
|
|
239
|
+
port: int | None = None,
|
|
240
|
+
env_vars: Mapping[str, str] | None = None,
|
|
241
|
+
**kwargs: Any, # noqa: ANN401 - Required by the upstream provider contract.
|
|
242
|
+
) -> str:
|
|
243
|
+
"""Create the sandbox and return its OpenShell-managed service URL."""
|
|
244
|
+
if self._adapter is not None or (
|
|
245
|
+
self.state.sandbox_name is not None and not self.state.deleted
|
|
246
|
+
):
|
|
247
|
+
msg = "Provider already owns a sandbox; clean it up before starting again"
|
|
248
|
+
raise OpenShellProviderError(msg)
|
|
249
|
+
request, submitted_policy_digest = self._prepare_create_request(
|
|
250
|
+
image, port=port, env_vars=env_vars, **kwargs
|
|
251
|
+
)
|
|
252
|
+
self._require_restricted_alpha_cleanup_acknowledgement(OpenShellProviderError)
|
|
253
|
+
# Prepare state before connecting so state construction cannot leak a client.
|
|
254
|
+
state = ProviderState(sandbox_name=request.name, image=image)
|
|
255
|
+
adapter = self._connect_adapter()
|
|
256
|
+
self._adapter = adapter
|
|
257
|
+
# Retain the attempted name for diagnosis; it is not proof of ownership.
|
|
258
|
+
self.state = state
|
|
259
|
+
self._metadata = None
|
|
260
|
+
self._deletion_attempted = False
|
|
261
|
+
error = SandboxCreationError
|
|
262
|
+
created_identity: str | None = None
|
|
263
|
+
try:
|
|
264
|
+
_LOGGER.info("sandbox.create.started")
|
|
265
|
+
sandbox = adapter.create(request)
|
|
266
|
+
created_identity = sandbox.sandbox_id
|
|
267
|
+
self.state.sandbox_id = sandbox.sandbox_id
|
|
268
|
+
self.state.created = True
|
|
269
|
+
created_at = datetime.now(UTC)
|
|
270
|
+
_LOGGER.info("sandbox.create.completed")
|
|
271
|
+
base_url = adapter.service_url(sandbox, request.service_name)
|
|
272
|
+
if base_url is None:
|
|
273
|
+
msg = "OpenShell did not return the requested service URL"
|
|
274
|
+
raise SandboxCreationError(msg) # noqa: TRY301 - Stage-specific failure enters rollback.
|
|
275
|
+
self._validate_service_url(base_url)
|
|
276
|
+
self.state.base_url = base_url
|
|
277
|
+
self._metadata = OpenShellRunMetadata(
|
|
278
|
+
sandbox_name=request.name,
|
|
279
|
+
sandbox_id=sandbox.sandbox_id,
|
|
280
|
+
workspace=request.workspace,
|
|
281
|
+
image=image,
|
|
282
|
+
service_url=base_url,
|
|
283
|
+
policy_digest=submitted_policy_digest,
|
|
284
|
+
created_at=created_at,
|
|
285
|
+
openenv_provider_version=package_version("openenv-openshell"),
|
|
286
|
+
openshell_version=package_version("openshell"),
|
|
287
|
+
openenv_version=package_version("openenv"),
|
|
288
|
+
)
|
|
289
|
+
_LOGGER.info("service.exposed")
|
|
290
|
+
error = SandboxReadinessError
|
|
291
|
+
ready = adapter.wait_ready(
|
|
292
|
+
request.name,
|
|
293
|
+
workspace=request.workspace,
|
|
294
|
+
timeout_s=self.config.startup_timeout_s,
|
|
295
|
+
)
|
|
296
|
+
if ready.sandbox_id != sandbox.sandbox_id:
|
|
297
|
+
msg = "OpenShell readiness returned a different sandbox identity"
|
|
298
|
+
raise SandboxReadinessError(msg) # noqa: TRY301 - Stage-specific failure enters rollback.
|
|
299
|
+
except CreateCollisionError:
|
|
300
|
+
failure = self._rollback_create_collision()
|
|
301
|
+
raise failure from None
|
|
302
|
+
except BaseException as startup_failure:
|
|
303
|
+
if created_identity is not None:
|
|
304
|
+
self.state.sandbox_id = created_identity
|
|
305
|
+
if isinstance(startup_failure, Exception):
|
|
306
|
+
# Do not expose runtime text, workload argv, or route data.
|
|
307
|
+
failure = error("OpenShell sandbox startup failed")
|
|
308
|
+
self._cleanup_failed_start(failure)
|
|
309
|
+
raise failure from None
|
|
310
|
+
# Preserve cancellation/exit semantics; interrupted create does not
|
|
311
|
+
# prove ownership, so public cleanup still refuses deletion by name.
|
|
312
|
+
self._cleanup_failed_start(startup_failure)
|
|
313
|
+
raise
|
|
314
|
+
_LOGGER.info("sandbox.ready")
|
|
315
|
+
return base_url
|
|
316
|
+
|
|
317
|
+
def _rollback_create_collision(self) -> SandboxCreationError:
|
|
318
|
+
"""Release only the client when the attempted name belongs to another owner."""
|
|
319
|
+
self.state = ProviderState()
|
|
320
|
+
failure = SandboxCreationError(
|
|
321
|
+
"OpenShell sandbox name is already in use; choose another name"
|
|
322
|
+
)
|
|
323
|
+
self._cleanup_failed_start(failure)
|
|
324
|
+
return failure
|
|
325
|
+
|
|
326
|
+
def _cleanup_failed_start(self, failure: BaseException) -> None:
|
|
327
|
+
"""Use public cleanup without replacing the primary startup failure."""
|
|
328
|
+
try:
|
|
329
|
+
self.stop_container()
|
|
330
|
+
except BaseException: # noqa: BLE001 - Cleanup must not replace the primary failure.
|
|
331
|
+
# Notes appear in tracebacks without exposing runtime errors or inputs.
|
|
332
|
+
if self.state.sandbox_name is not None and not self.state.sandbox_id:
|
|
333
|
+
failure.add_note(
|
|
334
|
+
"Sandbox ownership is unconfirmed; operator inspection required "
|
|
335
|
+
"before removing the attempted name in provider.state.sandbox_name"
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
failure.add_note(
|
|
339
|
+
"OpenShell sandbox cleanup failed; "
|
|
340
|
+
"call stop_container again to retry"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def _record_deletion(self) -> None:
|
|
344
|
+
"""Record confirmed absence, independently of releasing the SDK client."""
|
|
345
|
+
if self._metadata is not None:
|
|
346
|
+
self._metadata = replace(self._metadata, deleted_at=datetime.now(UTC))
|
|
347
|
+
_LOGGER.info("sandbox.delete.completed")
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _validate_service_url(base_url: str) -> httpx.URL:
|
|
351
|
+
"""Reject unusable routes without echoing secret URL data."""
|
|
352
|
+
try:
|
|
353
|
+
url = httpx.URL(base_url)
|
|
354
|
+
except httpx.InvalidURL:
|
|
355
|
+
msg = "base_url must be an absolute HTTP(S) service URL"
|
|
356
|
+
raise ValueError(msg) from None
|
|
357
|
+
if (
|
|
358
|
+
url.scheme not in {"http", "https"}
|
|
359
|
+
or not url.host
|
|
360
|
+
or url.userinfo
|
|
361
|
+
or url.query
|
|
362
|
+
or url.fragment
|
|
363
|
+
):
|
|
364
|
+
msg = (
|
|
365
|
+
"base_url must be an absolute HTTP(S) URL "
|
|
366
|
+
"without credentials, query, or fragment"
|
|
367
|
+
)
|
|
368
|
+
raise ValueError(msg)
|
|
369
|
+
return url
|
|
370
|
+
|
|
371
|
+
def stop_container(self) -> None:
|
|
372
|
+
"""Release ownership after deletion; retain uncertain cleanup for retry.
|
|
373
|
+
|
|
374
|
+
With keep_sandbox, release the client and local ownership without deleting
|
|
375
|
+
the runtime. Failures are sanitized and can be retried by calling again.
|
|
376
|
+
"""
|
|
377
|
+
name = self.state.sandbox_name
|
|
378
|
+
if name is None and self._adapter is None:
|
|
379
|
+
return
|
|
380
|
+
try:
|
|
381
|
+
if (
|
|
382
|
+
name is not None
|
|
383
|
+
and not self.state.deleted
|
|
384
|
+
and not self.config.keep_sandbox
|
|
385
|
+
):
|
|
386
|
+
self._require_restricted_alpha_cleanup_acknowledgement(
|
|
387
|
+
SandboxDeletionError
|
|
388
|
+
)
|
|
389
|
+
identity = self.state.sandbox_id
|
|
390
|
+
if not identity:
|
|
391
|
+
# Local resources do not establish runtime ownership. Release
|
|
392
|
+
# them without reconnecting or discarding inspection state.
|
|
393
|
+
if self._adapter is not None:
|
|
394
|
+
self._adapter.close()
|
|
395
|
+
self._adapter = None
|
|
396
|
+
msg = (
|
|
397
|
+
"Sandbox ownership is unconfirmed; operator inspection required"
|
|
398
|
+
)
|
|
399
|
+
raise SandboxDeletionError(msg) # noqa: TRY301 - Sanitize all cleanup failures below.
|
|
400
|
+
if self._adapter is None:
|
|
401
|
+
self._adapter = self._connect_adapter()
|
|
402
|
+
_LOGGER.info("sandbox.delete.started")
|
|
403
|
+
if not self._deletion_attempted:
|
|
404
|
+
# A lost delete reply may still have been applied. Never send
|
|
405
|
+
# a second name-based delete; it could target a replacement.
|
|
406
|
+
self._deletion_attempted = True
|
|
407
|
+
try:
|
|
408
|
+
self._adapter.delete(
|
|
409
|
+
name,
|
|
410
|
+
workspace=self.config.workspace,
|
|
411
|
+
expected_sandbox_id=identity,
|
|
412
|
+
)
|
|
413
|
+
except DeleteNotSentError:
|
|
414
|
+
# Only a proven preflight failure permits the first
|
|
415
|
+
# delete to be retried after checking ownership again.
|
|
416
|
+
self._deletion_attempted = False
|
|
417
|
+
raise
|
|
418
|
+
self._adapter.wait_deleted(
|
|
419
|
+
name,
|
|
420
|
+
workspace=self.config.workspace,
|
|
421
|
+
expected_sandbox_id=identity,
|
|
422
|
+
timeout_s=self.config.deletion_timeout_s,
|
|
423
|
+
)
|
|
424
|
+
self.state.deleted = True
|
|
425
|
+
self.state.ready = False
|
|
426
|
+
self._record_deletion()
|
|
427
|
+
if self._adapter is not None:
|
|
428
|
+
self._adapter.close()
|
|
429
|
+
except Exception: # noqa: BLE001 - SDK errors may contain credentials.
|
|
430
|
+
_LOGGER.warning("provider.cleanup.failed")
|
|
431
|
+
msg = (
|
|
432
|
+
"OpenShell sandbox cleanup failed; call stop_container again to retry "
|
|
433
|
+
"confirmation or inspect the attempted sandbox with an operator"
|
|
434
|
+
)
|
|
435
|
+
raise SandboxDeletionError(msg) from None
|
|
436
|
+
self._adapter = None
|
|
437
|
+
self.state = ProviderState(deleted=self.state.deleted)
|
|
438
|
+
|
|
439
|
+
def close(self) -> None:
|
|
440
|
+
"""Delete the owned sandbox and release resources, including on context exit.
|
|
441
|
+
|
|
442
|
+
This has the same keep_sandbox and retry semantics as stop_container.
|
|
443
|
+
The inherited OpenEnv context manager calls close without suppressing
|
|
444
|
+
exceptions. Cleanup failures retain ownership for an explicit retry.
|
|
445
|
+
"""
|
|
446
|
+
self.stop_container()
|
|
447
|
+
|
|
448
|
+
def wait_for_ready(self, base_url: str, timeout_s: float = 30.0) -> None:
|
|
449
|
+
"""Wait until the OpenEnv server's health endpoint is ready.
|
|
450
|
+
|
|
451
|
+
Raises ServiceAccessError without retrying when TLS rejects the route's
|
|
452
|
+
certificate or requires a client certificate, since an unmodified
|
|
453
|
+
OpenEnv client cannot satisfy either. HTTP statuses, including 401 and
|
|
454
|
+
403, keep polling until the deadline.
|
|
455
|
+
"""
|
|
456
|
+
if isinstance(timeout_s, bool) or not isfinite(timeout_s) or timeout_s <= 0:
|
|
457
|
+
msg = "timeout_s must be a positive finite number"
|
|
458
|
+
raise ValueError(msg)
|
|
459
|
+
url = self._validate_service_url(base_url)
|
|
460
|
+
|
|
461
|
+
health_url = url.copy_with(raw_path=url.raw_path.rstrip(b"/") + b"/health")
|
|
462
|
+
self.state.ready = False
|
|
463
|
+
deadline = monotonic() + timeout_s
|
|
464
|
+
with httpx.Client(follow_redirects=False) as client:
|
|
465
|
+
while (remaining := deadline - monotonic()) > 0:
|
|
466
|
+
request_timeout = min(self.config.health_request_timeout_s, remaining)
|
|
467
|
+
try:
|
|
468
|
+
# Only headers matter; do not buffer an unbounded health body.
|
|
469
|
+
with client.stream(
|
|
470
|
+
"GET", health_url, timeout=request_timeout
|
|
471
|
+
) as response:
|
|
472
|
+
status = response.status_code
|
|
473
|
+
if status == HTTPStatus.OK and monotonic() < deadline:
|
|
474
|
+
self.state.ready = True
|
|
475
|
+
metadata = self.metadata
|
|
476
|
+
if metadata is not None and base_url == self.state.base_url:
|
|
477
|
+
self._metadata = replace(
|
|
478
|
+
metadata, ready_at=datetime.now(UTC)
|
|
479
|
+
)
|
|
480
|
+
_LOGGER.info("openenv.health.ready")
|
|
481
|
+
return
|
|
482
|
+
denied = None
|
|
483
|
+
except httpx.RequestError as error:
|
|
484
|
+
# Transport messages may contain credentials or URL data.
|
|
485
|
+
denied = _service_access_failure(error)
|
|
486
|
+
if denied is not None:
|
|
487
|
+
# Raised outside the handler so no transport error is chained.
|
|
488
|
+
_LOGGER.warning("openenv.health.access_denied")
|
|
489
|
+
raise ServiceAccessError(denied)
|
|
490
|
+
remaining = deadline - monotonic()
|
|
491
|
+
if remaining > 0:
|
|
492
|
+
sleep(min(self.config.health_poll_interval_s, remaining))
|
|
493
|
+
|
|
494
|
+
msg = "OpenEnv health readiness timed out"
|
|
495
|
+
raise OpenEnvReadinessTimeout(msg)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|