mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
feat(provisioner): support ClusterIP services and scoped skills PVC mounts (#4016)
Some checks are pending
Backend Blocking IO / backend-blocking-io (push) Waiting to run
Unit Tests / backend-unit-tests (push) Waiting to run
Frontend Unit Tests / frontend-unit-tests (push) Waiting to run
Lint Check / lint-backend (push) Waiting to run
Lint Check / lint-frontend (push) Waiting to run
Replay E2E (front-back contract) / Layer 1 — backend golden (no API key) (push) Waiting to run
Replay E2E (front-back contract) / Layer 2 — full-stack render (no API key) (push) Waiting to run
Some checks are pending
Backend Blocking IO / backend-blocking-io (push) Waiting to run
Unit Tests / backend-unit-tests (push) Waiting to run
Frontend Unit Tests / frontend-unit-tests (push) Waiting to run
Lint Check / lint-backend (push) Waiting to run
Lint Check / lint-frontend (push) Waiting to run
Replay E2E (front-back contract) / Layer 1 — backend golden (no API key) (push) Waiting to run
Replay E2E (front-back contract) / Layer 2 — full-stack render (no API key) (push) Waiting to run
* feat(provisioner): add ClusterIP mode for sandbox services * feat(provisioner): support optional skills pvc subpath
This commit is contained in:
parent
52418d603b
commit
488ec17899
4 changed files with 205 additions and 131 deletions
|
|
@ -196,6 +196,22 @@ class TestBuildVolumeMounts:
|
||||||
userdata_mount = mounts[-1]
|
userdata_mount = mounts[-1]
|
||||||
assert userdata_mount.sub_path is None
|
assert userdata_mount.sub_path is None
|
||||||
|
|
||||||
|
def test_skills_pvc_does_not_set_subpath_by_default(self, provisioner_module):
|
||||||
|
"""PVC-backed skills keep legacy root mount unless explicitly configured."""
|
||||||
|
provisioner_module.SKILLS_PVC_NAME = "my-skills-pvc"
|
||||||
|
provisioner_module.SKILLS_PVC_SUBPATH_TEMPLATE = ""
|
||||||
|
mounts = provisioner_module._build_volume_mounts("thread-42", user_id="user-7")
|
||||||
|
skills_mount = mounts[0]
|
||||||
|
assert skills_mount.sub_path is None
|
||||||
|
|
||||||
|
def test_skills_pvc_can_use_user_scoped_subpath_template(self, provisioner_module):
|
||||||
|
"""Operators can opt into per-user/thread skills subPath for shared PVCs."""
|
||||||
|
provisioner_module.SKILLS_PVC_NAME = "my-skills-pvc"
|
||||||
|
provisioner_module.SKILLS_PVC_SUBPATH_TEMPLATE = "deer-flow/users/{user_id}/threads/{thread_id}/skills"
|
||||||
|
mounts = provisioner_module._build_volume_mounts("thread-42", user_id="user-7")
|
||||||
|
skills_mount = mounts[0]
|
||||||
|
assert skills_mount.sub_path == "deer-flow/users/user-7/threads/thread-42/skills"
|
||||||
|
|
||||||
def test_pvc_sets_user_scoped_subpath(self, provisioner_module):
|
def test_pvc_sets_user_scoped_subpath(self, provisioner_module):
|
||||||
"""PVC mode should include user_id in the user-data subPath."""
|
"""PVC mode should include user_id in the user-data subPath."""
|
||||||
provisioner_module.USERDATA_PVC_NAME = "my-pvc"
|
provisioner_module.USERDATA_PVC_NAME = "my-pvc"
|
||||||
|
|
@ -290,3 +306,12 @@ class TestBuildPodVolumes:
|
||||||
assert mount_paths["skills-public"] == "/mnt/skills/public"
|
assert mount_paths["skills-public"] == "/mnt/skills/public"
|
||||||
assert mount_paths["skills-custom"] == "/mnt/skills/custom"
|
assert mount_paths["skills-custom"] == "/mnt/skills/custom"
|
||||||
assert mount_paths["skills-legacy"] == "/mnt/skills/legacy"
|
assert mount_paths["skills-legacy"] == "/mnt/skills/legacy"
|
||||||
|
|
||||||
|
def test_pod_pvc_mode_can_use_user_scoped_skills_subpath(self, provisioner_module):
|
||||||
|
"""Pod should use a configured user-scoped subPath for PVC skills."""
|
||||||
|
provisioner_module.SKILLS_PVC_NAME = "skills-pvc"
|
||||||
|
provisioner_module.SKILLS_PVC_SUBPATH_TEMPLATE = "deer-flow/users/{user_id}/threads/{thread_id}/skills"
|
||||||
|
provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
|
||||||
|
pod = provisioner_module._build_pod("sandbox-1", "thread-1", user_id="user-7")
|
||||||
|
skills_mount = pod.spec.containers[0].volume_mounts[0]
|
||||||
|
assert skills_mount.sub_path == "deer-flow/users/user-7/threads/thread-1/skills"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from types import SimpleNamespace
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from blockbuster import BlockBuster
|
from blockbuster import BlockBuster
|
||||||
|
from kubernetes.client.rest import ApiException
|
||||||
|
|
||||||
|
|
||||||
class _RecordingCoreV1:
|
class _RecordingCoreV1:
|
||||||
|
|
@ -20,11 +21,13 @@ class _RecordingCoreV1:
|
||||||
*,
|
*,
|
||||||
event_loop_thread_id: int,
|
event_loop_thread_id: int,
|
||||||
ready_after_service_reads: dict[str, int] | None = None,
|
ready_after_service_reads: dict[str, int] | None = None,
|
||||||
|
service_read_failures: dict[str, list[int]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.event_loop_thread_id = event_loop_thread_id
|
self.event_loop_thread_id = event_loop_thread_id
|
||||||
self.thread_ids: list[int] = []
|
self.thread_ids: list[int] = []
|
||||||
self.service_sandboxes: set[str] = {"sandbox-existing"}
|
self.service_sandboxes: set[str] = {"sandbox-existing"}
|
||||||
self.ready_after_service_reads = ready_after_service_reads or {}
|
self.ready_after_service_reads = ready_after_service_reads or {}
|
||||||
|
self.service_read_failures = service_read_failures or {}
|
||||||
self.service_read_counts: dict[str, int] = {}
|
self.service_read_counts: dict[str, int] = {}
|
||||||
self.created_pods: list[str] = []
|
self.created_pods: list[str] = []
|
||||||
self.created_pod_specs: dict[str, object] = {}
|
self.created_pod_specs: dict[str, object] = {}
|
||||||
|
|
@ -46,10 +49,13 @@ class _RecordingCoreV1:
|
||||||
self._record_k8s_call()
|
self._record_k8s_call()
|
||||||
sandbox_id = _sandbox_id_from_service_name(_name)
|
sandbox_id = _sandbox_id_from_service_name(_name)
|
||||||
self.service_read_counts[sandbox_id] = self.service_read_counts.get(sandbox_id, 0) + 1
|
self.service_read_counts[sandbox_id] = self.service_read_counts.get(sandbox_id, 0) + 1
|
||||||
|
failures = self.service_read_failures.get(sandbox_id) or []
|
||||||
|
if failures:
|
||||||
|
raise ApiException(status=failures.pop(0))
|
||||||
ready_after_reads = self.ready_after_service_reads.get(sandbox_id, 1)
|
ready_after_reads = self.ready_after_service_reads.get(sandbox_id, 1)
|
||||||
if sandbox_id not in self.service_sandboxes or self.service_read_counts[sandbox_id] < ready_after_reads:
|
if sandbox_id not in self.service_sandboxes or self.service_read_counts[sandbox_id] < ready_after_reads:
|
||||||
return _service_without_node_port(sandbox_id)
|
raise ApiException(status=404)
|
||||||
return _service(sandbox_id)
|
return _node_port_service(sandbox_id)
|
||||||
|
|
||||||
def read_namespaced_pod(self, _name: str, _namespace: str):
|
def read_namespaced_pod(self, _name: str, _namespace: str):
|
||||||
self._record_k8s_call()
|
self._record_k8s_call()
|
||||||
|
|
@ -76,20 +82,13 @@ class _RecordingCoreV1:
|
||||||
def list_namespaced_service(self, _namespace: str, *, label_selector: str):
|
def list_namespaced_service(self, _namespace: str, *, label_selector: str):
|
||||||
self._record_k8s_call()
|
self._record_k8s_call()
|
||||||
assert label_selector == "app=deer-flow-sandbox"
|
assert label_selector == "app=deer-flow-sandbox"
|
||||||
return SimpleNamespace(items=[_service("sandbox-listed")])
|
return SimpleNamespace(items=[_node_port_service("sandbox-listed")])
|
||||||
|
|
||||||
|
|
||||||
def _service(sandbox_id: str):
|
def _node_port_service(sandbox_id: str):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
metadata=SimpleNamespace(labels={"sandbox-id": sandbox_id}),
|
metadata=SimpleNamespace(labels={"sandbox-id": sandbox_id}),
|
||||||
spec=SimpleNamespace(ports=[SimpleNamespace(name="http", node_port=32123)]),
|
spec=SimpleNamespace(ports=[SimpleNamespace(name="http", port=8080, node_port=32123)]),
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _service_without_node_port(sandbox_id: str):
|
|
||||||
return SimpleNamespace(
|
|
||||||
metadata=SimpleNamespace(labels={"sandbox-id": sandbox_id}),
|
|
||||||
spec=SimpleNamespace(ports=[]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -202,3 +201,52 @@ def test_create_sandbox_route_builds_expected_skills_mount_layout(
|
||||||
mount_names = [mount.name for mount in pod.spec.containers[0].volume_mounts]
|
mount_names = [mount.name for mount in pod.spec.containers[0].volume_mounts]
|
||||||
assert volume_names == expected_mount_names
|
assert volume_names == expected_mount_names
|
||||||
assert mount_names == expected_mount_names
|
assert mount_names == expected_mount_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_sandbox_retries_transient_service_read_errors(monkeypatch: pytest.MonkeyPatch, provisioner_module) -> None:
|
||||||
|
fake_core_v1 = _RecordingCoreV1(
|
||||||
|
event_loop_thread_id=-1,
|
||||||
|
ready_after_service_reads={"sandbox-transient": 3},
|
||||||
|
service_read_failures={"sandbox-transient": [503, 429]},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
|
||||||
|
monkeypatch.setattr(provisioner_module.time, "sleep", lambda _seconds: None)
|
||||||
|
|
||||||
|
response = provisioner_module.create_sandbox(
|
||||||
|
provisioner_module.CreateSandboxRequest(
|
||||||
|
sandbox_id="sandbox-transient",
|
||||||
|
thread_id="thread-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == "Running"
|
||||||
|
assert response.sandbox_url == provisioner_module._sandbox_url("sandbox-transient", node_port=32123)
|
||||||
|
assert fake_core_v1.service_read_counts["sandbox-transient"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_service_defaults_to_node_port_with_node_host_url(provisioner_module) -> None:
|
||||||
|
provisioner_module.K8S_NAMESPACE = "mdv-sit"
|
||||||
|
provisioner_module.SANDBOX_CONTAINER_PORT = 8080
|
||||||
|
provisioner_module.SANDBOX_SERVICE_TYPE = "NodePort"
|
||||||
|
provisioner_module.NODE_HOST = "node.example"
|
||||||
|
|
||||||
|
service = provisioner_module._build_service("abc123")
|
||||||
|
|
||||||
|
assert service.spec.type == "NodePort"
|
||||||
|
assert service.spec.ports[0].port == 8080
|
||||||
|
assert service.spec.ports[0].target_port == 8080
|
||||||
|
assert provisioner_module._sandbox_url("abc123", node_port=32123) == "http://node.example:32123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_service_supports_cluster_ip_with_dns_url(provisioner_module) -> None:
|
||||||
|
provisioner_module.K8S_NAMESPACE = "mdv-sit"
|
||||||
|
provisioner_module.SANDBOX_CONTAINER_PORT = 8080
|
||||||
|
provisioner_module.SANDBOX_SERVICE_TYPE = "ClusterIP"
|
||||||
|
|
||||||
|
service = provisioner_module._build_service("abc123")
|
||||||
|
|
||||||
|
assert service.spec.type == "ClusterIP"
|
||||||
|
assert service.spec.ports[0].port == 8080
|
||||||
|
assert service.spec.ports[0].target_port == 8080
|
||||||
|
assert provisioner_module._sandbox_url("abc123") == ("http://sandbox-abc123-svc.mdv-sit.svc.cluster.local:8080")
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ The **Sandbox Provisioner** is a FastAPI service that dynamically manages sandbo
|
||||||
│
|
│
|
||||||
┌─────────────┐ ┌────▼─────┐
|
┌─────────────┐ ┌────▼─────┐
|
||||||
│ Backend │ ──────▸ │ Sandbox │
|
│ Backend │ ──────▸ │ Sandbox │
|
||||||
│ (via Docker │ NodePort│ Pod(s) │
|
│ (NodePort │ or DNS │ Pod(s) │
|
||||||
│ network) │ └──────────┘
|
│ /ClusterIP)│ └──────────┘
|
||||||
└─────────────┘
|
└─────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -30,16 +30,16 @@ The **Sandbox Provisioner** is a FastAPI service that dynamically manages sandbo
|
||||||
- Resource limits (CPU, memory, ephemeral storage)
|
- Resource limits (CPU, memory, ephemeral storage)
|
||||||
- Readiness/liveness probes
|
- Readiness/liveness probes
|
||||||
|
|
||||||
3. **Service Creation**: A NodePort Service is created to expose the Pod, with Kubernetes auto-allocating a port from the NodePort range (typically 30000-32767).
|
3. **Service Creation**: A Service is created to expose the Pod. By default this is a NodePort Service for Docker Compose compatibility. Set `SANDBOX_SERVICE_TYPE=ClusterIP` when the backend runs inside the Kubernetes cluster.
|
||||||
|
|
||||||
4. **Access URL**: The provisioner returns `http://host.docker.internal:{NodePort}` to the backend, which the backend containers can reach directly.
|
4. **Access URL**: In NodePort mode, the provisioner returns `http://{NODE_HOST}:{NodePort}`. In ClusterIP mode, it returns a Kubernetes service DNS URL like `http://sandbox-{sandbox_id}-svc.{namespace}.svc.cluster.local:8080`.
|
||||||
|
|
||||||
5. **Cleanup**: When the session ends, `DELETE /api/sandboxes/{sandbox_id}` removes both the Pod and Service.
|
5. **Cleanup**: When the session ends, `DELETE /api/sandboxes/{sandbox_id}` removes both the Pod and Service.
|
||||||
|
|
||||||
The sandbox business endpoints are implemented as synchronous FastAPI handlers
|
The sandbox business endpoints are implemented as synchronous FastAPI handlers
|
||||||
because the Kubernetes Python client used here is synchronous. Starlette runs
|
because the Kubernetes Python client used here is synchronous. Starlette runs
|
||||||
sync handlers in its worker pool, keeping create/read/list/delete K8s API calls
|
sync handlers in its worker pool, keeping create/read/list/delete K8s API calls
|
||||||
and the NodePort polling sleep off the ASGI event-loop thread. Keep `/health`
|
and service access polling off the ASGI event-loop thread. Keep `/health`
|
||||||
lightweight; do not move the sandbox CRUD handlers back to `async def` unless
|
lightweight; do not move the sandbox CRUD handlers back to `async def` unless
|
||||||
the K8s client path is also made async or explicitly offloaded.
|
the K8s client path is also made async or explicitly offloaded.
|
||||||
|
|
||||||
|
|
@ -148,9 +148,11 @@ The provisioner is configured via environment variables (set in [docker-compose-
|
||||||
| `SKILLS_HOST_PATH` | - | **Host machine** path to skills directory (must be absolute) |
|
| `SKILLS_HOST_PATH` | - | **Host machine** path to skills directory (must be absolute) |
|
||||||
| `THREADS_HOST_PATH` | - | **Host machine** path to threads data directory (must be absolute) |
|
| `THREADS_HOST_PATH` | - | **Host machine** path to threads data directory (must be absolute) |
|
||||||
| `SKILLS_PVC_NAME` | empty (use hostPath) | PVC name for skills volume; when set, sandbox Pods use PVC instead of hostPath |
|
| `SKILLS_PVC_NAME` | empty (use hostPath) | PVC name for skills volume; when set, sandbox Pods use PVC instead of hostPath |
|
||||||
|
| `SKILLS_PVC_SUBPATH_TEMPLATE` | empty | Optional `subPath` template for `SKILLS_PVC_NAME`. Supports `{user_id}` and `{thread_id}`. When empty, the skills PVC root is mounted unchanged |
|
||||||
| `USERDATA_PVC_NAME` | empty (use hostPath) | PVC name for user-data volume; when set, uses PVC with `subPath: deer-flow/users/{user_id}/threads/{thread_id}/user-data` |
|
| `USERDATA_PVC_NAME` | empty (use hostPath) | PVC name for user-data volume; when set, uses PVC with `subPath: deer-flow/users/{user_id}/threads/{thread_id}/user-data` |
|
||||||
| `KUBECONFIG_PATH` | `/root/.kube/config` | Path to kubeconfig **inside** the provisioner container |
|
| `KUBECONFIG_PATH` | `/root/.kube/config` | Path to kubeconfig **inside** the provisioner container |
|
||||||
| `NODE_HOST` | `host.docker.internal` | Hostname that backend containers use to reach host NodePorts |
|
| `SANDBOX_SERVICE_TYPE` | `NodePort` | Service type for sandbox access. Use `ClusterIP` when backend and provisioner run inside the same Kubernetes cluster |
|
||||||
|
| `NODE_HOST` | `host.docker.internal` | Hostname that backend containers use to reach host NodePorts; ignored when `SANDBOX_SERVICE_TYPE=ClusterIP` |
|
||||||
| `K8S_API_SERVER` | (from kubeconfig) | Override K8s API server URL (e.g., `https://host.docker.internal:26443`) |
|
| `K8S_API_SERVER` | (from kubeconfig) | Override K8s API server URL (e.g., `https://host.docker.internal:26443`) |
|
||||||
|
|
||||||
### Custom sandbox image
|
### Custom sandbox image
|
||||||
|
|
@ -175,6 +177,8 @@ PYTHONPATH=. python scripts/migrate_user_isolation.py --user-id <target-user-id>
|
||||||
|
|
||||||
This moves legacy `threads/{thread_id}/user-data` data under `users/<target-user-id>/threads/{thread_id}/user-data`, which matches the new provisioner PVC subPath when the gateway base directory is mounted at `deer-flow/` on the PVC. Use `default` as the target user only when the legacy data should remain in the default no-auth user namespace. Run the migration while no gateway or sandbox Pods are writing to those paths.
|
This moves legacy `threads/{thread_id}/user-data` data under `users/<target-user-id>/threads/{thread_id}/user-data`, which matches the new provisioner PVC subPath when the gateway base directory is mounted at `deer-flow/` on the PVC. Use `default` as the target user only when the legacy data should remain in the default no-auth user namespace. Run the migration while no gateway or sandbox Pods are writing to those paths.
|
||||||
|
|
||||||
|
When skills are materialized per thread on the same PVC, set `SKILLS_PVC_NAME` to that PVC and configure `SKILLS_PVC_SUBPATH_TEMPLATE=deer-flow/users/{user_id}/threads/{thread_id}/skills`. Leaving the template empty preserves the legacy behavior of mounting the skills PVC root at `/mnt/skills`.
|
||||||
|
|
||||||
### Important: K8S_API_SERVER Override
|
### Important: K8S_API_SERVER Override
|
||||||
|
|
||||||
If your kubeconfig uses `localhost`, `127.0.0.1`, or `0.0.0.0` as the API server address (common with OrbStack, minikube, kind), the provisioner **cannot** reach it from inside the Docker container.
|
If your kubeconfig uses `localhost`, `127.0.0.1`, or `0.0.0.0` as the API server address (common with OrbStack, minikube, kind), the provisioner **cannot** reach it from inside the Docker container.
|
||||||
|
|
@ -333,13 +337,13 @@ docker exec deer-flow-gateway curl -s $SANDBOX_URL/v1/sandbox
|
||||||
|
|
||||||
### Issue: Cannot access sandbox URL from backend
|
### Issue: Cannot access sandbox URL from backend
|
||||||
|
|
||||||
**Cause**: NodePort not reachable or `NODE_HOST` misconfigured.
|
**Cause**: The backend cannot resolve or reach the sandbox ClusterIP Service DNS. This usually means the backend is not running inside the same Kubernetes cluster/network or cluster DNS/network policy is blocking access.
|
||||||
|
|
||||||
**Solution**:
|
**Solution**:
|
||||||
- Verify the Service exists: `kubectl get svc -n deer-flow`
|
- Verify the Service exists: `kubectl get svc -n deer-flow`
|
||||||
- Test from host: `curl http://localhost:NODE_PORT/v1/sandbox`
|
- In NodePort mode, test from the backend container: `curl http://$NODE_HOST:NODE_PORT/v1/sandbox`
|
||||||
- Ensure `extra_hosts` is set in docker-compose (Linux)
|
- In ClusterIP mode, test from the backend Pod: `curl http://sandbox-XXX-svc.deer-flow.svc.cluster.local:8080/v1/sandbox`
|
||||||
- Check `NODE_HOST` env var matches how backend reaches host
|
- Check `NODE_HOST` for NodePort deployments, or cluster DNS / NetworkPolicy / service mesh rules for ClusterIP deployments
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
|
|
@ -347,7 +351,7 @@ docker exec deer-flow-gateway curl -s $SANDBOX_URL/v1/sandbox
|
||||||
|
|
||||||
2. **Resource Limits**: Each sandbox Pod has CPU, memory, and storage limits to prevent resource exhaustion.
|
2. **Resource Limits**: Each sandbox Pod has CPU, memory, and storage limits to prevent resource exhaustion.
|
||||||
|
|
||||||
3. **Network Isolation**: Sandbox Pods run in the `deer-flow` namespace but share the host's network namespace via NodePort. Consider NetworkPolicies for stricter isolation.
|
3. **Network Isolation**: Sandbox Pods run in the configured namespace and are exposed through NodePort or ClusterIP Services. Prefer ClusterIP with NetworkPolicies for in-cluster deployments.
|
||||||
|
|
||||||
4. **kubeconfig Access**: The provisioner has full access to your Kubernetes cluster via the mounted kubeconfig. Run it only in trusted environments.
|
4. **kubeconfig Access**: The provisioner has full access to your Kubernetes cluster via the mounted kubeconfig. Run it only in trusted environments.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"""DeerFlow Sandbox Provisioner Service.
|
"""DeerFlow Sandbox Provisioner Service.
|
||||||
|
|
||||||
Dynamically creates and manages per-sandbox Pods in Kubernetes.
|
Dynamically creates and manages per-sandbox Pods in Kubernetes.
|
||||||
Each ``sandbox_id`` gets its own Pod + NodePort Service. The backend
|
Each ``sandbox_id`` gets its own Pod + Service. The backend accesses sandboxes
|
||||||
accesses sandboxes directly via ``{NODE_HOST}:{NodePort}``.
|
through NodePort or Kubernetes service DNS, depending on configuration.
|
||||||
|
|
||||||
The provisioner connects to the host machine's Kubernetes cluster via a
|
The provisioner connects to the host machine's Kubernetes cluster via a
|
||||||
mounted kubeconfig (``~/.kube/config``). Sandbox Pods run on the host
|
mounted kubeconfig (``~/.kube/config``) or in-cluster config. Sandbox Pods
|
||||||
K8s and are accessed by the backend via ``{NODE_HOST}:{NodePort}``.
|
run in K8s and are accessed by the backend via the configured Service mode.
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
POST /api/sandboxes — Create a sandbox Pod + Service
|
POST /api/sandboxes — Create a sandbox Pod + Service
|
||||||
|
|
@ -23,8 +23,8 @@ Architecture (docker-compose-dev):
|
||||||
│ creates
|
│ creates
|
||||||
┌─────────────┐ ┌──────▼───────┐
|
┌─────────────┐ ┌──────▼───────┐
|
||||||
│ backend │ ────────▸ │ sandbox │
|
│ backend │ ────────▸ │ sandbox │
|
||||||
│ │ direct │ Pod(s) │
|
│ │ direct/DNS│ Pod(s) │
|
||||||
└─────────────┘ NodePort └──────────────┘
|
└─────────────┘ └──────────────┘
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -63,17 +63,17 @@ THREADS_HOST_PATH = os.environ.get("THREADS_HOST_PATH", "/.deer-flow/threads")
|
||||||
DEER_FLOW_HOST_BASE_DIR = os.environ.get("DEER_FLOW_HOST_BASE_DIR", "/.deer-flow")
|
DEER_FLOW_HOST_BASE_DIR = os.environ.get("DEER_FLOW_HOST_BASE_DIR", "/.deer-flow")
|
||||||
SKILLS_PVC_NAME = os.environ.get("SKILLS_PVC_NAME", "")
|
SKILLS_PVC_NAME = os.environ.get("SKILLS_PVC_NAME", "")
|
||||||
USERDATA_PVC_NAME = os.environ.get("USERDATA_PVC_NAME", "")
|
USERDATA_PVC_NAME = os.environ.get("USERDATA_PVC_NAME", "")
|
||||||
|
SKILLS_PVC_SUBPATH_TEMPLATE = os.environ.get("SKILLS_PVC_SUBPATH_TEMPLATE", "")
|
||||||
SANDBOX_CONTAINER_PORT_RAW = os.environ.get("SANDBOX_CONTAINER_PORT", "8080")
|
SANDBOX_CONTAINER_PORT_RAW = os.environ.get("SANDBOX_CONTAINER_PORT", "8080")
|
||||||
|
SANDBOX_SERVICE_TYPE = os.environ.get("SANDBOX_SERVICE_TYPE", "NodePort")
|
||||||
try:
|
try:
|
||||||
SANDBOX_CONTAINER_PORT = int(SANDBOX_CONTAINER_PORT_RAW)
|
SANDBOX_CONTAINER_PORT = int(SANDBOX_CONTAINER_PORT_RAW)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"Invalid SANDBOX_CONTAINER_PORT={SANDBOX_CONTAINER_PORT_RAW!r}; expected an integer TCP port") from exc
|
||||||
f"Invalid SANDBOX_CONTAINER_PORT={SANDBOX_CONTAINER_PORT_RAW!r}; expected an integer TCP port"
|
|
||||||
) from exc
|
|
||||||
if not (1 <= SANDBOX_CONTAINER_PORT <= 65535):
|
if not (1 <= SANDBOX_CONTAINER_PORT <= 65535):
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"Invalid SANDBOX_CONTAINER_PORT={SANDBOX_CONTAINER_PORT}; expected a value in [1, 65535]")
|
||||||
f"Invalid SANDBOX_CONTAINER_PORT={SANDBOX_CONTAINER_PORT}; expected a value in [1, 65535]"
|
if SANDBOX_SERVICE_TYPE not in {"NodePort", "ClusterIP"}:
|
||||||
)
|
raise RuntimeError(f"Invalid SANDBOX_SERVICE_TYPE={SANDBOX_SERVICE_TYPE!r}; expected 'NodePort' or 'ClusterIP'")
|
||||||
SAFE_THREAD_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
|
SAFE_THREAD_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
|
||||||
SAFE_USER_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
|
SAFE_USER_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
|
||||||
DEFAULT_USER_ID = "default"
|
DEFAULT_USER_ID = "default"
|
||||||
|
|
@ -82,9 +82,9 @@ DEFAULT_USER_ID = "default"
|
||||||
# Typically the host's ~/.kube/config is mounted here.
|
# Typically the host's ~/.kube/config is mounted here.
|
||||||
KUBECONFIG_PATH = os.environ.get("KUBECONFIG_PATH", "/root/.kube/config")
|
KUBECONFIG_PATH = os.environ.get("KUBECONFIG_PATH", "/root/.kube/config")
|
||||||
|
|
||||||
# The hostname / IP that the *backend container* uses to reach NodePort
|
# The hostname / IP that the backend uses to reach NodePort services. On Docker
|
||||||
# services on the host Kubernetes node. On Docker Desktop for macOS this
|
# Desktop for macOS this is ``host.docker.internal``; on Linux it may be the
|
||||||
# is ``host.docker.internal``; on Linux it may be the host's LAN IP.
|
# host's LAN IP. Ignored when SANDBOX_SERVICE_TYPE=ClusterIP.
|
||||||
NODE_HOST = os.environ.get("NODE_HOST", "host.docker.internal")
|
NODE_HOST = os.environ.get("NODE_HOST", "host.docker.internal")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,27 +122,18 @@ def _init_k8s_client() -> k8s_client.CoreV1Api:
|
||||||
"""
|
"""
|
||||||
if os.path.exists(KUBECONFIG_PATH):
|
if os.path.exists(KUBECONFIG_PATH):
|
||||||
if os.path.isdir(KUBECONFIG_PATH):
|
if os.path.isdir(KUBECONFIG_PATH):
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"KUBECONFIG_PATH points to a directory, expected a file: {KUBECONFIG_PATH}")
|
||||||
f"KUBECONFIG_PATH points to a directory, expected a file: {KUBECONFIG_PATH}"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
k8s_config.load_kube_config(config_file=KUBECONFIG_PATH)
|
k8s_config.load_kube_config(config_file=KUBECONFIG_PATH)
|
||||||
logger.info(f"Loaded kubeconfig from {KUBECONFIG_PATH}")
|
logger.info(f"Loaded kubeconfig from {KUBECONFIG_PATH}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"Failed to load kubeconfig from {KUBECONFIG_PATH}: {exc}") from exc
|
||||||
f"Failed to load kubeconfig from {KUBECONFIG_PATH}: {exc}"
|
|
||||||
) from exc
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(f"Kubeconfig not found at {KUBECONFIG_PATH}; trying in-cluster config")
|
||||||
f"Kubeconfig not found at {KUBECONFIG_PATH}; trying in-cluster config"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
k8s_config.load_incluster_config()
|
k8s_config.load_incluster_config()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"Failed to initialize Kubernetes client. No kubeconfig at {KUBECONFIG_PATH}, and in-cluster config is unavailable: {exc}") from exc
|
||||||
"Failed to initialize Kubernetes client. "
|
|
||||||
f"No kubeconfig at {KUBECONFIG_PATH}, and in-cluster config is unavailable: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
# When connecting from inside Docker to the host's K8s API, the
|
# When connecting from inside Docker to the host's K8s API, the
|
||||||
# kubeconfig may reference ``localhost`` or ``127.0.0.1``. We
|
# kubeconfig may reference ``localhost`` or ``127.0.0.1``. We
|
||||||
|
|
@ -168,19 +159,11 @@ def _wait_for_kubeconfig(timeout: int = 30) -> None:
|
||||||
logger.info(f"Found kubeconfig file at {KUBECONFIG_PATH}")
|
logger.info(f"Found kubeconfig file at {KUBECONFIG_PATH}")
|
||||||
return
|
return
|
||||||
if os.path.isdir(KUBECONFIG_PATH):
|
if os.path.isdir(KUBECONFIG_PATH):
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"Kubeconfig path is a directory. Please mount a kubeconfig file at {KUBECONFIG_PATH}.")
|
||||||
"Kubeconfig path is a directory. "
|
raise RuntimeError(f"Kubeconfig path exists but is not a regular file: {KUBECONFIG_PATH}")
|
||||||
f"Please mount a kubeconfig file at {KUBECONFIG_PATH}."
|
|
||||||
)
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Kubeconfig path exists but is not a regular file: {KUBECONFIG_PATH}"
|
|
||||||
)
|
|
||||||
logger.info(f"Waiting for kubeconfig at {KUBECONFIG_PATH} …")
|
logger.info(f"Waiting for kubeconfig at {KUBECONFIG_PATH} …")
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
logger.warning(
|
logger.warning(f"Kubeconfig not found at {KUBECONFIG_PATH} after {timeout}s; will attempt in-cluster Kubernetes config")
|
||||||
f"Kubeconfig not found at {KUBECONFIG_PATH} after {timeout}s; "
|
|
||||||
"will attempt in-cluster Kubernetes config"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_namespace() -> None:
|
def _ensure_namespace() -> None:
|
||||||
|
|
@ -233,7 +216,7 @@ class CreateSandboxRequest(BaseModel):
|
||||||
|
|
||||||
class SandboxResponse(BaseModel):
|
class SandboxResponse(BaseModel):
|
||||||
sandbox_id: str
|
sandbox_id: str
|
||||||
sandbox_url: str # Direct access URL, e.g. http://host.docker.internal:{NodePort}
|
sandbox_url: str
|
||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -248,10 +231,15 @@ def _svc_name(sandbox_id: str) -> str:
|
||||||
return f"sandbox-{sandbox_id}-svc"
|
return f"sandbox-{sandbox_id}-svc"
|
||||||
|
|
||||||
|
|
||||||
def _sandbox_url(node_port: int) -> str:
|
def _sandbox_url(sandbox_id: str, node_port: int | None = None) -> str:
|
||||||
"""Build the sandbox URL using the configured NODE_HOST."""
|
"""Build the sandbox access URL for the configured Service mode."""
|
||||||
|
if SANDBOX_SERVICE_TYPE == "ClusterIP":
|
||||||
|
return f"http://{_svc_name(sandbox_id)}.{K8S_NAMESPACE}.svc.cluster.local:{SANDBOX_CONTAINER_PORT}"
|
||||||
|
if node_port is None:
|
||||||
|
raise RuntimeError("node_port is required when SANDBOX_SERVICE_TYPE=NodePort")
|
||||||
return f"http://{NODE_HOST}:{node_port}"
|
return f"http://{NODE_HOST}:{node_port}"
|
||||||
|
|
||||||
|
|
||||||
def _build_volumes(
|
def _build_volumes(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
user_id: str = DEFAULT_USER_ID,
|
user_id: str = DEFAULT_USER_ID,
|
||||||
|
|
@ -272,10 +260,7 @@ def _build_volumes(
|
||||||
if SKILLS_PVC_NAME:
|
if SKILLS_PVC_NAME:
|
||||||
# PVC mode: three-way subPath not yet supported; fall back to
|
# PVC mode: three-way subPath not yet supported; fall back to
|
||||||
# single-volume mount for backward compatibility.
|
# single-volume mount for backward compatibility.
|
||||||
logger.warning(
|
logger.warning("SKILLS_PVC_NAME is set — three-way skills layout is not supported in PVC mode yet; falling back to single /mnt/skills mount")
|
||||||
"SKILLS_PVC_NAME is set — three-way skills layout is not "
|
|
||||||
"supported in PVC mode yet; falling back to single /mnt/skills mount"
|
|
||||||
)
|
|
||||||
volumes.append(
|
volumes.append(
|
||||||
k8s_client.V1Volume(
|
k8s_client.V1Volume(
|
||||||
name="skills",
|
name="skills",
|
||||||
|
|
@ -299,7 +284,11 @@ def _build_volumes(
|
||||||
)
|
)
|
||||||
|
|
||||||
user_custom_path = join_host_path(
|
user_custom_path = join_host_path(
|
||||||
DEER_FLOW_HOST_BASE_DIR, "users", user_id, "skills", "custom",
|
DEER_FLOW_HOST_BASE_DIR,
|
||||||
|
"users",
|
||||||
|
user_id,
|
||||||
|
"skills",
|
||||||
|
"custom",
|
||||||
)
|
)
|
||||||
volumes.append(
|
volumes.append(
|
||||||
k8s_client.V1Volume(
|
k8s_client.V1Volume(
|
||||||
|
|
@ -355,18 +344,23 @@ def _build_volume_mounts(
|
||||||
|
|
||||||
Skills are mounted to ``/mnt/skills/{public,custom,legacy}/`` so that
|
Skills are mounted to ``/mnt/skills/{public,custom,legacy}/`` so that
|
||||||
category-aware ``Skill.get_container_path()`` paths resolve correctly.
|
category-aware ``Skill.get_container_path()`` paths resolve correctly.
|
||||||
PVC mode falls back to a single ``/mnt/skills`` mount.
|
PVC mode falls back to a single ``/mnt/skills`` mount and can optionally
|
||||||
|
scope that mount with ``SKILLS_PVC_SUBPATH_TEMPLATE``.
|
||||||
"""
|
"""
|
||||||
mounts: list[k8s_client.V1VolumeMount] = []
|
mounts: list[k8s_client.V1VolumeMount] = []
|
||||||
|
|
||||||
if SKILLS_PVC_NAME:
|
if SKILLS_PVC_NAME:
|
||||||
mounts.append(
|
skills_mount = k8s_client.V1VolumeMount(
|
||||||
k8s_client.V1VolumeMount(
|
name="skills",
|
||||||
name="skills",
|
mount_path="/mnt/skills",
|
||||||
mount_path="/mnt/skills",
|
read_only=True,
|
||||||
read_only=True,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if SKILLS_PVC_SUBPATH_TEMPLATE:
|
||||||
|
skills_mount.sub_path = SKILLS_PVC_SUBPATH_TEMPLATE.format(
|
||||||
|
user_id=user_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
mounts.append(skills_mount)
|
||||||
else:
|
else:
|
||||||
mounts.extend(
|
mounts.extend(
|
||||||
[
|
[
|
||||||
|
|
@ -397,9 +391,7 @@ def _build_volume_mounts(
|
||||||
read_only=False,
|
read_only=False,
|
||||||
)
|
)
|
||||||
if USERDATA_PVC_NAME:
|
if USERDATA_PVC_NAME:
|
||||||
userdata_mount.sub_path = (
|
userdata_mount.sub_path = f"deer-flow/users/{user_id}/threads/{thread_id}/user-data"
|
||||||
f"deer-flow/users/{user_id}/threads/{thread_id}/user-data"
|
|
||||||
)
|
|
||||||
mounts.append(userdata_mount)
|
mounts.append(userdata_mount)
|
||||||
|
|
||||||
return mounts
|
return mounts
|
||||||
|
|
@ -491,7 +483,7 @@ def _build_pod(
|
||||||
|
|
||||||
|
|
||||||
def _build_service(sandbox_id: str) -> k8s_client.V1Service:
|
def _build_service(sandbox_id: str) -> k8s_client.V1Service:
|
||||||
"""Construct a NodePort Service manifest (port auto-allocated by K8s)."""
|
"""Construct a Service manifest for the configured access mode."""
|
||||||
return k8s_client.V1Service(
|
return k8s_client.V1Service(
|
||||||
metadata=k8s_client.V1ObjectMeta(
|
metadata=k8s_client.V1ObjectMeta(
|
||||||
name=_svc_name(sandbox_id),
|
name=_svc_name(sandbox_id),
|
||||||
|
|
@ -504,14 +496,13 @@ def _build_service(sandbox_id: str) -> k8s_client.V1Service:
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
spec=k8s_client.V1ServiceSpec(
|
spec=k8s_client.V1ServiceSpec(
|
||||||
type="NodePort",
|
type=SANDBOX_SERVICE_TYPE,
|
||||||
ports=[
|
ports=[
|
||||||
k8s_client.V1ServicePort(
|
k8s_client.V1ServicePort(
|
||||||
name="http",
|
name="http",
|
||||||
port=SANDBOX_CONTAINER_PORT,
|
port=SANDBOX_CONTAINER_PORT,
|
||||||
target_port=SANDBOX_CONTAINER_PORT,
|
target_port=SANDBOX_CONTAINER_PORT,
|
||||||
protocol="TCP",
|
protocol="TCP",
|
||||||
# nodePort omitted → K8s auto-allocates from the range
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
selector={
|
selector={
|
||||||
|
|
@ -521,16 +512,35 @@ def _build_service(sandbox_id: str) -> k8s_client.V1Service:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_node_port(sandbox_id: str) -> int | None:
|
def _url_from_service(svc, sandbox_id: str) -> str | None:
|
||||||
"""Read the K8s-allocated NodePort from the Service."""
|
"""Build the backend-facing sandbox URL from an already-fetched Service."""
|
||||||
|
if SANDBOX_SERVICE_TYPE == "ClusterIP":
|
||||||
|
return _sandbox_url(sandbox_id)
|
||||||
|
|
||||||
|
for port in svc.spec.ports or []:
|
||||||
|
if port.name == "http" and port.node_port:
|
||||||
|
return _sandbox_url(sandbox_id, node_port=port.node_port)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sandbox_access_url(sandbox_id: str, *, tolerate_read_errors: bool = False) -> str | None:
|
||||||
|
"""Read the sandbox Service and return its backend-facing URL when ready."""
|
||||||
try:
|
try:
|
||||||
svc = core_v1.read_namespaced_service(_svc_name(sandbox_id), K8S_NAMESPACE)
|
svc = core_v1.read_namespaced_service(_svc_name(sandbox_id), K8S_NAMESPACE)
|
||||||
for port in svc.spec.ports or []:
|
except ApiException as exc:
|
||||||
if port.name == "http":
|
if exc.status == 404:
|
||||||
return port.node_port
|
return None
|
||||||
except ApiException:
|
if tolerate_read_errors and exc.status not in {401, 403}:
|
||||||
pass
|
logger.warning(
|
||||||
return None
|
"Transient error reading Service %s: status=%s reason=%s",
|
||||||
|
_svc_name(sandbox_id),
|
||||||
|
exc.status,
|
||||||
|
exc.reason,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
return _url_from_service(svc, sandbox_id)
|
||||||
|
|
||||||
|
|
||||||
def _get_pod_phase(sandbox_id: str) -> str:
|
def _get_pod_phase(sandbox_id: str) -> str:
|
||||||
|
|
@ -553,7 +563,7 @@ async def health():
|
||||||
|
|
||||||
@app.post("/api/sandboxes", response_model=SandboxResponse)
|
@app.post("/api/sandboxes", response_model=SandboxResponse)
|
||||||
def create_sandbox(req: CreateSandboxRequest):
|
def create_sandbox(req: CreateSandboxRequest):
|
||||||
"""Create a sandbox Pod + NodePort Service for *sandbox_id*.
|
"""Create a sandbox Pod + Service for *sandbox_id*.
|
||||||
|
|
||||||
If the sandbox already exists, returns the existing information
|
If the sandbox already exists, returns the existing information
|
||||||
(idempotent).
|
(idempotent).
|
||||||
|
|
@ -572,11 +582,11 @@ def create_sandbox(req: CreateSandboxRequest):
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Fast path: sandbox already exists ────────────────────────────
|
# ── Fast path: sandbox already exists ────────────────────────────
|
||||||
existing_port = _get_node_port(sandbox_id)
|
existing_url = _sandbox_access_url(sandbox_id, tolerate_read_errors=True)
|
||||||
if existing_port:
|
if existing_url:
|
||||||
return SandboxResponse(
|
return SandboxResponse(
|
||||||
sandbox_id=sandbox_id,
|
sandbox_id=sandbox_id,
|
||||||
sandbox_url=_sandbox_url(existing_port),
|
sandbox_url=existing_url,
|
||||||
status=_get_pod_phase(sandbox_id),
|
status=_get_pod_phase(sandbox_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -594,9 +604,7 @@ def create_sandbox(req: CreateSandboxRequest):
|
||||||
logger.info(f"Created Pod {_pod_name(sandbox_id)}")
|
logger.info(f"Created Pod {_pod_name(sandbox_id)}")
|
||||||
except ApiException as exc:
|
except ApiException as exc:
|
||||||
if exc.status != 409: # 409 = AlreadyExists
|
if exc.status != 409: # 409 = AlreadyExists
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=500, detail=f"Pod creation failed: {exc.reason}")
|
||||||
status_code=500, detail=f"Pod creation failed: {exc.reason}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Create Service ───────────────────────────────────────────────
|
# ── Create Service ───────────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
|
|
@ -609,26 +617,22 @@ def create_sandbox(req: CreateSandboxRequest):
|
||||||
core_v1.delete_namespaced_pod(_pod_name(sandbox_id), K8S_NAMESPACE)
|
core_v1.delete_namespaced_pod(_pod_name(sandbox_id), K8S_NAMESPACE)
|
||||||
except ApiException:
|
except ApiException:
|
||||||
pass
|
pass
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=500, detail=f"Service creation failed: {exc.reason}")
|
||||||
status_code=500, detail=f"Service creation failed: {exc.reason}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Read the auto-allocated NodePort ─────────────────────────────
|
# ── Wait until the Service has a usable access URL ───────────────
|
||||||
node_port: int | None = None
|
sandbox_url: str | None = None
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
node_port = _get_node_port(sandbox_id)
|
sandbox_url = _sandbox_access_url(sandbox_id, tolerate_read_errors=True)
|
||||||
if node_port:
|
if sandbox_url:
|
||||||
break
|
break
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if not node_port:
|
if not sandbox_url:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=500, detail="Service access URL was not available in time")
|
||||||
status_code=500, detail="NodePort was not allocated in time"
|
|
||||||
)
|
|
||||||
|
|
||||||
return SandboxResponse(
|
return SandboxResponse(
|
||||||
sandbox_id=sandbox_id,
|
sandbox_id=sandbox_id,
|
||||||
sandbox_url=_sandbox_url(node_port),
|
sandbox_url=sandbox_url,
|
||||||
status=_get_pod_phase(sandbox_id),
|
status=_get_pod_phase(sandbox_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -655,9 +659,7 @@ def destroy_sandbox(sandbox_id: str):
|
||||||
errors.append(f"pod: {exc.reason}")
|
errors.append(f"pod: {exc.reason}")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=500, detail=f"Partial cleanup: {', '.join(errors)}")
|
||||||
status_code=500, detail=f"Partial cleanup: {', '.join(errors)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"ok": True, "sandbox_id": sandbox_id}
|
return {"ok": True, "sandbox_id": sandbox_id}
|
||||||
|
|
||||||
|
|
@ -665,13 +667,13 @@ def destroy_sandbox(sandbox_id: str):
|
||||||
@app.get("/api/sandboxes/{sandbox_id}", response_model=SandboxResponse)
|
@app.get("/api/sandboxes/{sandbox_id}", response_model=SandboxResponse)
|
||||||
def get_sandbox(sandbox_id: str):
|
def get_sandbox(sandbox_id: str):
|
||||||
"""Return current status and URL for a sandbox."""
|
"""Return current status and URL for a sandbox."""
|
||||||
node_port = _get_node_port(sandbox_id)
|
sandbox_url = _sandbox_access_url(sandbox_id)
|
||||||
if not node_port:
|
if not sandbox_url:
|
||||||
raise HTTPException(status_code=404, detail=f"Sandbox '{sandbox_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Sandbox '{sandbox_id}' not found")
|
||||||
|
|
||||||
return SandboxResponse(
|
return SandboxResponse(
|
||||||
sandbox_id=sandbox_id,
|
sandbox_id=sandbox_id,
|
||||||
sandbox_url=_sandbox_url(node_port),
|
sandbox_url=sandbox_url,
|
||||||
status=_get_pod_phase(sandbox_id),
|
status=_get_pod_phase(sandbox_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -685,27 +687,22 @@ def list_sandboxes():
|
||||||
label_selector="app=deer-flow-sandbox",
|
label_selector="app=deer-flow-sandbox",
|
||||||
)
|
)
|
||||||
except ApiException as exc:
|
except ApiException as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=500, detail=f"Failed to list services: {exc.reason}")
|
||||||
status_code=500, detail=f"Failed to list services: {exc.reason}"
|
|
||||||
)
|
|
||||||
|
|
||||||
sandboxes: list[SandboxResponse] = []
|
sandboxes: list[SandboxResponse] = []
|
||||||
for svc in services.items:
|
for svc in services.items:
|
||||||
sid = (svc.metadata.labels or {}).get("sandbox-id")
|
sid = (svc.metadata.labels or {}).get("sandbox-id")
|
||||||
if not sid:
|
if not sid:
|
||||||
continue
|
continue
|
||||||
node_port = None
|
sandbox_url = _url_from_service(svc, sid)
|
||||||
for port in svc.spec.ports or []:
|
if not sandbox_url:
|
||||||
if port.name == "http":
|
continue
|
||||||
node_port = port.node_port
|
sandboxes.append(
|
||||||
break
|
SandboxResponse(
|
||||||
if node_port:
|
sandbox_id=sid,
|
||||||
sandboxes.append(
|
sandbox_url=sandbox_url,
|
||||||
SandboxResponse(
|
status=_get_pod_phase(sid),
|
||||||
sandbox_id=sid,
|
|
||||||
sandbox_url=_sandbox_url(node_port),
|
|
||||||
status=_get_pod_phase(sid),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return {"sandboxes": sandboxes, "count": len(sandboxes)}
|
return {"sandboxes": sandboxes, "count": len(sandboxes)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue