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,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
+