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.
Files changed (124) hide show
  1. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/.gitignore +13 -0
  2. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/PKG-INFO +4 -2
  3. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/devops.py +32 -29
  4. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/lifecycle.py +20 -1
  5. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/proxy.py +129 -37
  6. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/schema.py +92 -0
  7. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/cli.py +1 -0
  8. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/config.py +93 -3
  9. opensandbox_server-0.2.3/opensandbox_server/constants.py +18 -0
  10. opensandbox_server-0.2.3/opensandbox_server/examples/e2e.batchsandbox-template.yaml +33 -0
  11. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.config.k8s.toml +2 -2
  12. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.config.k8s.zh.toml +2 -2
  13. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.config.toml +9 -2
  14. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.config.zh.toml +8 -2
  15. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/otel/__init__.py +3 -1
  16. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/otel/metrics.py +75 -6
  17. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/main.py +67 -6
  18. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/middleware/auth.py +1 -1
  19. opensandbox_server-0.2.3/opensandbox_server/middleware/date_header.py +43 -0
  20. opensandbox_server-0.2.3/opensandbox_server/middleware/http_metrics.py +86 -0
  21. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/constants.py +12 -1
  22. opensandbox_server-0.2.3/opensandbox_server/services/diagnostics.py +85 -0
  23. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/container_ops.py +5 -1
  24. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/docker_diagnostics.py +91 -1
  25. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/docker_service.py +26 -2
  26. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/networking.py +4 -1
  27. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/runtime.py +116 -1
  28. opensandbox_server-0.2.3/opensandbox_server/services/fleets/__init__.py +13 -0
  29. opensandbox_server-0.2.3/opensandbox_server/services/fleets/create_mapping.py +280 -0
  30. opensandbox_server-0.2.3/opensandbox_server/services/fleets/fastpath_client.py +310 -0
  31. opensandbox_server-0.2.3/opensandbox_server/services/fleets/status_mapping.py +106 -0
  32. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/helpers.py +13 -1
  33. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/agent_sandbox_provider.py +25 -0
  34. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/batchsandbox_provider.py +148 -26
  35. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/client.py +20 -6
  36. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/create_helpers.py +6 -0
  37. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/egress_helper.py +6 -0
  38. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/informer.py +37 -2
  39. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/k8s_diagnostics.py +149 -8
  40. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/kubernetes_service.py +74 -25
  41. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/provider_common.py +9 -1
  42. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/snapshot_runtime.py +10 -0
  43. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/status_helpers.py +8 -1
  44. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/volume_helper.py +8 -20
  45. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/workload_mapper.py +122 -1
  46. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/workload_provider.py +9 -0
  47. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/sandbox_service.py +35 -0
  48. opensandbox_server-0.2.3/opensandbox_server/tenants/__init__.py +156 -0
  49. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/tenants/file_provider.py +5 -0
  50. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/tenants/http_provider.py +5 -0
  51. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/tenants/provider.py +8 -0
  52. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/pyproject.toml +4 -0
  53. opensandbox_server-0.2.2/opensandbox_server/examples/e2e.batchsandbox-template.yaml +0 -17
  54. opensandbox_server-0.2.2/opensandbox_server/tenants/__init__.py +0 -65
  55. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/LICENSE +0 -0
  56. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/README.md +0 -0
  57. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/__init__.py +0 -0
  58. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/__init__.py +0 -0
  59. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/metrics.py +0 -0
  60. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/api/pool.py +0 -0
  61. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/.gitkeep +0 -0
  62. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/__init__.py +0 -0
  63. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/examples/example.batchsandbox-template.yaml +0 -0
  64. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/extensions/__init__.py +0 -0
  65. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/extensions/codec.py +0 -0
  66. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/extensions/keys.py +0 -0
  67. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/extensions/validation.py +0 -0
  68. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/__init__.py +0 -0
  69. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/__init__.py +0 -0
  70. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/constants.py +0 -0
  71. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/consumer.py +0 -0
  72. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/controller.py +0 -0
  73. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/intent.py +0 -0
  74. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/logutil.py +0 -0
  75. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/proxy_renew.py +0 -0
  76. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/redis_client.py +0 -0
  77. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/integrations/renew_intent/runner.py +0 -0
  78. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/logging_config.py +0 -0
  79. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/middleware/__init__.py +0 -0
  80. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/middleware/request_id.py +0 -0
  81. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/py.typed +0 -0
  82. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/repositories/__init__.py +0 -0
  83. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/repositories/snapshots/__init__.py +0 -0
  84. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/repositories/snapshots/factory.py +0 -0
  85. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/repositories/snapshots/sqlite.py +0 -0
  86. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/__init__.py +0 -0
  87. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/__init__.py +0 -0
  88. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/metadata.py +0 -0
  89. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/ossfs_mixin.py +0 -0
  90. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/port_allocator.py +0 -0
  91. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/snapshot_runtime.py +0 -0
  92. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/volumes.py +0 -0
  93. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/docker/windows_profile.py +0 -0
  94. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/endpoint_auth.py +0 -0
  95. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/extension_service.py +0 -0
  96. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/factory.py +0 -0
  97. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/__init__.py +0 -0
  98. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/agent_sandbox_template.py +0 -0
  99. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/batchsandbox_template.py +0 -0
  100. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/endpoint_resolver.py +0 -0
  101. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/error_helpers.py +0 -0
  102. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/image_pull_secret_helper.py +0 -0
  103. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/label_selector.py +0 -0
  104. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/list_helpers.py +0 -0
  105. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/pool_service.py +0 -0
  106. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/provider_factory.py +0 -0
  107. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/rate_limiter.py +0 -0
  108. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/security_context.py +0 -0
  109. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/template_manager.py +0 -0
  110. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/windows_profile.py +0 -0
  111. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/k8s/workload_access.py +0 -0
  112. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/runtime_resolver.py +0 -0
  113. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/signing.py +0 -0
  114. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_models.py +0 -0
  115. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_repository.py +0 -0
  116. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_restore.py +0 -0
  117. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_runtime.py +0 -0
  118. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_runtime_factory.py +0 -0
  119. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/snapshot_service.py +0 -0
  120. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/validators.py +0 -0
  121. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/services/windows_common.py +0 -0
  122. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/startup_guard.py +0 -0
  123. {opensandbox_server-0.2.2 → opensandbox_server-0.2.3}/opensandbox_server/tenants/context.py +0 -0
  124. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: opensandbox-server
