Skip to content

Commit e74e126

Browse files
authored
fix(sandbox): scope provisioner PVC data by user (#2973)
* fix(sandbox): scope provisioner PVC data by user * Address provisioner PVC review feedback
1 parent c0233ca commit e74e126

7 files changed

Lines changed: 92 additions & 29 deletions

File tree

backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
import requests
2323

24+
from deerflow.runtime.user_context import get_effective_user_id
25+
2426
from .backend import SandboxBackend
2527
from .sandbox_info import SandboxInfo
2628

@@ -138,6 +140,7 @@ def _provisioner_create(self, thread_id: str, sandbox_id: str, extra_mounts: lis
138140
json={
139141
"sandbox_id": sandbox_id,
140142
"thread_id": thread_id,
143+
"user_id": get_effective_user_id(),
141144
},
142145
timeout=30,
143146
)

backend/tests/test_aio_sandbox_provider.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Tests for AioSandboxProvider mount helpers."""
22

33
import importlib
4+
from types import SimpleNamespace
45
from unittest.mock import MagicMock, patch
56

67
import pytest
78

89
from deerflow.config.paths import Paths, join_host_path
10+
from deerflow.runtime.user_context import reset_current_user, set_current_user
911

1012
# ── ensure_thread_dirs ───────────────────────────────────────────────────────
1113

@@ -136,3 +138,36 @@ def test_discover_or_create_only_unlocks_when_lock_succeeds(tmp_path, monkeypatc
136138
provider._discover_or_create_with_lock("thread-5", "sandbox-5")
137139

138140
assert unlock_calls == []
141+
142+
143+
def test_remote_backend_create_forwards_effective_user_id(monkeypatch):
144+
"""Provisioner mode must receive user_id so PVC subPath matches user isolation."""
145+
remote_mod = importlib.import_module("deerflow.community.aio_sandbox.remote_backend")
146+
backend = remote_mod.RemoteSandboxBackend("http://provisioner:8002")
147+
token = set_current_user(SimpleNamespace(id="user-7"))
148+
posted: dict = {}
149+
150+
class _Response:
151+
def raise_for_status(self):
152+
return None
153+
154+
def json(self):
155+
return {"sandbox_url": "http://sandbox.local"}
156+
157+
def _post(url, json, timeout): # noqa: A002 - mirrors requests.post kwarg
158+
posted.update({"url": url, "json": json, "timeout": timeout})
159+
return _Response()
160+
161+
monkeypatch.setattr(remote_mod.requests, "post", _post)
162+
163+
try:
164+
backend.create("thread-42", "sandbox-42")
165+
finally:
166+
reset_current_user(token)
167+
168+
assert posted["url"] == "http://provisioner:8002/api/sandboxes"
169+
assert posted["json"] == {
170+
"sandbox_id": "sandbox-42",
171+
"thread_id": "thread-42",
172+
"user_id": "user-7",
173+
}

backend/tests/test_provisioner_pvc_volumes.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,19 @@ def test_default_no_subpath(self, provisioner_module):
9292
userdata_mount = mounts[1]
9393
assert userdata_mount.sub_path is None
9494

95-
def test_pvc_sets_subpath(self, provisioner_module):
96-
"""PVC mode should set sub_path to threads/{thread_id}/user-data."""
95+
def test_pvc_sets_user_scoped_subpath(self, provisioner_module):
96+
"""PVC mode should include user_id in the user-data subPath."""
97+
provisioner_module.USERDATA_PVC_NAME = "my-pvc"
98+
mounts = provisioner_module._build_volume_mounts("thread-42", user_id="user-7")
99+
userdata_mount = mounts[1]
100+
assert userdata_mount.sub_path == "deer-flow/users/user-7/threads/thread-42/user-data"
101+
102+
def test_pvc_defaults_to_default_user_subpath(self, provisioner_module):
103+
"""Older callers should still land under a stable default user namespace."""
97104
provisioner_module.USERDATA_PVC_NAME = "my-pvc"
98105
mounts = provisioner_module._build_volume_mounts("thread-42")
99106
userdata_mount = mounts[1]
100-
assert userdata_mount.sub_path == "threads/thread-42/user-data"
107+
assert userdata_mount.sub_path == "deer-flow/users/default/threads/thread-42/user-data"
101108

102109
def test_skills_mount_read_only(self, provisioner_module):
103110
"""Skills mount should always be read-only."""
@@ -146,13 +153,12 @@ def test_pod_spec_has_volume_mounts(self, provisioner_module):
146153
pod = provisioner_module._build_pod("sandbox-1", "thread-1")
147154
assert len(pod.spec.containers[0].volume_mounts) == 2
148155

149-
def test_pod_pvc_mode(self, provisioner_module):
150-
"""Pod should use PVC volumes when PVC names are configured."""
156+
def test_pod_pvc_mode_uses_user_scoped_subpath(self, provisioner_module):
157+
"""Pod should use a user-scoped subPath for PVC user-data."""
151158
provisioner_module.SKILLS_PVC_NAME = "skills-pvc"
152159
provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
153-
pod = provisioner_module._build_pod("sandbox-1", "thread-1")
160+
pod = provisioner_module._build_pod("sandbox-1", "thread-1", user_id="user-7")
154161
assert pod.spec.volumes[0].persistent_volume_claim is not None
155162
assert pod.spec.volumes[1].persistent_volume_claim is not None
156-
# subPath should be set on user-data mount
157163
userdata_mount = pod.spec.containers[0].volume_mounts[1]
158-
assert userdata_mount.sub_path == "threads/thread-1/user-data"
164+
assert userdata_mount.sub_path == "deer-flow/users/user-7/threads/thread-1/user-data"

backend/tests/test_remote_sandbox_backend.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,11 @@ def test_provisioner_create_returns_sandbox_info(monkeypatch):
144144

145145
def mock_post(url: str, json: dict, timeout: int):
146146
assert url == "http://provisioner:8002/api/sandboxes"
147-
assert json == {"sandbox_id": "abc123", "thread_id": "thread-1"}
147+
assert json == {
148+
"sandbox_id": "abc123",
149+
"thread_id": "thread-1",
150+
"user_id": "test-user-autouse",
151+
}
148152
assert timeout == 30
149153
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
150154

docker/docker-compose-dev.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ services:
3737
- THREADS_HOST_PATH=${DEER_FLOW_ROOT}/backend/.deer-flow/threads
3838
# Production: use PVC instead of hostPath to avoid data loss on node failure.
3939
# When set, hostPath vars above are ignored for the corresponding volume.
40-
# USERDATA_PVC_NAME uses subPath (threads/{thread_id}/user-data) automatically.
40+
# USERDATA_PVC_NAME uses subPath (deer-flow/users/{user_id}/threads/{thread_id}/user-data) automatically.
4141
# - SKILLS_PVC_NAME=deer-flow-skills-pvc
4242
# - USERDATA_PVC_NAME=deer-flow-userdata-pvc
4343
- KUBECONFIG_PATH=/root/.kube/config

docker/provisioner/README.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The **Sandbox Provisioner** is a FastAPI service that dynamically manages sandbo
2020

2121
### How It Works
2222

23-
1. **Backend Request**: When the backend needs to execute code, it sends a `POST /api/sandboxes` request with a `sandbox_id` and `thread_id`.
23+
1. **Backend Request**: When the backend needs to execute code, it sends a `POST /api/sandboxes` request with a `sandbox_id`, `thread_id`, and optional `user_id`.
2424

2525
2. **Pod Creation**: The provisioner creates a dedicated Pod in the `deer-flow` namespace with:
2626
- The sandbox container image (all-in-one-sandbox)
@@ -70,10 +70,13 @@ Create a new sandbox Pod + Service.
7070
```json
7171
{
7272
"sandbox_id": "abc-123",
73-
"thread_id": "thread-456"
73+
"thread_id": "thread-456",
74+
"user_id": "user-789"
7475
}
7576
```
7677

78+
`user_id` is optional for backwards compatibility and defaults to `default`. When `USERDATA_PVC_NAME` is set, the provisioner uses it to isolate PVC-backed user-data directories.
79+
7780
**Response**:
7881
```json
7982
{
@@ -138,11 +141,25 @@ The provisioner is configured via environment variables (set in [docker-compose-
138141
| `SKILLS_HOST_PATH` | - | **Host machine** path to skills directory (must be absolute) |
139142
| `THREADS_HOST_PATH` | - | **Host machine** path to threads data directory (must be absolute) |
140143
| `SKILLS_PVC_NAME` | empty (use hostPath) | PVC name for skills volume; when set, sandbox Pods use PVC instead of hostPath |
141-
| `USERDATA_PVC_NAME` | empty (use hostPath) | PVC name for user-data volume; when set, uses PVC with `subPath: threads/{thread_id}/user-data` |
144+
| `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` |
142145
| `KUBECONFIG_PATH` | `/root/.kube/config` | Path to kubeconfig **inside** the provisioner container |
143146
| `NODE_HOST` | `host.docker.internal` | Hostname that backend containers use to reach host NodePorts |
144147
| `K8S_API_SERVER` | (from kubeconfig) | Override K8s API server URL (e.g., `https://host.docker.internal:26443`) |
145148

149+
### PVC User-Data Upgrade Note
150+
151+
Older provisioner versions mounted PVC user-data from `threads/{thread_id}/user-data`. The user-scoped layout mounts from `deer-flow/users/{user_id}/threads/{thread_id}/user-data`.
152+
153+
If an existing deployment already has PVC-backed user-data under the legacy layout, migrate the DeerFlow data directory before relying on the new PVC subPath. Mount the same PVC path that the gateway uses as its DeerFlow base directory, then run the existing user-isolation migration script:
154+
155+
```bash
156+
cd backend
157+
PYTHONPATH=. python scripts/migrate_user_isolation.py --dry-run
158+
PYTHONPATH=. python scripts/migrate_user_isolation.py --user-id <target-user-id>
159+
```
160+
161+
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.
162+
146163
### Important: K8S_API_SERVER Override
147164

148165
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.
@@ -213,7 +230,7 @@ curl http://localhost:8002/health
213230
# Create a sandbox (via provisioner container for internal DNS)
214231
docker exec deer-flow-provisioner curl -X POST http://localhost:8002/api/sandboxes \
215232
-H "Content-Type: application/json" \
216-
-d '{"sandbox_id":"test-001","thread_id":"thread-001"}'
233+
-d '{"sandbox_id":"test-001","thread_id":"thread-001","user_id":"user-001"}'
217234

218235
# Check sandbox status
219236
docker exec deer-flow-provisioner curl http://localhost:8002/api/sandboxes/test-001

docker/provisioner/app.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
SKILLS_PVC_NAME = os.environ.get("SKILLS_PVC_NAME", "")
6464
USERDATA_PVC_NAME = os.environ.get("USERDATA_PVC_NAME", "")
6565
SAFE_THREAD_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
66+
SAFE_USER_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
67+
DEFAULT_USER_ID = "default"
6668

6769
# Path to the kubeconfig *inside* the provisioner container.
6870
# Typically the host's ~/.kube/config is mounted here.
@@ -95,14 +97,6 @@ def join_host_path(base: str, *parts: str) -> str:
9597
return str(result)
9698

9799

98-
def _validate_thread_id(thread_id: str) -> str:
99-
if not re.match(SAFE_THREAD_ID_PATTERN, thread_id):
100-
raise ValueError(
101-
"Invalid thread_id: only alphanumeric characters, hyphens, and underscores are allowed."
102-
)
103-
return thread_id
104-
105-
106100
# ── K8s client setup ────────────────────────────────────────────────────
107101

108102
core_v1: k8s_client.CoreV1Api | None = None
@@ -221,6 +215,7 @@ async def lifespan(_app: FastAPI):
221215
class CreateSandboxRequest(BaseModel):
222216
sandbox_id: str
223217
thread_id: str = Field(pattern=SAFE_THREAD_ID_PATTERN)
218+
user_id: str = Field(default=DEFAULT_USER_ID, pattern=SAFE_USER_ID_PATTERN)
224219

225220

226221
class SandboxResponse(BaseModel):
@@ -283,15 +278,15 @@ def _build_volumes(thread_id: str) -> list[k8s_client.V1Volume]:
283278
return [skills_vol, userdata_vol]
284279

285280

286-
def _build_volume_mounts(thread_id: str) -> list[k8s_client.V1VolumeMount]:
281+
def _build_volume_mounts(thread_id: str, user_id: str = DEFAULT_USER_ID) -> list[k8s_client.V1VolumeMount]:
287282
"""Build volume mount list, using subPath for PVC user-data."""
288283
userdata_mount = k8s_client.V1VolumeMount(
289284
name="user-data",
290285
mount_path="/mnt/user-data",
291286
read_only=False,
292287
)
293288
if USERDATA_PVC_NAME:
294-
userdata_mount.sub_path = f"threads/{thread_id}/user-data"
289+
userdata_mount.sub_path = f"deer-flow/users/{user_id}/threads/{thread_id}/user-data"
295290

296291
return [
297292
k8s_client.V1VolumeMount(
@@ -303,9 +298,8 @@ def _build_volume_mounts(thread_id: str) -> list[k8s_client.V1VolumeMount]:
303298
]
304299

305300

306-
def _build_pod(sandbox_id: str, thread_id: str) -> k8s_client.V1Pod:
301+
def _build_pod(sandbox_id: str, thread_id: str, user_id: str = DEFAULT_USER_ID) -> k8s_client.V1Pod:
307302
"""Construct a Pod manifest for a single sandbox."""
308-
thread_id = _validate_thread_id(thread_id)
309303
return k8s_client.V1Pod(
310304
metadata=k8s_client.V1ObjectMeta(
311305
name=_pod_name(sandbox_id),
@@ -362,7 +356,7 @@ def _build_pod(sandbox_id: str, thread_id: str) -> k8s_client.V1Pod:
362356
"ephemeral-storage": "500Mi",
363357
},
364358
),
365-
volume_mounts=_build_volume_mounts(thread_id),
359+
volume_mounts=_build_volume_mounts(thread_id, user_id=user_id),
366360
security_context=k8s_client.V1SecurityContext(
367361
privileged=False,
368362
allow_privilege_escalation=True,
@@ -445,9 +439,13 @@ async def create_sandbox(req: CreateSandboxRequest):
445439
"""
446440
sandbox_id = req.sandbox_id
447441
thread_id = req.thread_id
442+
user_id = req.user_id
448443

449444
logger.info(
450-
f"Received request to create sandbox '{sandbox_id}' for thread '{thread_id}'"
445+
"Received request to create sandbox '%s' for thread '%s' user '%s'",
446+
sandbox_id,
447+
thread_id,
448+
user_id,
451449
)
452450

453451
# ── Fast path: sandbox already exists ────────────────────────────
@@ -461,7 +459,7 @@ async def create_sandbox(req: CreateSandboxRequest):
461459

462460
# ── Create Pod ───────────────────────────────────────────────────
463461
try:
464-
core_v1.create_namespaced_pod(K8S_NAMESPACE, _build_pod(sandbox_id, thread_id))
462+
core_v1.create_namespaced_pod(K8S_NAMESPACE, _build_pod(sandbox_id, thread_id, user_id=user_id))
465463
logger.info(f"Created Pod {_pod_name(sandbox_id)}")
466464
except ApiException as exc:
467465
if exc.status != 409: # 409 = AlreadyExists

0 commit comments

Comments
 (0)