opensandbox-server 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/.gitignore +1 -0
- opensandbox_server-0.2.2/PKG-INFO +46 -0
- opensandbox_server-0.2.2/README.md +4 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/devops.py +12 -1
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/lifecycle.py +12 -3
- opensandbox_server-0.2.2/opensandbox_server/api/metrics.py +83 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/proxy.py +122 -17
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/schema.py +68 -9
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/cli.py +1 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/config.py +123 -13
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.k8s.toml +4 -4
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.k8s.zh.toml +4 -4
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.toml +9 -4
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.zh.toml +9 -4
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/extensions/__init__.py +8 -2
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/extensions/codec.py +23 -3
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/extensions/keys.py +9 -0
- opensandbox_server-0.2.2/opensandbox_server/integrations/otel/__init__.py +27 -0
- opensandbox_server-0.2.2/opensandbox_server/integrations/otel/metrics.py +166 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/consumer.py +2 -2
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/main.py +49 -7
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/middleware/auth.py +58 -39
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/repositories/snapshots/sqlite.py +80 -1
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/constants.py +21 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/__init__.py +1 -2
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/container_ops.py +2 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/docker_diagnostics.py +13 -3
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/docker_service.py +183 -192
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/metadata.py +30 -8
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/networking.py +81 -23
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/port_allocator.py +3 -1
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/runtime.py +36 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/snapshot_runtime.py +12 -3
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/helpers.py +35 -1
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/agent_sandbox_provider.py +33 -10
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/batchsandbox_provider.py +40 -7
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/client.py +60 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/create_helpers.py +32 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/egress_helper.py +12 -7
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/informer.py +23 -4
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/k8s_diagnostics.py +121 -5
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/kubernetes_service.py +696 -84
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/pool_service.py +24 -22
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/provider_common.py +34 -2
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/security_context.py +14 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/snapshot_runtime.py +24 -15
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/volume_helper.py +32 -1
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/windows_profile.py +16 -4
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/workload_mapper.py +4 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/workload_provider.py +3 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/runtime_resolver.py +17 -1
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/sandbox_service.py +12 -1
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_models.py +1 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_repository.py +2 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_restore.py +11 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_runtime.py +8 -4
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_service.py +38 -4
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/validators.py +77 -3
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/startup_guard.py +4 -4
- opensandbox_server-0.2.2/opensandbox_server/tenants/__init__.py +65 -0
- opensandbox_server-0.2.2/opensandbox_server/tenants/context.py +30 -0
- opensandbox_server-0.2.2/opensandbox_server/tenants/file_provider.py +188 -0
- opensandbox_server-0.2.2/opensandbox_server/tenants/http_provider.py +235 -0
- opensandbox_server-0.2.2/opensandbox_server/tenants/models.py +25 -0
- opensandbox_server-0.2.2/opensandbox_server/tenants/provider.py +66 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/pyproject.toml +10 -5
- opensandbox_server-0.2.0/PKG-INFO +0 -327
- opensandbox_server-0.2.0/README.md +0 -290
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/LICENSE +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/pool.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/.gitkeep +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/e2e.batchsandbox-template.yaml +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.batchsandbox-template.yaml +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/extensions/validation.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/constants.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/controller.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/intent.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/logutil.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/proxy_renew.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/redis_client.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/runner.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/logging_config.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/middleware/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/middleware/request_id.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/py.typed +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/repositories/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/repositories/snapshots/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/repositories/snapshots/factory.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/ossfs_mixin.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/volumes.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/windows_profile.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/endpoint_auth.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/extension_service.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/factory.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/__init__.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/agent_sandbox_template.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/batchsandbox_template.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/endpoint_resolver.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/error_helpers.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/image_pull_secret_helper.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/label_selector.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/list_helpers.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/provider_factory.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/rate_limiter.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/status_helpers.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/template_manager.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/workload_access.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/signing.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_runtime_factory.py +0 -0
- {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/windows_common.py +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opensandbox-server
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: FastAPI control plane for OpenSandbox that manages sandbox lifecycle on Docker (ready) and Kubernetes (planned) runtimes.
|
|
5
|
+
Project-URL: Homepage, https://github.com/opensandbox-group/OpenSandbox
|
|
6
|
+
Project-URL: Repository, https://github.com/opensandbox-group/OpenSandbox
|
|
7
|
+
Project-URL: Issues, https://github.com/opensandbox-group/OpenSandbox/issues
|
|
8
|
+
Author-email: OpenSandbox Team <pangjiping.pjp@alibaba-inc.com>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: control-plane,fastapi,opensandbox,sandbox,server
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: docker
|
|
26
|
+
Requires-Dist: fastapi>=0.137.0
|
|
27
|
+
Requires-Dist: httpx[socks]
|
|
28
|
+
Requires-Dist: kubernetes
|
|
29
|
+
Requires-Dist: opentelemetry-api
|
|
30
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http
|
|
31
|
+
Requires-Dist: opentelemetry-sdk
|
|
32
|
+
Requires-Dist: pydantic
|
|
33
|
+
Requires-Dist: pydantic-settings>=2.14.2
|
|
34
|
+
Requires-Dist: python-multipart>=0.0.31
|
|
35
|
+
Requires-Dist: pyyaml
|
|
36
|
+
Requires-Dist: redis>=5
|
|
37
|
+
Requires-Dist: starlette>=1.3.1
|
|
38
|
+
Requires-Dist: tomli; python_version < '3.11'
|
|
39
|
+
Requires-Dist: uvicorn[standard]
|
|
40
|
+
Requires-Dist: websockets>=14.0
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# OpenSandbox Server
|
|
44
|
+
|
|
45
|
+
Documentation: [docs/components/server.md](../docs/components/server.md)
|
|
46
|
+
|
|
@@ -84,11 +84,22 @@ def get_sandbox_logs(
|
|
|
84
84
|
deprecated=True,
|
|
85
85
|
description="Deprecated plain-text logs only. Only return logs newer than this duration (e.g. 10m, 1h).",
|
|
86
86
|
),
|
|
87
|
+
container: Optional[str] = Query(
|
|
88
|
+
None,
|
|
89
|
+
deprecated=True,
|
|
90
|
+
description=(
|
|
91
|
+
"Deprecated plain-text logs only. Container name to read logs from. "
|
|
92
|
+
"Defaults to the canonical user container (typically 'sandbox') when "
|
|
93
|
+
"the runtime supports multi-container pods."
|
|
94
|
+
),
|
|
95
|
+
),
|
|
87
96
|
) -> JSONResponse | PlainTextResponse:
|
|
88
97
|
"""Retrieve diagnostic logs for a sandbox."""
|
|
89
98
|
if scope is not None:
|
|
90
99
|
return _diagnostics_not_implemented_response()
|
|
91
|
-
text = sandbox_service.get_sandbox_logs(
|
|
100
|
+
text = sandbox_service.get_sandbox_logs(
|
|
101
|
+
sandbox_id, tail=tail, since=since, container=container
|
|
102
|
+
)
|
|
92
103
|
return _deprecated_plain_text_response(text)
|
|
93
104
|
|
|
94
105
|
|
|
@@ -431,6 +431,7 @@ def create_snapshot(
|
|
|
431
431
|
)
|
|
432
432
|
def list_snapshots(
|
|
433
433
|
sandbox_id: Optional[str] = Query(None, alias="sandboxId", description="Filter snapshots by source sandbox identifier"),
|
|
434
|
+
name: Optional[str] = Query(None, description="Filter snapshots by exact snapshot name"),
|
|
434
435
|
state: Optional[List[str]] = Query(None, description="Filter by snapshot lifecycle state. Pass multiple times for OR logic."),
|
|
435
436
|
page: int = Query(1, ge=1, description="Page number for pagination"),
|
|
436
437
|
page_size: int = Query(20, ge=1, le=200, alias="pageSize", description="Number of items per page"),
|
|
@@ -440,7 +441,7 @@ def list_snapshots(
|
|
|
440
441
|
List snapshots with optional filtering and pagination.
|
|
441
442
|
"""
|
|
442
443
|
request = ListSnapshotsRequest(
|
|
443
|
-
filter=SnapshotFilter(sandboxId=sandbox_id, state=state),
|
|
444
|
+
filter=SnapshotFilter(sandboxId=sandbox_id, name=name, state=state),
|
|
444
445
|
pagination=PaginationRequest(page=page, pageSize=page_size),
|
|
445
446
|
)
|
|
446
447
|
return snapshot_service.list_snapshots(request)
|
|
@@ -564,11 +565,19 @@ def get_sandbox_endpoint(
|
|
|
564
565
|
endpoint = sandbox_service.get_endpoint(sandbox_id, port, expires=expires)
|
|
565
566
|
|
|
566
567
|
if use_server_proxy:
|
|
567
|
-
# Prefer configured external address when available
|
|
568
|
-
|
|
568
|
+
# Prefer configured external address when available (it is the full
|
|
569
|
+
# external base and may already carry a path prefix).
|
|
569
570
|
eip = (get_config().server.eip or "").strip().rstrip("/")
|
|
570
571
|
if eip:
|
|
571
572
|
base_url = eip
|
|
573
|
+
else:
|
|
574
|
+
# Reflect how the client reached this API: same host AND same mount
|
|
575
|
+
# prefix (the API is served both bare and under /v1). Dropping the
|
|
576
|
+
# prefix pointed proxied traffic at whatever backend owns the root
|
|
577
|
+
# path on shared hosts (breaking HTTP with redirects and WebSocket
|
|
578
|
+
# handshakes outright).
|
|
579
|
+
mount_prefix = request.url.path.split("/sandboxes/", 1)[0]
|
|
580
|
+
base_url = str(request.base_url).rstrip("/") + mount_prefix
|
|
572
581
|
base_url = base_url.replace("https://", "").replace("http://", "")
|
|
573
582
|
endpoint.endpoint = f"{base_url}/sandboxes/{sandbox_id}/proxy/{port}"
|
|
574
583
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""SDK metrics ingestion endpoints."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from fastapi import APIRouter, Header, Response, status
|
|
24
|
+
|
|
25
|
+
from opensandbox_server.api.schema import MetricsEvent
|
|
26
|
+
from opensandbox_server.integrations.otel import record_sandbox_create_duration
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
router = APIRouter(tags=["Metrics"])
|
|
31
|
+
|
|
32
|
+
# OpenSandbox-Python-SDK/0.1.14, OpenSandbox-Go-SDK/0.1.0, OpenSandbox-JS-SDK/0.1.10
|
|
33
|
+
_SDK_USER_AGENT_RE = re.compile(
|
|
34
|
+
r"OpenSandbox-([A-Za-z0-9]+)-SDK/([^\s]+)",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_sdk_user_agent(user_agent: Optional[str]) -> tuple[str, str]:
|
|
40
|
+
"""Extract (language, version) from SDK User-Agent; fallback to unknown."""
|
|
41
|
+
if not user_agent:
|
|
42
|
+
return "unknown", "unknown"
|
|
43
|
+
match = _SDK_USER_AGENT_RE.search(user_agent)
|
|
44
|
+
if not match:
|
|
45
|
+
return "unknown", "unknown"
|
|
46
|
+
return match.group(1).lower(), match.group(2)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.post(
|
|
50
|
+
"/metrics/events",
|
|
51
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
52
|
+
responses={
|
|
53
|
+
400: {"description": "Bad request"},
|
|
54
|
+
401: {"description": "Unauthorized"},
|
|
55
|
+
500: {"description": "Internal server error"},
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
def report_metrics_event(
|
|
59
|
+
event: MetricsEvent,
|
|
60
|
+
x_request_id: Optional[str] = Header(None, alias="X-Request-ID"),
|
|
61
|
+
user_agent: Optional[str] = Header(None, alias="User-Agent"),
|
|
62
|
+
) -> Response:
|
|
63
|
+
"""Accept best-effort SDK metrics and record to OTEL (noop when disabled)."""
|
|
64
|
+
_ = x_request_id
|
|
65
|
+
if event.event_type == "sandbox.create":
|
|
66
|
+
sdk_language, sdk_version = parse_sdk_user_agent(user_agent)
|
|
67
|
+
record_sandbox_create_duration(
|
|
68
|
+
create_duration_ms=event.create_duration_ms,
|
|
69
|
+
sdk_language=sdk_language,
|
|
70
|
+
sdk_version=sdk_version,
|
|
71
|
+
success=event.success,
|
|
72
|
+
)
|
|
73
|
+
logger.debug(
|
|
74
|
+
"Accepted sandbox.create metrics event sandbox_id=%s image=%s "
|
|
75
|
+
"duration_ms=%s sdk=%s/%s success=%s",
|
|
76
|
+
event.sandbox_id,
|
|
77
|
+
event.image,
|
|
78
|
+
event.create_duration_ms,
|
|
79
|
+
sdk_language,
|
|
80
|
+
sdk_version,
|
|
81
|
+
event.success,
|
|
82
|
+
)
|
|
83
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
HTTP and WebSocket proxy routes for reaching services inside sandboxes via the lifecycle API.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
+
import hmac
|
|
19
20
|
import logging
|
|
20
21
|
from collections.abc import AsyncIterator, Mapping
|
|
21
22
|
from typing import Optional
|
|
@@ -34,6 +35,8 @@ from opensandbox_server.api import lifecycle
|
|
|
34
35
|
from opensandbox_server.api.schema import Endpoint
|
|
35
36
|
from opensandbox_server.middleware.auth import SANDBOX_API_KEY_HEADER
|
|
36
37
|
from opensandbox_server.services.constants import OPEN_SANDBOX_EGRESS_AUTH_HEADER, OPEN_SANDBOX_SECURE_ACCESS_HEADER
|
|
38
|
+
from opensandbox_server.tenants.context import set_current_tenant
|
|
39
|
+
from opensandbox_server.tenants.provider import TenantProviderUnavailable
|
|
37
40
|
|
|
38
41
|
logger = logging.getLogger(__name__)
|
|
39
42
|
|
|
@@ -54,6 +57,16 @@ SENSITIVE_HEADERS = {
|
|
|
54
57
|
"authorization",
|
|
55
58
|
"cookie",
|
|
56
59
|
SANDBOX_API_KEY_HEADER.lower(),
|
|
60
|
+
OPEN_SANDBOX_SECURE_ACCESS_HEADER.lower(),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
FORWARDED_HEADERS = {
|
|
64
|
+
"forwarded",
|
|
65
|
+
"x-forwarded-for",
|
|
66
|
+
"x-forwarded-host",
|
|
67
|
+
"x-forwarded-port",
|
|
68
|
+
"x-forwarded-proto",
|
|
69
|
+
"x-real-ip",
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
# Handled by websockets on the outbound handshake; do not duplicate on additional_headers
|
|
@@ -103,7 +116,7 @@ def _filter_proxy_headers(
|
|
|
103
116
|
Endpoint-resolved headers are merged for routing, except secure-access
|
|
104
117
|
credentials which callers must explicitly provide on server-proxy requests.
|
|
105
118
|
"""
|
|
106
|
-
excluded = set(HOP_BY_HOP_HEADERS) | set(SENSITIVE_HEADERS)
|
|
119
|
+
excluded = set(HOP_BY_HOP_HEADERS) | set(SENSITIVE_HEADERS) | set(FORWARDED_HEADERS)
|
|
107
120
|
if extra_excluded:
|
|
108
121
|
excluded.update(extra_excluded)
|
|
109
122
|
if connection_header:
|
|
@@ -121,7 +134,7 @@ def _filter_proxy_headers(
|
|
|
121
134
|
endpoint_header_excluded = {
|
|
122
135
|
OPEN_SANDBOX_SECURE_ACCESS_HEADER.lower(),
|
|
123
136
|
OPEN_SANDBOX_EGRESS_AUTH_HEADER.lower(),
|
|
124
|
-
}
|
|
137
|
+
} | FORWARDED_HEADERS
|
|
125
138
|
forwarded.update(
|
|
126
139
|
{
|
|
127
140
|
key: value
|
|
@@ -132,12 +145,68 @@ def _filter_proxy_headers(
|
|
|
132
145
|
return forwarded
|
|
133
146
|
|
|
134
147
|
|
|
148
|
+
def _set_forwarded_headers(
|
|
149
|
+
headers: dict[str, str], request: Request | WebSocket
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Rebuild proxy headers from the connection observed by this server."""
|
|
152
|
+
scheme = request.url.scheme.lower()
|
|
153
|
+
if scheme == "ws":
|
|
154
|
+
scheme = "http"
|
|
155
|
+
elif scheme == "wss":
|
|
156
|
+
scheme = "https"
|
|
157
|
+
headers["X-Forwarded-Proto"] = scheme
|
|
158
|
+
|
|
159
|
+
inbound_host = request.headers.get("host", "")
|
|
160
|
+
if inbound_host:
|
|
161
|
+
headers["X-Forwarded-Host"] = inbound_host
|
|
162
|
+
if request.client:
|
|
163
|
+
headers["X-Forwarded-For"] = request.client.host
|
|
164
|
+
|
|
165
|
+
|
|
135
166
|
def _schedule_proxy_renew(request: Request | WebSocket, sandbox_id: str) -> None:
|
|
136
167
|
proxy_renew = getattr(request.app.state, "proxy_renew_coordinator", None)
|
|
137
168
|
if proxy_renew is not None:
|
|
138
169
|
proxy_renew.schedule(sandbox_id)
|
|
139
170
|
|
|
140
171
|
|
|
172
|
+
async def _authenticate_websocket_tenant(websocket: WebSocket) -> bool:
|
|
173
|
+
"""Authenticate WebSocket connections in multi-tenant mode.
|
|
174
|
+
|
|
175
|
+
BaseHTTPMiddleware only intercepts HTTP requests, so WebSocket
|
|
176
|
+
connections must be authenticated here to establish tenant context.
|
|
177
|
+
Returns True if the request is authorized (or single-tenant mode).
|
|
178
|
+
"""
|
|
179
|
+
import asyncio
|
|
180
|
+
|
|
181
|
+
provider = getattr(websocket.app.state, "tenant_provider", None)
|
|
182
|
+
if provider is None:
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
api_key = websocket.headers.get(SANDBOX_API_KEY_HEADER)
|
|
186
|
+
if not api_key:
|
|
187
|
+
await _fail_client_websocket(
|
|
188
|
+
websocket, status.WS_1008_POLICY_VIOLATION, "missing API key"
|
|
189
|
+
)
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
tenant = await asyncio.to_thread(provider.lookup, api_key)
|
|
194
|
+
except TenantProviderUnavailable:
|
|
195
|
+
await _fail_client_websocket(
|
|
196
|
+
websocket, status.WS_1011_INTERNAL_ERROR, "tenant provider unavailable"
|
|
197
|
+
)
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
if tenant is None:
|
|
201
|
+
await _fail_client_websocket(
|
|
202
|
+
websocket, status.WS_1008_POLICY_VIOLATION, "invalid API key"
|
|
203
|
+
)
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
set_current_tenant(tenant)
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
|
|
141
210
|
async def _stream_backend_response(resp: httpx.Response) -> AsyncIterator[bytes]:
|
|
142
211
|
"""
|
|
143
212
|
Yield backend body chunks without httpx content decoding and always close the response.
|
|
@@ -153,14 +222,47 @@ async def _stream_backend_response(resp: httpx.Response) -> AsyncIterator[bytes]
|
|
|
153
222
|
await resp.aclose()
|
|
154
223
|
|
|
155
224
|
|
|
225
|
+
def _verify_secure_access(endpoint: Endpoint, caller_headers: Mapping[str, str]) -> None:
|
|
226
|
+
"""Enforce OpenSandbox-Secure-Access validation on server-proxy requests.
|
|
227
|
+
|
|
228
|
+
When endpoint resolution returns a secure-access token, the caller must
|
|
229
|
+
supply the same header value. Raises 401 for missing or mismatched tokens.
|
|
230
|
+
Uses constant-time comparison to avoid timing side-channels.
|
|
231
|
+
"""
|
|
232
|
+
if not endpoint.headers:
|
|
233
|
+
return
|
|
234
|
+
expected_token = endpoint.headers.get(OPEN_SANDBOX_SECURE_ACCESS_HEADER)
|
|
235
|
+
if not expected_token:
|
|
236
|
+
return
|
|
237
|
+
caller_token = None
|
|
238
|
+
for key, value in caller_headers.items():
|
|
239
|
+
if key.lower() == OPEN_SANDBOX_SECURE_ACCESS_HEADER.lower():
|
|
240
|
+
caller_token = value
|
|
241
|
+
break
|
|
242
|
+
if not caller_token or not hmac.compare_digest(
|
|
243
|
+
caller_token.encode(), expected_token.encode()
|
|
244
|
+
):
|
|
245
|
+
raise HTTPException(
|
|
246
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
247
|
+
detail={
|
|
248
|
+
"code": "MISSING_OR_INVALID_SECURE_ACCESS",
|
|
249
|
+
"message": (
|
|
250
|
+
"This sandbox requires the "
|
|
251
|
+
f"{OPEN_SANDBOX_SECURE_ACCESS_HEADER} header for access."
|
|
252
|
+
),
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
156
257
|
async def _proxy_http_request(
|
|
157
258
|
request: Request,
|
|
158
259
|
sandbox_id: str,
|
|
159
260
|
port: int,
|
|
160
261
|
full_path: str,
|
|
161
262
|
) -> StreamingResponse:
|
|
162
|
-
_schedule_proxy_renew(request, sandbox_id)
|
|
163
263
|
endpoint = lifecycle.sandbox_service.get_endpoint(sandbox_id, port, resolve_internal=True)
|
|
264
|
+
_verify_secure_access(endpoint, request.headers)
|
|
265
|
+
_schedule_proxy_renew(request, sandbox_id)
|
|
164
266
|
query_string = request.url.query
|
|
165
267
|
target_url = _build_proxy_target_url(endpoint, full_path, query_string, websocket=False)
|
|
166
268
|
client: httpx.AsyncClient = request.app.state.http_client
|
|
@@ -178,19 +280,9 @@ async def _proxy_http_request(
|
|
|
178
280
|
endpoint.headers,
|
|
179
281
|
connection_header=request.headers.get("connection"),
|
|
180
282
|
)
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
# preserved and we don't emit a duplicate with different casing,
|
|
185
|
-
# which would break chain-safe semantics for downstream backends.
|
|
186
|
-
existing_lower = {key.lower() for key in headers}
|
|
187
|
-
if "x-forwarded-proto" not in existing_lower:
|
|
188
|
-
headers["X-Forwarded-Proto"] = request.url.scheme
|
|
189
|
-
inbound_host = request.headers.get("host", "")
|
|
190
|
-
if inbound_host and "x-forwarded-host" not in existing_lower:
|
|
191
|
-
headers["X-Forwarded-Host"] = inbound_host
|
|
192
|
-
if request.client and "x-forwarded-for" not in existing_lower:
|
|
193
|
-
headers["X-Forwarded-For"] = request.client.host
|
|
283
|
+
# Forwarded headers are stripped above and rebuilt from the connection
|
|
284
|
+
# observed by this trusted proxy, so clients cannot spoof transport state.
|
|
285
|
+
_set_forwarded_headers(headers, request)
|
|
194
286
|
|
|
195
287
|
stream_body = request.method in ("POST", "PUT", "PATCH", "DELETE")
|
|
196
288
|
req = client.build_request(
|
|
@@ -307,7 +399,8 @@ async def _proxy_websocket_request(
|
|
|
307
399
|
port: int,
|
|
308
400
|
full_path: str,
|
|
309
401
|
) -> None:
|
|
310
|
-
|
|
402
|
+
if not await _authenticate_websocket_tenant(websocket):
|
|
403
|
+
return
|
|
311
404
|
|
|
312
405
|
try:
|
|
313
406
|
endpoint = lifecycle.sandbox_service.get_endpoint(sandbox_id, port, resolve_internal=True)
|
|
@@ -325,6 +418,17 @@ async def _proxy_websocket_request(
|
|
|
325
418
|
)
|
|
326
419
|
return
|
|
327
420
|
|
|
421
|
+
try:
|
|
422
|
+
_verify_secure_access(endpoint, dict(websocket.headers))
|
|
423
|
+
except HTTPException:
|
|
424
|
+
await _fail_client_websocket(
|
|
425
|
+
websocket,
|
|
426
|
+
status.WS_1008_POLICY_VIOLATION,
|
|
427
|
+
"Missing or invalid secure-access token",
|
|
428
|
+
)
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
_schedule_proxy_renew(websocket, sandbox_id)
|
|
328
432
|
query_string = websocket.url.query or ""
|
|
329
433
|
target_url = _build_proxy_target_url(
|
|
330
434
|
endpoint,
|
|
@@ -338,6 +442,7 @@ async def _proxy_websocket_request(
|
|
|
338
442
|
extra_excluded=WEBSOCKET_HANDSHAKE_HEADERS,
|
|
339
443
|
connection_header=websocket.headers.get("connection"),
|
|
340
444
|
)
|
|
445
|
+
_set_forwarded_headers(headers, websocket)
|
|
341
446
|
subprotocols = list(websocket.scope.get("subprotocols", []))
|
|
342
447
|
raw_origin = websocket.headers.get("origin")
|
|
343
448
|
origin: Origin | None = Origin(raw_origin) if raw_origin else None
|
|
@@ -198,9 +198,9 @@ class PVC(BaseModel):
|
|
|
198
198
|
description=(
|
|
199
199
|
"When true, the volume is automatically removed when the sandbox is "
|
|
200
200
|
"deleted. Only applies to volumes that were auto-created by the server "
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"reclaim policy."
|
|
201
|
+
"on this request; pre-existing volumes are never removed. For "
|
|
202
|
+
"Kubernetes, the resulting PVC delete then triggers the bound PV's "
|
|
203
|
+
"StorageClass reclaim policy (Retain/Delete)."
|
|
204
204
|
),
|
|
205
205
|
)
|
|
206
206
|
|
|
@@ -424,7 +424,16 @@ class CreateSandboxRequest(BaseModel):
|
|
|
424
424
|
resource_limits: Optional[ResourceLimits] = Field(
|
|
425
425
|
None,
|
|
426
426
|
alias="resourceLimits",
|
|
427
|
-
description="Runtime resource constraints for the sandbox instance. Optional when poolRef is provided.",
|
|
427
|
+
description="Runtime resource constraints (hard caps) for the sandbox instance. Optional when poolRef is provided.",
|
|
428
|
+
)
|
|
429
|
+
resource_requests: Optional[ResourceLimits] = Field(
|
|
430
|
+
None,
|
|
431
|
+
alias="resourceRequests",
|
|
432
|
+
description=(
|
|
433
|
+
"Resource reservations (guaranteed minimums) for the sandbox instance. "
|
|
434
|
+
"When provided, used as Kubernetes resource requests, enabling Burstable QoS. "
|
|
435
|
+
"When omitted, resourceLimits values are used for both limits and requests."
|
|
436
|
+
),
|
|
428
437
|
)
|
|
429
438
|
env: Optional[Dict[str, Optional[str]]] = Field(
|
|
430
439
|
None,
|
|
@@ -449,7 +458,8 @@ class CreateSandboxRequest(BaseModel):
|
|
|
449
458
|
alias="networkPolicy",
|
|
450
459
|
description=(
|
|
451
460
|
"Optional outbound network policy. Shape matches the egress sidecar /policy endpoint. "
|
|
452
|
-
"Empty/omitted means allow-all until updated."
|
|
461
|
+
"Empty/omitted means allow-all until updated. Not supported together with "
|
|
462
|
+
"extensions.poolRef because pooled pods are pre-created."
|
|
453
463
|
),
|
|
454
464
|
)
|
|
455
465
|
credential_proxy: Optional[CredentialProxyConfig] = Field(
|
|
@@ -490,16 +500,15 @@ class CreateSandboxRequest(BaseModel):
|
|
|
490
500
|
# Reject conflicting fields that would be ignored in pool mode
|
|
491
501
|
if bool((self.snapshot_id or "").strip()):
|
|
492
502
|
raise ValueError("snapshotId cannot be used together with poolRef.")
|
|
493
|
-
if self.credential_proxy and self.credential_proxy.enabled:
|
|
494
|
-
raise ValueError("credentialProxy.enabled cannot be used together with poolRef.")
|
|
495
503
|
# Normalize blank snapshotId so downstream code won't see
|
|
496
504
|
# a truthy whitespace string (e.g. " ") as a real value.
|
|
497
505
|
if self.snapshot_id is not None and not self.snapshot_id.strip():
|
|
498
506
|
self.snapshot_id = None
|
|
499
507
|
return self
|
|
500
508
|
|
|
501
|
-
if self.credential_proxy and self.credential_proxy.enabled
|
|
502
|
-
|
|
509
|
+
if self.credential_proxy and self.credential_proxy.enabled:
|
|
510
|
+
if self.network_policy is None:
|
|
511
|
+
raise ValueError("credentialProxy.enabled requires networkPolicy.")
|
|
503
512
|
|
|
504
513
|
has_image = self.image is not None and bool(self.image.uri.strip())
|
|
505
514
|
has_snapshot = bool((self.snapshot_id or "").strip())
|
|
@@ -534,6 +543,10 @@ class CreateSandboxResponse(BaseModel):
|
|
|
534
543
|
id: str = Field(..., description="Unique sandbox identifier")
|
|
535
544
|
status: SandboxStatus = Field(..., description="Current lifecycle status and detailed state information")
|
|
536
545
|
metadata: Optional[Dict[str, str]] = Field(None, description="Custom metadata from creation request")
|
|
546
|
+
extensions: Optional[Dict[str, str]] = Field(
|
|
547
|
+
None,
|
|
548
|
+
description="Opaque extension data restored from provider-specific storage",
|
|
549
|
+
)
|
|
537
550
|
platform: Optional[PlatformSpec] = Field(
|
|
538
551
|
None,
|
|
539
552
|
description=(
|
|
@@ -575,6 +588,10 @@ class Sandbox(BaseModel):
|
|
|
575
588
|
)
|
|
576
589
|
status: SandboxStatus = Field(..., description="Current lifecycle status and detailed state information")
|
|
577
590
|
metadata: Optional[Dict[str, str]] = Field(None, description="Custom metadata from creation request")
|
|
591
|
+
extensions: Optional[Dict[str, str]] = Field(
|
|
592
|
+
None,
|
|
593
|
+
description="Opaque extension data restored from provider-specific storage",
|
|
594
|
+
)
|
|
578
595
|
entrypoint: Optional[List[str]] = Field(None, description="The command to execute as the sandbox's entry process")
|
|
579
596
|
expires_at: Optional[datetime] = Field(
|
|
580
597
|
None,
|
|
@@ -668,6 +685,10 @@ class SnapshotFilter(BaseModel):
|
|
|
668
685
|
alias="sandboxId",
|
|
669
686
|
description="Filter snapshots by source sandbox identifier",
|
|
670
687
|
)
|
|
688
|
+
name: Optional[str] = Field(
|
|
689
|
+
None,
|
|
690
|
+
description="Filter snapshots by exact snapshot name",
|
|
691
|
+
)
|
|
671
692
|
state: Optional[List[str]] = Field(
|
|
672
693
|
None,
|
|
673
694
|
min_length=1,
|
|
@@ -961,3 +982,41 @@ class ListPoolsResponse(BaseModel):
|
|
|
961
982
|
Collection of pools.
|
|
962
983
|
"""
|
|
963
984
|
items: List[PoolResponse] = Field(..., description="List of pools.")
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
# ============================================================================
|
|
988
|
+
# Metrics
|
|
989
|
+
# ============================================================================
|
|
990
|
+
|
|
991
|
+
class MetricsEvent(BaseModel):
|
|
992
|
+
"""
|
|
993
|
+
SDK-reported metrics event (Phase 1: sandbox creation latency).
|
|
994
|
+
"""
|
|
995
|
+
|
|
996
|
+
event_type: Literal["sandbox.create"] = Field(
|
|
997
|
+
...,
|
|
998
|
+
alias="eventType",
|
|
999
|
+
description="Metric event type",
|
|
1000
|
+
)
|
|
1001
|
+
sandbox_id: Optional[str] = Field(
|
|
1002
|
+
default=None,
|
|
1003
|
+
alias="sandboxId",
|
|
1004
|
+
description="Sandbox identifier when available",
|
|
1005
|
+
)
|
|
1006
|
+
image: Optional[str] = Field(
|
|
1007
|
+
default=None,
|
|
1008
|
+
description="Container image URI or snapshot startup source label",
|
|
1009
|
+
)
|
|
1010
|
+
create_duration_ms: int = Field(
|
|
1011
|
+
...,
|
|
1012
|
+
alias="createDurationMs",
|
|
1013
|
+
ge=0,
|
|
1014
|
+
description="Wall-clock duration in milliseconds from create start to ready or failure",
|
|
1015
|
+
)
|
|
1016
|
+
success: bool = Field(
|
|
1017
|
+
...,
|
|
1018
|
+
description="Whether create + readiness completed successfully",
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
class Config:
|
|
1022
|
+
populate_by_name = True
|