3
- Version: 0.2.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 `scope` target the stable Diagnostics API. The open-source
19
- server has not implemented that API yet, so these requests return a uniform
20
- not-implemented error. Requests without `scope` preserve the deprecated DevOps
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 _diagnostics_not_implemented_response() -> JSONResponse:
37
- return JSONResponse(
38
- status_code=status.HTTP_501_NOT_IMPLEMENTED,
39
- content={
40
- "code": "DIAGNOSTICS_NOT_IMPLEMENTED",
41
- "message": "The stable Diagnostics API is not implemented by this OpenSandbox server.",
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": "Deprecated plain-text logs when scope is omitted",
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
- return _diagnostics_not_implemented_response()
100
- text = sandbox_service.get_sandbox_logs(
101
- sandbox_id, tail=tail, since=since, container=container
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": "Deprecated plain-text events when scope is omitted",
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
- return _diagnostics_not_implemented_response()
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 SandboxErrorCodes
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
- Yield backend body chunks without httpx content decoding and always close the response.
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
- httpx requires ``await resp.aclose()`` for ``stream=True`` responses so connections
215
- return to the pool; Starlette's StreamingResponse does not do this automatically.
216
- Use ``aiter_raw`` so forwarded ``content-encoding`` headers still match the body bytes.
217
- """
218
- try:
219
- async for chunk in resp.aiter_raw():
220
- yield chunk
221
- finally:
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
- hop_by_hop = set(HOP_BY_HOP_HEADERS)
299
- connection_header = resp.headers.get("connection")
300
- if connection_header:
301
- hop_by_hop.update(
302
- header.strip().lower()
303
- for header in connection_header.split(",")
304
- if header.strip()
305
- )
306
- response_headers = {
307
- key: value
308
- for key, value in resp.headers.items()
309
- if key.lower() not in hop_by_hop
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
- return StreamingResponse(
313
- content=_stream_backend_response(resp),
314
- status_code=resp.status_code,
315
- headers=response_headers,
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,
@@ -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
+ date_header=False,
308
309
  timeout_graceful_shutdown=server_cfg.timeout_graceful_shutdown,
309
310
  )
310
311