opensandbox-server 0.2.2__tar.gz → 0.2.3__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.2 → opensandbox_server-0.2.3}/.gitignore +13 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/PKG-INFO +4 -2
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/devops.py +32 -29
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/lifecycle.py +20 -1
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/proxy.py +129 -37
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/schema.py +92 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/cli.py +1 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/config.py +93 -3
- opensandbox_server-0.2.3/opensandbox_server/constants.py +18 -0
- opensandbox_server-0.2.3/opensandbox_server/examples/e2e.batchsandbox-template.yaml +33 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.config.k8s.toml +2 -2
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.config.k8s.zh.toml +2 -2
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.config.toml +9 -2
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.config.zh.toml +8 -2
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/otel/__init__.py +3 -1
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/otel/metrics.py +75 -6
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/main.py +67 -6
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/middleware/auth.py +1 -1
- opensandbox_server-0.2.3/opensandbox_server/middleware/date_header.py +43 -0
- opensandbox_server-0.2.3/opensandbox_server/middleware/http_metrics.py +86 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/constants.py +12 -1
- opensandbox_server-0.2.3/opensandbox_server/services/diagnostics.py +85 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/container_ops.py +5 -1
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/docker_diagnostics.py +91 -1
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/docker_service.py +26 -2
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/networking.py +4 -1
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/runtime.py +116 -1
- opensandbox_server-0.2.3/opensandbox_server/services/fleets/__init__.py +13 -0
- opensandbox_server-0.2.3/opensandbox_server/services/fleets/create_mapping.py +280 -0
- opensandbox_server-0.2.3/opensandbox_server/services/fleets/fastpath_client.py +310 -0
- opensandbox_server-0.2.3/opensandbox_server/services/fleets/status_mapping.py +106 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/helpers.py +13 -1
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/agent_sandbox_provider.py +25 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/batchsandbox_provider.py +148 -26
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/client.py +20 -6
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/create_helpers.py +6 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/egress_helper.py +6 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/informer.py +37 -2
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/k8s_diagnostics.py +149 -8
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/kubernetes_service.py +74 -25
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/provider_common.py +9 -1
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/snapshot_runtime.py +10 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/status_helpers.py +8 -1
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/volume_helper.py +8 -20
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/workload_mapper.py +122 -1
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/workload_provider.py +9 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/sandbox_service.py +35 -0
- opensandbox_server-0.2.3/opensandbox_server/tenants/__init__.py +156 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/tenants/file_provider.py +5 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/tenants/http_provider.py +5 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/tenants/provider.py +8 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/pyproject.toml +4 -0
- opensandbox_server-0.2.2/opensandbox_server/examples/e2e.batchsandbox-template.yaml +0 -17
- opensandbox_server-0.2.2/opensandbox_server/tenants/__init__.py +0 -65
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/LICENSE +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/README.md +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/metrics.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/pool.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/.gitkeep +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.batchsandbox-template.yaml +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/extensions/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/extensions/codec.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/extensions/keys.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/extensions/validation.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/constants.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/consumer.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/controller.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/intent.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/logutil.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/proxy_renew.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/redis_client.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/runner.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/logging_config.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/middleware/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/middleware/request_id.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/py.typed +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/repositories/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/repositories/snapshots/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/repositories/snapshots/factory.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/repositories/snapshots/sqlite.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/metadata.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/ossfs_mixin.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/port_allocator.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/snapshot_runtime.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/volumes.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/windows_profile.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/endpoint_auth.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/extension_service.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/factory.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/__init__.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/agent_sandbox_template.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/batchsandbox_template.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/endpoint_resolver.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/error_helpers.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/image_pull_secret_helper.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/label_selector.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/list_helpers.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/pool_service.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/provider_factory.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/rate_limiter.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/security_context.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/template_manager.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/windows_profile.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/workload_access.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/runtime_resolver.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/signing.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_models.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_repository.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_restore.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_runtime.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_runtime_factory.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_service.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/validators.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/windows_common.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/startup_guard.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/tenants/context.py +0 -0
- {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/tenants/models.py +0 -0
|
@@ -269,6 +269,19 @@ nbdist/
|
|
|
269
269
|
generated/
|
|
270
270
|
**/generated/**
|
|
271
271
|
|
|
272
|
+
# fleets FastPath gRPC stubs are committed intentionally (deterministic protoc
|
|
273
|
+
# output; the server package needs them at install time, so they are checked
|
|
274
|
+
# in rather than regenerated).
|
|
275
|
+
!server/opensandbox_server/services/fleets/generated/
|
|
276
|
+
!server/opensandbox_server/services/fleets/generated/**
|
|
277
|
+
server/opensandbox_server/services/fleets/generated/__pycache__/
|
|
278
|
+
|
|
279
|
+
# execd eBPF kernel BTF dumps (bpf2go build-time only, never committed —
|
|
280
|
+
# see components/execd/pkg/ebpf/prog/audit.bpf.c; regenerate with:
|
|
281
|
+
# bpftool btf dump file /sys/kernel/btf/vmlinux format c > .../vmlinux.h)
|
|
282
|
+
components/execd/pkg/ebpf/prog/vmlinux.h
|
|
283
|
+
components/execd/pkg/ebpf/prog/vmlinux_*.h
|
|
284
|
+
|
|
272
285
|
# gVisor runtime binaries (downloaded dynamically)
|
|
273
286
|
kubernetes/test/kind/gvisor/runsc
|
|
274
287
|
kubernetes/test/kind/gvisor/containerd-shim-runsc-v1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: opensandbox-server
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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
|
|
@@ -24,11 +24,13 @@ Classifier: Typing :: Typed
|
|
|
24
24
|
Requires-Python: >=3.10
|
|
25
25
|
Requires-Dist: docker
|
|
26
26
|
Requires-Dist: fastapi>=0.137.0
|
|
27
|
+
Requires-Dist: grpcio>=1.83.0
|
|
27
28
|
Requires-Dist: httpx[socks]
|
|
28
29
|
Requires-Dist: kubernetes
|
|
29
30
|
Requires-Dist: opentelemetry-api
|
|
30
31
|
Requires-Dist: opentelemetry-exporter-otlp-proto-http
|
|
31
32
|
Requires-Dist: opentelemetry-sdk
|
|
33
|
+
Requires-Dist: protobuf>=7.35.1
|
|
32
34
|
Requires-Dist: pydantic
|
|
33
35
|
Requires-Dist: pydantic-settings>=2.14.2
|
|
34
36
|
Requires-Dist: python-multipart>=0.0.31
|
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
"""
|
|
16
16
|
API routes for OpenSandbox DevOps diagnostics.
|
|
17
17
|
|
|
18
|
-
Requests that include
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
plain-text behavior for legacy humans, agents, and CLI clients.
|
|
18
|
+
Requests that include ``scope`` target the stable Diagnostics API and return an
|
|
19
|
+
inline JSON descriptor. Requests without ``scope`` preserve the deprecated
|
|
20
|
+
DevOps plain-text behavior for legacy humans, agents, and CLI clients.
|
|
22
21
|
"""
|
|
23
22
|
|
|
24
23
|
import logging
|
|
@@ -28,19 +27,29 @@ from fastapi import APIRouter, HTTPException, Query, status
|
|
|
28
27
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
29
28
|
|
|
30
29
|
from opensandbox_server.api.lifecycle import sandbox_service
|
|
30
|
+
from opensandbox_server.services.diagnostics import DiagnosticResult
|
|
31
31
|
|
|
32
32
|
logger = logging.getLogger(__name__)
|
|
33
33
|
router = APIRouter(tags=["DevOps"])
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
def _diagnostic_inline_response(
|
|
37
|
+
result: DiagnosticResult,
|
|
38
|
+
) -> JSONResponse:
|
|
39
|
+
"""Build a Diagnostics API descriptor for inline plain-text content."""
|
|
40
|
+
payload: dict[str, object] = {
|
|
41
|
+
"sandboxId": result.sandbox_id,
|
|
42
|
+
"kind": result.kind,
|
|
43
|
+
"scope": result.scope,
|
|
44
|
+
"delivery": "inline",
|
|
45
|
+
"contentType": "text/plain; charset=utf-8",
|
|
46
|
+
"content": result.content,
|
|
47
|
+
"contentLength": len(result.content.encode("utf-8")),
|
|
48
|
+
"truncated": result.truncated,
|
|
49
|
+
}
|
|
50
|
+
if result.warnings:
|
|
51
|
+
payload["warnings"] = list(result.warnings)
|
|
52
|
+
return JSONResponse(status_code=status.HTTP_200_OK, content=payload)
|
|
44
53
|
|
|
45
54
|
|
|
46
55
|
def _deprecated_plain_text_response(content: str) -> PlainTextResponse:
|
|
@@ -56,13 +65,10 @@ def _deprecated_plain_text_response(content: str) -> PlainTextResponse:
|
|
|
56
65
|
status_code=status.HTTP_200_OK,
|
|
57
66
|
responses={
|
|
58
67
|
200: {
|
|
59
|
-
"description": "
|
|
60
|
-
"content": {"text/plain": {}},
|
|
61
|
-
},
|
|
62
|
-
501: {
|
|
63
|
-
"description": "Stable Diagnostics API is not implemented by this server",
|
|
64
|
-
"content": {"application/json": {}},
|
|
68
|
+
"description": "Stable JSON descriptor, or deprecated text when scope is omitted",
|
|
69
|
+
"content": {"application/json": {}, "text/plain": {}},
|
|
65
70
|
},
|
|
71
|
+
400: {"description": "Unsupported diagnostics scope"},
|
|
66
72
|
404: {"description": "Sandbox not found"},
|
|
67
73
|
},
|
|
68
74
|
)
|
|
@@ -96,10 +102,9 @@ def get_sandbox_logs(
|
|
|
96
102
|
) -> JSONResponse | PlainTextResponse:
|
|
97
103
|
"""Retrieve diagnostic logs for a sandbox."""
|
|
98
104
|
if scope is not None:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
105
|
+
result = sandbox_service.get_sandbox_log_diagnostics(sandbox_id, scope)
|
|
106
|
+
return _diagnostic_inline_response(result)
|
|
107
|
+
text = sandbox_service.get_sandbox_logs(sandbox_id, tail=tail, since=since, container=container)
|
|
103
108
|
return _deprecated_plain_text_response(text)
|
|
104
109
|
|
|
105
110
|
|
|
@@ -125,13 +130,10 @@ def get_sandbox_inspect(sandbox_id: str) -> PlainTextResponse:
|
|
|
125
130
|
status_code=status.HTTP_200_OK,
|
|
126
131
|
responses={
|
|
127
132
|
200: {
|
|
128
|
-
"description": "
|
|
129
|
-
"content": {"text/plain": {}},
|
|
130
|
-
},
|
|
131
|
-
501: {
|
|
132
|
-
"description": "Stable Diagnostics API is not implemented by this server",
|
|
133
|
-
"content": {"application/json": {}},
|
|
133
|
+
"description": "Stable JSON descriptor, or deprecated text when scope is omitted",
|
|
134
|
+
"content": {"application/json": {}, "text/plain": {}},
|
|
134
135
|
},
|
|
136
|
+
400: {"description": "Unsupported diagnostics scope"},
|
|
135
137
|
404: {"description": "Sandbox not found"},
|
|
136
138
|
},
|
|
137
139
|
)
|
|
@@ -151,7 +153,8 @@ def get_sandbox_events(
|
|
|
151
153
|
) -> JSONResponse | PlainTextResponse:
|
|
152
154
|
"""Retrieve diagnostic events for a sandbox."""
|
|
153
155
|
if scope is not None:
|
|
154
|
-
|
|
156
|
+
result = sandbox_service.get_sandbox_event_diagnostics(sandbox_id, scope)
|
|
157
|
+
return _diagnostic_inline_response(result)
|
|
155
158
|
text = sandbox_service.get_sandbox_events(sandbox_id, limit=limit)
|
|
156
159
|
return _deprecated_plain_text_response(text)
|
|
157
160
|
|
|
@@ -45,7 +45,10 @@ from opensandbox_server.api.schema import (
|
|
|
45
45
|
Snapshot,
|
|
46
46
|
SnapshotFilter,
|
|
47
47
|
)
|
|
48
|
-
from opensandbox_server.services.constants import
|
|
48
|
+
from opensandbox_server.services.constants import (
|
|
49
|
+
OPEN_SANDBOX_INGRESS_HEADER,
|
|
50
|
+
SandboxErrorCodes,
|
|
51
|
+
)
|
|
49
52
|
from opensandbox_server.services.factory import create_sandbox_service
|
|
50
53
|
from opensandbox_server.services.snapshot_service import create_snapshot_service
|
|
51
54
|
|
|
@@ -71,6 +74,16 @@ snapshot_service = create_snapshot_service(sandbox_service)
|
|
|
71
74
|
400: {"model": ErrorResponse, "description": "The request was invalid or malformed"},
|
|
72
75
|
401: {"model": ErrorResponse, "description": "Authentication credentials are missing or invalid"},
|
|
73
76
|
409: {"model": ErrorResponse, "description": "The operation conflicts with the current state"},
|
|
77
|
+
429: {
|
|
78
|
+
"model": ErrorResponse,
|
|
79
|
+
"description": "Pool capacity remained unavailable before the acquisition timeout",
|
|
80
|
+
"headers": {
|
|
81
|
+
"Retry-After": {
|
|
82
|
+
"description": "Suggested delay in seconds before retrying",
|
|
83
|
+
"schema": {"type": "integer", "minimum": 1},
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
},
|
|
74
87
|
500: {"model": ErrorResponse, "description": "An unexpected server error occurred"},
|
|
75
88
|
},
|
|
76
89
|
)
|
|
@@ -580,5 +593,11 @@ def get_sandbox_endpoint(
|
|
|
580
593
|
base_url = str(request.base_url).rstrip("/") + mount_prefix
|
|
581
594
|
base_url = base_url.replace("https://", "").replace("http://", "")
|
|
582
595
|
endpoint.endpoint = f"{base_url}/sandboxes/{sandbox_id}/proxy/{port}"
|
|
596
|
+
if endpoint.headers:
|
|
597
|
+
endpoint.headers = {
|
|
598
|
+
key: value
|
|
599
|
+
for key, value in endpoint.headers.items()
|
|
600
|
+
if key.lower() != OPEN_SANDBOX_INGRESS_HEADER.lower()
|
|
601
|
+
} or None
|
|
583
602
|
|
|
584
603
|
return endpoint
|
|
@@ -20,6 +20,7 @@ import hmac
|
|
|
20
20
|
import logging
|
|
21
21
|
from collections.abc import AsyncIterator, Mapping
|
|
22
22
|
from typing import Optional
|
|
23
|
+
from urllib.parse import urlsplit
|
|
23
24
|
|
|
24
25
|
import anyio
|
|
25
26
|
import httpx
|
|
@@ -27,6 +28,7 @@ import websockets
|
|
|
27
28
|
from fastapi import APIRouter, Request, WebSocket, status
|
|
28
29
|
from fastapi.exceptions import HTTPException
|
|
29
30
|
from fastapi.responses import StreamingResponse
|
|
31
|
+
from starlette.types import Receive, Scope, Send
|
|
30
32
|
from starlette.websockets import WebSocketDisconnect
|
|
31
33
|
from websockets.asyncio.client import ClientConnection
|
|
32
34
|
from websockets.typing import Origin
|
|
@@ -52,6 +54,12 @@ HOP_BY_HOP_HEADERS = {
|
|
|
52
54
|
"upgrade",
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
# Uvicorn adds this to client-facing responses. Forwarding the backend value as
|
|
58
|
+
# well would produce a duplicate field on the wire.
|
|
59
|
+
SERVER_GENERATED_RESPONSE_HEADERS = {
|
|
60
|
+
"server",
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
# Headers that shouldn't be forwarded to untrusted/internal backends
|
|
56
64
|
SENSITIVE_HEADERS = {
|
|
57
65
|
"authorization",
|
|
@@ -163,6 +171,30 @@ def _set_forwarded_headers(
|
|
|
163
171
|
headers["X-Forwarded-For"] = request.client.host
|
|
164
172
|
|
|
165
173
|
|
|
174
|
+
def _rewrite_proxy_location(
|
|
175
|
+
location: str,
|
|
176
|
+
request: Request,
|
|
177
|
+
sandbox_id: str,
|
|
178
|
+
port: int,
|
|
179
|
+
) -> str:
|
|
180
|
+
"""Keep root-relative redirects inside the current sandbox proxy route."""
|
|
181
|
+
if not location.startswith("/") or location.startswith("//"):
|
|
182
|
+
return location
|
|
183
|
+
|
|
184
|
+
proxy_suffix = f"/sandboxes/{sandbox_id}/proxy/{port}"
|
|
185
|
+
eip = (lifecycle.get_config().server.eip or "").strip().rstrip("/")
|
|
186
|
+
if eip:
|
|
187
|
+
external_url = eip if "://" in eip else f"//{eip}"
|
|
188
|
+
external_prefix = urlsplit(external_url).path.rstrip("/")
|
|
189
|
+
return f"{external_prefix}{proxy_suffix}{location}"
|
|
190
|
+
|
|
191
|
+
proxy_start = request.url.path.find(proxy_suffix)
|
|
192
|
+
if proxy_start < 0:
|
|
193
|
+
return location
|
|
194
|
+
proxy_path = request.url.path[: proxy_start + len(proxy_suffix)]
|
|
195
|
+
return f"{proxy_path}{location}"
|
|
196
|
+
|
|
197
|
+
|
|
166
198
|
def _schedule_proxy_renew(request: Request | WebSocket, sandbox_id: str) -> None:
|
|
167
199
|
proxy_renew = getattr(request.app.state, "proxy_renew_coordinator", None)
|
|
168
200
|
if proxy_renew is not None:
|
|
@@ -207,19 +239,43 @@ async def _authenticate_websocket_tenant(websocket: WebSocket) -> bool:
|
|
|
207
239
|
return True
|
|
208
240
|
|
|
209
241
|
|
|
242
|
+
async def _close_backend_response(resp: httpx.Response) -> None:
|
|
243
|
+
"""Return a streamed backend response to httpx's pool, even during cancellation."""
|
|
244
|
+
with anyio.CancelScope(shield=True):
|
|
245
|
+
await resp.aclose()
|
|
246
|
+
|
|
247
|
+
|
|
210
248
|
async def _stream_backend_response(resp: httpx.Response) -> AsyncIterator[bytes]:
|
|
211
|
-
"""
|
|
212
|
-
|
|
249
|
+
"""Yield raw backend chunks so content-encoding still matches the body bytes."""
|
|
250
|
+
async for chunk in resp.aiter_raw():
|
|
251
|
+
yield chunk
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class _ProxyStreamingResponse(StreamingResponse):
|
|
255
|
+
"""Streaming response that owns and always releases its httpx response."""
|
|
256
|
+
|
|
257
|
+
def __init__(
|
|
258
|
+
self,
|
|
259
|
+
resp: httpx.Response,
|
|
260
|
+
*,
|
|
261
|
+
status_code: int,
|
|
262
|
+
headers: Mapping[str, str],
|
|
263
|
+
) -> None:
|
|
264
|
+
self._backend_response = resp
|
|
265
|
+
super().__init__(
|
|
266
|
+
content=_stream_backend_response(resp),
|
|
267
|
+
status_code=status_code,
|
|
268
|
+
headers=headers,
|
|
269
|
+
)
|
|
213
270
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
await resp.aclose()
|
|
271
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
272
|
+
try:
|
|
273
|
+
await super().__call__(scope, receive, send)
|
|
274
|
+
finally:
|
|
275
|
+
# The body iterator may never start if the downstream disconnects
|
|
276
|
+
# while Starlette sends response headers. Keep ownership here so
|
|
277
|
+
# that connection is still returned to the shared httpx pool.
|
|
278
|
+
await _close_backend_response(self._backend_response)
|
|
223
279
|
|
|
224
280
|
|
|
225
281
|
def _verify_secure_access(endpoint: Endpoint, caller_headers: Mapping[str, str]) -> None:
|
|
@@ -295,25 +351,36 @@ async def _proxy_http_request(
|
|
|
295
351
|
|
|
296
352
|
resp = await client.send(req, stream=True)
|
|
297
353
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
354
|
+
try:
|
|
355
|
+
hop_by_hop = set(HOP_BY_HOP_HEADERS)
|
|
356
|
+
connection_header = resp.headers.get("connection")
|
|
357
|
+
if connection_header:
|
|
358
|
+
hop_by_hop.update(
|
|
359
|
+
header.strip().lower()
|
|
360
|
+
for header in connection_header.split(",")
|
|
361
|
+
if header.strip()
|
|
362
|
+
)
|
|
363
|
+
response_header_exclusions = hop_by_hop | SERVER_GENERATED_RESPONSE_HEADERS
|
|
364
|
+
response_headers = {
|
|
365
|
+
key: (
|
|
366
|
+
_rewrite_proxy_location(value, request, sandbox_id, port)
|
|
367
|
+
if key.lower() == "location"
|
|
368
|
+
else value
|
|
369
|
+
)
|
|
370
|
+
for key, value in resp.headers.items()
|
|
371
|
+
if key.lower() not in response_header_exclusions
|
|
372
|
+
}
|
|
311
373
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
374
|
+
return _ProxyStreamingResponse(
|
|
375
|
+
resp,
|
|
376
|
+
status_code=resp.status_code,
|
|
377
|
+
headers=response_headers,
|
|
378
|
+
)
|
|
379
|
+
except BaseException:
|
|
380
|
+
# Until ownership passes to _ProxyStreamingResponse, any failure
|
|
381
|
+
# after client.send() must release the acquired pool connection.
|
|
382
|
+
await _close_backend_response(resp)
|
|
383
|
+
raise
|
|
317
384
|
except httpx.ConnectError as e:
|
|
318
385
|
raise HTTPException(
|
|
319
386
|
status_code=502,
|
|
@@ -495,10 +562,6 @@ async def _proxy_websocket_request(
|
|
|
495
562
|
await _fail_client_websocket(websocket, status.WS_1011_INTERNAL_ERROR, "")
|
|
496
563
|
|
|
497
564
|
|
|
498
|
-
@router.api_route(
|
|
499
|
-
"/sandboxes/{sandbox_id}/proxy/{port}",
|
|
500
|
-
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
501
|
-
)
|
|
502
565
|
async def proxy_sandbox_endpoint_root(
|
|
503
566
|
request: Request,
|
|
504
567
|
sandbox_id: str,
|
|
@@ -508,10 +571,6 @@ async def proxy_sandbox_endpoint_root(
|
|
|
508
571
|
return await _proxy_http_request(request, sandbox_id, port, "")
|
|
509
572
|
|
|
510
573
|
|
|
511
|
-
@router.api_route(
|
|
512
|
-
"/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}",
|
|
513
|
-
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
514
|
-
)
|
|
515
574
|
async def proxy_sandbox_endpoint_request(
|
|
516
575
|
request: Request,
|
|
517
576
|
sandbox_id: str,
|
|
@@ -522,6 +581,39 @@ async def proxy_sandbox_endpoint_request(
|
|
|
522
581
|
return await _proxy_http_request(request, sandbox_id, port, full_path)
|
|
523
582
|
|
|
524
583
|
|
|
584
|
+
_PROXY_HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH")
|
|
585
|
+
|
|
586
|
+
# Keep the multi-method route first for runtime dispatch so 405 responses retain
|
|
587
|
+
# the complete Allow header. The method-specific routes provide unique OpenAPI IDs.
|
|
588
|
+
router.add_api_route(
|
|
589
|
+
"/sandboxes/{sandbox_id}/proxy/{port}",
|
|
590
|
+
proxy_sandbox_endpoint_root,
|
|
591
|
+
methods=list(_PROXY_HTTP_METHODS),
|
|
592
|
+
include_in_schema=False,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
for _method in _PROXY_HTTP_METHODS:
|
|
596
|
+
router.add_api_route(
|
|
597
|
+
"/sandboxes/{sandbox_id}/proxy/{port}",
|
|
598
|
+
proxy_sandbox_endpoint_root,
|
|
599
|
+
methods=[_method],
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
router.add_api_route(
|
|
603
|
+
"/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}",
|
|
604
|
+
proxy_sandbox_endpoint_request,
|
|
605
|
+
methods=list(_PROXY_HTTP_METHODS),
|
|
606
|
+
include_in_schema=False,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
for _method in _PROXY_HTTP_METHODS:
|
|
610
|
+
router.add_api_route(
|
|
611
|
+
"/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}",
|
|
612
|
+
proxy_sandbox_endpoint_request,
|
|
613
|
+
methods=[_method],
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
525
617
|
@router.websocket("/sandboxes/{sandbox_id}/proxy/{port}")
|
|
526
618
|
async def proxy_sandbox_endpoint_root_websocket(
|
|
527
619
|
websocket: WebSocket,
|
|
@@ -24,6 +24,7 @@ from typing import Dict, List, Literal, Optional
|
|
|
24
24
|
|
|
25
25
|
from pydantic import BaseModel, Field, RootModel, model_validator
|
|
26
26
|
|
|
27
|
+
from opensandbox_server.constants import OPENSANDBOX_LIFECYCLE
|
|
27
28
|
|
|
28
29
|
# ============================================================================
|
|
29
30
|
# Image Specification
|
|
@@ -138,6 +139,66 @@ class CredentialProxyConfig(BaseModel):
|
|
|
138
139
|
populate_by_name = True
|
|
139
140
|
|
|
140
141
|
|
|
142
|
+
class LifecycleHook(BaseModel):
|
|
143
|
+
"""Command executed by execd before the user entrypoint starts."""
|
|
144
|
+
|
|
145
|
+
command: List[str] = Field(..., min_length=1)
|
|
146
|
+
timeout_seconds: Optional[int] = Field(None, alias="timeoutSeconds", ge=1, le=10800)
|
|
147
|
+
|
|
148
|
+
@model_validator(mode="after")
|
|
149
|
+
def validate_command(self) -> "LifecycleHook":
|
|
150
|
+
if not self.command[0].strip():
|
|
151
|
+
raise ValueError("Lifecycle hook command must not be empty.")
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
class Config:
|
|
155
|
+
populate_by_name = True
|
|
156
|
+
extra = "forbid"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class PeriodicLifecycleHook(BaseModel):
|
|
160
|
+
"""Named command scheduled by execd while the sandbox is running."""
|
|
161
|
+
|
|
162
|
+
name: str = Field(..., min_length=1)
|
|
163
|
+
schedule: str = Field(..., min_length=1)
|
|
164
|
+
command: List[str] = Field(..., min_length=1)
|
|
165
|
+
timeout_seconds: Optional[int] = Field(None, alias="timeoutSeconds", ge=1, le=300)
|
|
166
|
+
|
|
167
|
+
@model_validator(mode="after")
|
|
168
|
+
def normalize_and_validate(self) -> "PeriodicLifecycleHook":
|
|
169
|
+
self.name = self.name.strip()
|
|
170
|
+
self.schedule = self.schedule.strip()
|
|
171
|
+
if not self.name:
|
|
172
|
+
raise ValueError("Periodic lifecycle hook name must not be blank.")
|
|
173
|
+
if not self.schedule:
|
|
174
|
+
raise ValueError("Periodic lifecycle hook schedule must not be blank.")
|
|
175
|
+
if not self.command[0].strip():
|
|
176
|
+
raise ValueError("Periodic lifecycle hook command must not be empty.")
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
class Config:
|
|
180
|
+
populate_by_name = True
|
|
181
|
+
extra = "forbid"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class SandboxLifecycle(BaseModel):
|
|
185
|
+
"""Extensible lifecycle configuration transported internally to execd."""
|
|
186
|
+
|
|
187
|
+
pre_start: Optional[LifecycleHook] = Field(None, alias="preStart")
|
|
188
|
+
periodic: Optional[List[PeriodicLifecycleHook]] = None
|
|
189
|
+
|
|
190
|
+
@model_validator(mode="after")
|
|
191
|
+
def validate_periodic_names(self) -> "SandboxLifecycle":
|
|
192
|
+
names = [hook.name for hook in self.periodic or []]
|
|
193
|
+
if len(names) != len(set(names)):
|
|
194
|
+
raise ValueError("Periodic lifecycle hook names must be unique.")
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
class Config:
|
|
198
|
+
populate_by_name = True
|
|
199
|
+
extra = "forbid"
|
|
200
|
+
|
|
201
|
+
|
|
141
202
|
# ============================================================================
|
|
142
203
|
# Volume Definitions
|
|
143
204
|
# ============================================================================
|
|
@@ -443,6 +504,10 @@ class CreateSandboxRequest(BaseModel):
|
|
|
443
504
|
None,
|
|
444
505
|
description="Custom key-value metadata for management, filtering, and tagging",
|
|
445
506
|
)
|
|
507
|
+
lifecycle: Optional[SandboxLifecycle] = Field(
|
|
508
|
+
None,
|
|
509
|
+
description="Optional declarative lifecycle hooks executed by execd.",
|
|
510
|
+
)
|
|
446
511
|
entrypoint: Optional[List[str]] = Field(
|
|
447
512
|
None,
|
|
448
513
|
min_length=1,
|
|
@@ -493,10 +558,18 @@ class CreateSandboxRequest(BaseModel):
|
|
|
493
558
|
|
|
494
559
|
@model_validator(mode="after")
|
|
495
560
|
def validate_source_and_entrypoint(self) -> "CreateSandboxRequest":
|
|
561
|
+
if self.env and OPENSANDBOX_LIFECYCLE in self.env:
|
|
562
|
+
raise ValueError(
|
|
563
|
+
f"Environment variable '{OPENSANDBOX_LIFECYCLE}' is reserved. "
|
|
564
|
+
"Use the lifecycle request field instead."
|
|
565
|
+
)
|
|
566
|
+
|
|
496
567
|
# When poolRef is set, image/snapshotId/entrypoint/resourceLimits are
|
|
497
568
|
# all defined in the Pool CRD and not required from the caller.
|
|
498
569
|
has_pool_ref = bool((self.extensions or {}).get("poolRef", "").strip())
|
|
499
570
|
if has_pool_ref:
|
|
571
|
+
if self.lifecycle is not None:
|
|
572
|
+
raise ValueError("lifecycle cannot be used together with poolRef.")
|
|
500
573
|
# Reject conflicting fields that would be ignored in pool mode
|
|
501
574
|
if bool((self.snapshot_id or "").strip()):
|
|
502
575
|
raise ValueError("snapshotId cannot be used together with poolRef.")
|
|
@@ -566,6 +639,17 @@ class CreateSandboxResponse(BaseModel):
|
|
|
566
639
|
populate_by_name = True
|
|
567
640
|
|
|
568
641
|
|
|
642
|
+
class AllocationSummary(BaseModel):
|
|
643
|
+
"""Current runtime-confirmed pool allocation summary."""
|
|
644
|
+
mode: Literal["pool"] = Field("pool", description="Allocation mode.")
|
|
645
|
+
pool_ref: str = Field(..., alias="poolRef", description="Concrete pool reference currently allocated.")
|
|
646
|
+
state: Literal["allocated"] = Field("allocated", description="Current confirmed allocation state.")
|
|
647
|
+
|
|
648
|
+
class Config:
|
|
649
|
+
populate_by_name = True
|
|
650
|
+
extra = "forbid"
|
|
651
|
+
|
|
652
|
+
|
|
569
653
|
class Sandbox(BaseModel):
|
|
570
654
|
"""
|
|
571
655
|
Runtime execution environment provisioned from a container image.
|
|
@@ -592,6 +676,14 @@ class Sandbox(BaseModel):
|
|
|
592
676
|
None,
|
|
593
677
|
description="Opaque extension data restored from provider-specific storage",
|
|
594
678
|
)
|
|
679
|
+
allocation: Optional[AllocationSummary] = Field(
|
|
680
|
+
None,
|
|
681
|
+
description=(
|
|
682
|
+
"Current runtime-confirmed pool allocation summary. Omitted unless the runtime confirms "
|
|
683
|
+
"an active pool allocation; it is not a request echo, allocation history, readiness signal, "
|
|
684
|
+
"or Kubernetes introspection result."
|
|
685
|
+
),
|
|
686
|
+
)
|
|
595
687
|
entrypoint: Optional[List[str]] = Field(None, description="The command to execute as the sandbox's entry process")
|
|
596
688
|
expires_at: Optional[datetime] = Field(
|
|
597
689
|
None,
|