opensandbox-server 0.2.1__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.1 → opensandbox_server-0.2.2}/.gitignore +1 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/PKG-INFO +6 -3
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/api/devops.py +12 -1
- {opensandbox_server-0.2.1 → 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.1 → opensandbox_server-0.2.2}/opensandbox_server/api/proxy.py +122 -17
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/api/schema.py +55 -5
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/cli.py +1 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/config.py +123 -13
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.k8s.toml +4 -4
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.k8s.zh.toml +4 -4
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.toml +9 -4
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.zh.toml +9 -4
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/extensions/__init__.py +4 -2
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/extensions/codec.py +23 -3
- 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.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/consumer.py +2 -2
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/main.py +49 -7
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/middleware/auth.py +58 -39
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/repositories/snapshots/sqlite.py +80 -1
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/constants.py +6 -1
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/__init__.py +1 -2
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/container_ops.py +2 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/docker_diagnostics.py +13 -3
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/docker_service.py +86 -178
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/metadata.py +30 -8
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/networking.py +72 -22
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/port_allocator.py +3 -1
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/snapshot_runtime.py +12 -3
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/agent_sandbox_provider.py +1 -3
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/batchsandbox_provider.py +11 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/client.py +6 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/informer.py +23 -4
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/k8s_diagnostics.py +121 -5
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/kubernetes_service.py +197 -33
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/pool_service.py +24 -22
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/snapshot_runtime.py +24 -15
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/volume_helper.py +32 -1
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/workload_mapper.py +4 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/sandbox_service.py +12 -1
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_models.py +1 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_repository.py +2 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_restore.py +11 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_runtime.py +8 -4
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_service.py +38 -4
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/validators.py +39 -2
- {opensandbox_server-0.2.1 → 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.1 → opensandbox_server-0.2.2}/pyproject.toml +5 -2
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/LICENSE +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/README.md +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/api/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/api/pool.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/examples/.gitkeep +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/examples/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/examples/e2e.batchsandbox-template.yaml +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.batchsandbox-template.yaml +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/extensions/keys.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/extensions/validation.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/constants.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/controller.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/intent.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/logutil.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/proxy_renew.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/redis_client.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/runner.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/logging_config.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/middleware/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/middleware/request_id.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/py.typed +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/repositories/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/repositories/snapshots/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/repositories/snapshots/factory.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/ossfs_mixin.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/runtime.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/volumes.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/windows_profile.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/endpoint_auth.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/extension_service.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/factory.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/helpers.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/__init__.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/agent_sandbox_template.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/batchsandbox_template.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/create_helpers.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/egress_helper.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/endpoint_resolver.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/error_helpers.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/image_pull_secret_helper.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/label_selector.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/list_helpers.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/provider_common.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/provider_factory.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/rate_limiter.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/security_context.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/status_helpers.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/template_manager.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/windows_profile.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/workload_access.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/workload_provider.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/runtime_resolver.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/signing.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_runtime_factory.py +0 -0
- {opensandbox_server-0.2.1 → opensandbox_server-0.2.2}/opensandbox_server/services/windows_common.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opensandbox-server
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: FastAPI control plane for OpenSandbox that manages sandbox lifecycle on Docker (ready) and Kubernetes (planned) runtimes.
|
|
5
5
|
Project-URL: Homepage, https://github.com/opensandbox-group/OpenSandbox
|
|
6
6
|
Project-URL: Repository, https://github.com/opensandbox-group/OpenSandbox
|
|
@@ -26,9 +26,12 @@ Requires-Dist: docker
|
|
|
26
26
|
Requires-Dist: fastapi>=0.137.0
|
|
27
27
|
Requires-Dist: httpx[socks]
|
|
28
28
|
Requires-Dist: kubernetes
|
|
29
|
+
Requires-Dist: opentelemetry-api
|
|
30
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http
|
|
31
|
+
Requires-Dist: opentelemetry-sdk
|
|
29
32
|
Requires-Dist: pydantic
|
|
30
|
-
Requires-Dist: pydantic-settings
|
|
31
|
-
Requires-Dist: python-multipart>=0.0.
|
|
33
|
+
Requires-Dist: pydantic-settings>=2.14.2
|
|
34
|
+
Requires-Dist: python-multipart>=0.0.31
|
|
32
35
|
Requires-Dist: pyyaml
|
|
33
36
|
Requires-Dist: redis>=5
|
|
34
37
|
Requires-Dist: starlette>=1.3.1
|
|
@@ -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
|
|
@@ -458,7 +458,8 @@ class CreateSandboxRequest(BaseModel):
|
|
|
458
458
|
alias="networkPolicy",
|
|
459
459
|
description=(
|
|
460
460
|
"Optional outbound network policy. Shape matches the egress sidecar /policy endpoint. "
|
|
461
|
-
"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."
|
|
462
463
|
),
|
|
463
464
|
)
|
|
464
465
|
credential_proxy: Optional[CredentialProxyConfig] = Field(
|
|
@@ -499,16 +500,15 @@ class CreateSandboxRequest(BaseModel):
|
|
|
499
500
|
# Reject conflicting fields that would be ignored in pool mode
|
|
500
501
|
if bool((self.snapshot_id or "").strip()):
|
|
501
502
|
raise ValueError("snapshotId cannot be used together with poolRef.")
|
|
502
|
-
if self.credential_proxy and self.credential_proxy.enabled:
|
|
503
|
-
raise ValueError("credentialProxy.enabled cannot be used together with poolRef.")
|
|
504
503
|
# Normalize blank snapshotId so downstream code won't see
|
|
505
504
|
# a truthy whitespace string (e.g. " ") as a real value.
|
|
506
505
|
if self.snapshot_id is not None and not self.snapshot_id.strip():
|
|
507
506
|
self.snapshot_id = None
|
|
508
507
|
return self
|
|
509
508
|
|
|
510
|
-
if self.credential_proxy and self.credential_proxy.enabled
|
|
511
|
-
|
|
509
|
+
if self.credential_proxy and self.credential_proxy.enabled:
|
|
510
|
+
if self.network_policy is None:
|
|
511
|
+
raise ValueError("credentialProxy.enabled requires networkPolicy.")
|
|
512
512
|
|
|
513
513
|
has_image = self.image is not None and bool(self.image.uri.strip())
|
|
514
514
|
has_snapshot = bool((self.snapshot_id or "").strip())
|
|
@@ -543,6 +543,10 @@ class CreateSandboxResponse(BaseModel):
|
|
|
543
543
|
id: str = Field(..., description="Unique sandbox identifier")
|
|
544
544
|
status: SandboxStatus = Field(..., description="Current lifecycle status and detailed state information")
|
|
545
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
|
+
)
|
|
546
550
|
platform: Optional[PlatformSpec] = Field(
|
|
547
551
|
None,
|
|
548
552
|
description=(
|
|
@@ -584,6 +588,10 @@ class Sandbox(BaseModel):
|
|
|
584
588
|
)
|
|
585
589
|
status: SandboxStatus = Field(..., description="Current lifecycle status and detailed state information")
|
|
586
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
|
+
)
|
|
587
595
|
entrypoint: Optional[List[str]] = Field(None, description="The command to execute as the sandbox's entry process")
|
|
588
596
|
expires_at: Optional[datetime] = Field(
|
|
589
597
|
None,
|
|
@@ -677,6 +685,10 @@ class SnapshotFilter(BaseModel):
|
|
|
677
685
|
alias="sandboxId",
|
|
678
686
|
description="Filter snapshots by source sandbox identifier",
|
|
679
687
|
)
|
|
688
|
+
name: Optional[str] = Field(
|
|
689
|
+
None,
|
|
690
|
+
description="Filter snapshots by exact snapshot name",
|
|
691
|
+
)
|
|
680
692
|
state: Optional[List[str]] = Field(
|
|
681
693
|
None,
|
|
682
694
|
min_length=1,
|
|
@@ -970,3 +982,41 @@ class ListPoolsResponse(BaseModel):
|
|
|
970
982
|
Collection of pools.
|
|
971
983
|
"""
|
|
972
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
|