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.
Files changed (116) hide show
  1. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/.gitignore +1 -0
  2. opensandbox_server-0.2.2/PKG-INFO +46 -0
  3. opensandbox_server-0.2.2/README.md +4 -0
  4. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/devops.py +12 -1
  5. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/lifecycle.py +12 -3
  6. opensandbox_server-0.2.2/opensandbox_server/api/metrics.py +83 -0
  7. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/proxy.py +122 -17
  8. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/schema.py +68 -9
  9. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/cli.py +1 -0
  10. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/config.py +123 -13
  11. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.k8s.toml +4 -4
  12. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.k8s.zh.toml +4 -4
  13. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.toml +9 -4
  14. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.config.zh.toml +9 -4
  15. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/extensions/__init__.py +8 -2
  16. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/extensions/codec.py +23 -3
  17. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/extensions/keys.py +9 -0
  18. opensandbox_server-0.2.2/opensandbox_server/integrations/otel/__init__.py +27 -0
  19. opensandbox_server-0.2.2/opensandbox_server/integrations/otel/metrics.py +166 -0
  20. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/consumer.py +2 -2
  21. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/main.py +49 -7
  22. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/middleware/auth.py +58 -39
  23. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/repositories/snapshots/sqlite.py +80 -1
  24. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/constants.py +21 -0
  25. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/__init__.py +1 -2
  26. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/container_ops.py +2 -0
  27. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/docker_diagnostics.py +13 -3
  28. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/docker_service.py +183 -192
  29. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/metadata.py +30 -8
  30. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/networking.py +81 -23
  31. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/port_allocator.py +3 -1
  32. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/runtime.py +36 -0
  33. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/snapshot_runtime.py +12 -3
  34. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/helpers.py +35 -1
  35. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/agent_sandbox_provider.py +33 -10
  36. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/batchsandbox_provider.py +40 -7
  37. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/client.py +60 -0
  38. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/create_helpers.py +32 -0
  39. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/egress_helper.py +12 -7
  40. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/informer.py +23 -4
  41. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/k8s_diagnostics.py +121 -5
  42. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/kubernetes_service.py +696 -84
  43. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/pool_service.py +24 -22
  44. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/provider_common.py +34 -2
  45. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/security_context.py +14 -0
  46. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/snapshot_runtime.py +24 -15
  47. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/volume_helper.py +32 -1
  48. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/windows_profile.py +16 -4
  49. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/workload_mapper.py +4 -0
  50. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/workload_provider.py +3 -0
  51. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/runtime_resolver.py +17 -1
  52. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/sandbox_service.py +12 -1
  53. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_models.py +1 -0
  54. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_repository.py +2 -0
  55. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_restore.py +11 -0
  56. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_runtime.py +8 -4
  57. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_service.py +38 -4
  58. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/validators.py +77 -3
  59. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/startup_guard.py +4 -4
  60. opensandbox_server-0.2.2/opensandbox_server/tenants/__init__.py +65 -0
  61. opensandbox_server-0.2.2/opensandbox_server/tenants/context.py +30 -0
  62. opensandbox_server-0.2.2/opensandbox_server/tenants/file_provider.py +188 -0
  63. opensandbox_server-0.2.2/opensandbox_server/tenants/http_provider.py +235 -0
  64. opensandbox_server-0.2.2/opensandbox_server/tenants/models.py +25 -0
  65. opensandbox_server-0.2.2/opensandbox_server/tenants/provider.py +66 -0
  66. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/pyproject.toml +10 -5
  67. opensandbox_server-0.2.0/PKG-INFO +0 -327
  68. opensandbox_server-0.2.0/README.md +0 -290
  69. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/LICENSE +0 -0
  70. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/__init__.py +0 -0
  71. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/__init__.py +0 -0
  72. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/api/pool.py +0 -0
  73. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/.gitkeep +0 -0
  74. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/__init__.py +0 -0
  75. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/e2e.batchsandbox-template.yaml +0 -0
  76. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/examples/example.batchsandbox-template.yaml +0 -0
  77. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/extensions/validation.py +0 -0
  78. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/__init__.py +0 -0
  79. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/__init__.py +0 -0
  80. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/constants.py +0 -0
  81. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/controller.py +0 -0
  82. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/intent.py +0 -0
  83. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/logutil.py +0 -0
  84. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/proxy_renew.py +0 -0
  85. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/redis_client.py +0 -0
  86. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/integrations/renew_intent/runner.py +0 -0
  87. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/logging_config.py +0 -0
  88. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/middleware/__init__.py +0 -0
  89. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/middleware/request_id.py +0 -0
  90. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/py.typed +0 -0
  91. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/repositories/__init__.py +0 -0
  92. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/repositories/snapshots/__init__.py +0 -0
  93. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/repositories/snapshots/factory.py +0 -0
  94. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/__init__.py +0 -0
  95. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/ossfs_mixin.py +0 -0
  96. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/volumes.py +0 -0
  97. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/docker/windows_profile.py +0 -0
  98. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/endpoint_auth.py +0 -0
  99. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/extension_service.py +0 -0
  100. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/factory.py +0 -0
  101. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/__init__.py +0 -0
  102. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/agent_sandbox_template.py +0 -0
  103. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/batchsandbox_template.py +0 -0
  104. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/endpoint_resolver.py +0 -0
  105. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/error_helpers.py +0 -0
  106. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/image_pull_secret_helper.py +0 -0
  107. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/label_selector.py +0 -0
  108. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/list_helpers.py +0 -0
  109. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/provider_factory.py +0 -0
  110. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/rate_limiter.py +0 -0
  111. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/status_helpers.py +0 -0
  112. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/template_manager.py +0 -0
  113. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/k8s/workload_access.py +0 -0
  114. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/signing.py +0 -0
  115. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/snapshot_runtime_factory.py +0 -0
  116. {opensandbox_server-0.2.0 → opensandbox_server-0.2.2}/opensandbox_server/services/windows_common.py +0 -0
@@ -276,3 +276,4 @@ bin/
276
276
  obj/
277
277
 
278
278
  .qoder/
279
+ /.vs
@@ -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
+
@@ -0,0 +1,4 @@
1
+ # OpenSandbox Server
2
+
3
+ Documentation: [docs/components/server.md](../docs/components/server.md)
4
+
@@ -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(sandbox_id, tail=tail, since=since)
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
- base_url = str(request.base_url).rstrip("/")
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
- # Inject standard reverse-proxy headers. Check for existing values
182
- # case-insensitively so an already-present header with any casing
183
- # (e.g. lowercase "x-forwarded-proto" from an upstream edge) is
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
- _schedule_proxy_renew(websocket, sandbox_id)
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
- "(Docker only). Pre-existing volumes are never removed. Has no effect "
202
- "on Kubernetes PVCs, whose lifecycle is managed by the StorageClass "
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 and self.network_policy is None:
502
- raise ValueError("credentialProxy.enabled requires networkPolicy.")
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
@@ -305,6 +305,7 @@ def main() -> None:
305
305
  backlog=server_cfg.backlog,
306
306
  loop=server_cfg.loop,
307
307
  http=server_cfg.http,
308
+ timeout_graceful_shutdown=server_cfg.timeout_graceful_shutdown,
308
309
  )
309
310
 
310
311