mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-05 12:28:37 +00:00
* fix(sandbox): project enabled skills into sandbox views * fix(skills): keep projection mutations consistent * fix(skills): fail closed on projection errors * fix(skills): isolate per-scope failures during boot projection rebuild rebuild_all_skill_projections() propagated any exception from the public rebuild or from a single user's rebuild straight out of the gateway lifespan startup, uncaught. A single broken user directory (bad permissions, corrupted _skill_states.json, unreadable content) would therefore abort gateway boot for every user, not just that one - _rebuild_*_locked already fails closed internally (clears the view and re-raises), so the boot loop only needed to stop treating that re-raise as fatal. Each scope's rebuild now fails closed independently and boot continues; a scope left empty by a boot failure self-heals on the next sandbox acquire via ensure_skill_projections(). Also patches deerflow.skills.projection.rebuild_all_skill_projections in the memory-flush lifespan test fixture, matching the two sibling fixtures in the same file — this call is now on the lifespan startup path and the fixture's minimal SimpleNamespace config predates it. * test(skills): update authz test for the projection-aware public toggle _persist_shared_skill_state (introduced earlier in this branch) reads the shared extensions_config.json fresh from disk under the projection lock instead of through the cached get_extensions_config() singleton - that's the whole point of the fix (stale worker caches must not clobber another worker's concurrent update). The name no longer exists on the skills router module, so the test's monkeypatch of it started raising AttributeError instead of exercising the endpoint. The mock storage in this test isn't a real LocalSkillStorage instance, so _persist_shared_skill_state's projection-mutation branch is already skipped (nullcontext) and it falls back to a fresh ExtensionsConfig() for the nonexistent tmp config_path - no replacement monkeypatch needed. * fix(sandbox): make skill projection ensure best-effort in acquire acquire() called _ensure_skills_projection() directly, outside any try/except, in both LocalSandboxProvider and AioSandboxProvider. Every other skill-mount setup path in these providers has always caught exceptions and logged a warning rather than failing sandbox acquire outright (e.g. when config.yaml can't be resolved) - these two new call sites broke that contract, so any projection failure (including simply not having a config.yaml, as in CI's test environment) now failed acquire() itself instead of just leaving skill mounts off. _ensure_skills_projection now catches its own exceptions and returns None; both providers' callers already tolerate that (a None projection skips the skill-specific mounts, matching the existing degrade path) after making _append_public_skill_mapping and the custom/legacy mount block in LocalSandboxProvider explicitly None-safe. Caught by running the full suite with config.yaml removed, matching CI's environment - not caught locally because a real config.yaml was present, masking the failure. * fix(sandbox): make E2B skill projection mounts best-effort _skill_projection_mounts called ensure_skill_projections with no guard, unlike Local/AIO's _ensure_skills_projection. A raise propagated out of _apply_mounts before the configured-mounts loop ran, so a skills projection failure dropped the operator's own configured mounts too - only caught by create()'s outer warning, with nothing applied at all. Swallow here and return an empty mount list on failure, matching the Local/AIO pattern: still fail-closed for skills, but no longer widens the blast radius to unrelated configured mounts. Review feedback from PR #4178. * docs(skills): document projection trade-offs flagged in review - _update_tree_digest: note the metadata-only (not content) hashing trade-off and why runtime writes through this codebase are still covered regardless (rebuild-under-lock + rename always changes inode). - LocalSandboxProvider.acquire: note the acquire-time self-heal cost (cheap on a fresh manifest, ~400ms rebuild under lock on stale/drift). - skill_projection_mutation: drop the no-op except-Exception-then-raise; a raise from the mutation already propagates past the yield with the view left cleared, no explicit re-raise needed. - provisioner README: spell out that hostPath skills volumes require the gateway and K8s node to share DEER_FLOW_HOST_BASE_DIR (single-node or shared storage), and that the custom/legacy volumes' hostPath type Directory (not DirectoryOrCreate) makes a violation of that assumption a visible Pod-creation failure instead of a silent empty mount. Review feedback from PR #4178. * fix(skills): lazily repair user projections * fix(skills): close projection review gaps * fix(skills): refresh user projection enable state * fix(skills): close projection review follow-ups * fix(skills): preserve state across projection writes --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
709 lines
34 KiB
Python
709 lines
34 KiB
Python
"""Regression tests for provisioner three-way skills + PVC volume support."""
|
|
|
|
import pytest
|
|
|
|
# ── _build_volumes ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBuildVolumes:
|
|
"""Tests for _build_volumes: hostPath three-way vs PVC fallback."""
|
|
|
|
# ── hostPath mode (default) ────────────────────────────────────────
|
|
|
|
def test_hostpath_uses_three_projection_volumes(self, provisioner_module):
|
|
"""hostPath mode always mounts stable public/custom/legacy views."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
volumes = provisioner_module._build_volumes("thread-1")
|
|
assert len(volumes) == 4
|
|
|
|
def test_hostpath_skills_public_volume(self, provisioner_module):
|
|
"""First skills volume mounts public/ subdirectory."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
volumes = provisioner_module._build_volumes("thread-1")
|
|
pub = volumes[0]
|
|
assert pub.name == "skills-public"
|
|
assert pub.host_path is not None
|
|
assert pub.host_path.path.endswith("/skills_view/public")
|
|
assert pub.host_path.type == "Directory"
|
|
assert pub.persistent_volume_claim is None
|
|
|
|
def test_hostpath_skills_custom_volume(self, provisioner_module):
|
|
"""Second skills volume mounts per-user custom directory."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
volumes = provisioner_module._build_volumes("thread-1", user_id="user-7")
|
|
custom = volumes[1]
|
|
assert custom.name == "skills-custom"
|
|
assert custom.host_path is not None
|
|
assert "users/user-7/skills_view/custom" in custom.host_path.path
|
|
assert custom.host_path.type == "Directory"
|
|
|
|
def test_hostpath_skills_legacy_volume(self, provisioner_module):
|
|
"""Legacy projection is per-user and stable regardless of visibility."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
volumes = provisioner_module._build_volumes(
|
|
"thread-1",
|
|
include_legacy_skills=True,
|
|
)
|
|
legacy = volumes[2]
|
|
assert legacy.name == "skills-legacy"
|
|
assert legacy.host_path is not None
|
|
assert "users/default/skills_view/legacy" in legacy.host_path.path
|
|
assert legacy.host_path.type == "Directory"
|
|
|
|
def test_hostpath_without_legacy_flag_still_has_empty_capable_mount(self, provisioner_module):
|
|
"""Visibility changes update contents without recreating the Pod."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
volumes = provisioner_module._build_volumes("thread-1")
|
|
assert [volume.name for volume in volumes] == [
|
|
"skills-public",
|
|
"skills-custom",
|
|
"skills-legacy",
|
|
"user-data",
|
|
]
|
|
|
|
def test_hostpath_userdata_includes_thread_id(self, provisioner_module):
|
|
"""hostPath user-data path should include thread_id."""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
volumes = provisioner_module._build_volumes("my-thread-42")
|
|
userdata_vol = volumes[-1]
|
|
path = userdata_vol.host_path.path
|
|
assert "my-thread-42" in path
|
|
assert path.endswith("user-data")
|
|
assert userdata_vol.host_path.type == "DirectoryOrCreate"
|
|
|
|
# ── PVC mode (single-volume fallback) ──────────────────────────────
|
|
|
|
def test_pvc_returns_two_volumes(self, provisioner_module):
|
|
"""PVC mode falls back to 1 skills volume + 1 user-data volume."""
|
|
provisioner_module.SKILLS_PVC_NAME = "my-skills-pvc"
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
volumes = provisioner_module._build_volumes("thread-1")
|
|
assert len(volumes) == 2
|
|
|
|
def test_skills_pvc_overrides_hostpath(self, provisioner_module):
|
|
"""When SKILLS_PVC_NAME is set, skills volume should use PVC."""
|
|
provisioner_module.SKILLS_PVC_NAME = "my-skills-pvc"
|
|
volumes = provisioner_module._build_volumes("thread-1")
|
|
skills_vol = volumes[0]
|
|
assert skills_vol.persistent_volume_claim is not None
|
|
assert skills_vol.persistent_volume_claim.claim_name == "my-skills-pvc"
|
|
assert skills_vol.persistent_volume_claim.read_only is True
|
|
assert skills_vol.host_path is None
|
|
|
|
def test_userdata_pvc_overrides_hostpath(self, provisioner_module):
|
|
"""When USERDATA_PVC_NAME is set, user-data volume should use PVC."""
|
|
provisioner_module.USERDATA_PVC_NAME = "my-userdata-pvc"
|
|
volumes = provisioner_module._build_volumes("thread-1")
|
|
userdata_vol = volumes[-1]
|
|
assert userdata_vol.persistent_volume_claim is not None
|
|
assert userdata_vol.persistent_volume_claim.claim_name == "my-userdata-pvc"
|
|
assert userdata_vol.host_path is None
|
|
|
|
def test_both_pvc_set(self, provisioner_module):
|
|
"""When both PVC names are set, both volumes use PVC."""
|
|
provisioner_module.SKILLS_PVC_NAME = "skills-pvc"
|
|
provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
|
|
volumes = provisioner_module._build_volumes("thread-1")
|
|
assert volumes[0].persistent_volume_claim is not None
|
|
assert volumes[-1].persistent_volume_claim is not None
|
|
|
|
def test_pvc_volume_names_are_stable(self, provisioner_module):
|
|
"""PVC mode volume names must stay 'skills' and 'user-data'."""
|
|
provisioner_module.SKILLS_PVC_NAME = "x"
|
|
volumes = provisioner_module._build_volumes("thread-1")
|
|
assert volumes[0].name == "skills"
|
|
assert volumes[-1].name == "user-data"
|
|
|
|
def test_extra_mount_uses_hostpath_when_userdata_pvc_is_disabled(self, provisioner_module):
|
|
"""Controlled extra mounts should become extra hostPath volumes by default."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
extra_mounts = [
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/config",
|
|
container_path="/mnt/integrations/lark-cli/config",
|
|
read_only=False,
|
|
)
|
|
]
|
|
|
|
volumes = provisioner_module._build_volumes("thread-1", extra_mounts=extra_mounts)
|
|
|
|
# Three skill projections + user-data (4 base) + 1 extra volume.
|
|
assert len(volumes) == 5
|
|
extra_vol = volumes[-1]
|
|
assert extra_vol.name == "extra-0"
|
|
assert extra_vol.host_path.path == "/state/users/alice/integrations/lark-cli/config"
|
|
assert extra_vol.host_path.type == "DirectoryOrCreate"
|
|
|
|
def test_extra_mount_uses_userdata_pvc_when_configured(self, provisioner_module):
|
|
"""PVC mode should use the same DeerFlow data PVC for runtime config mounts."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
extra_mounts = [
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/config",
|
|
container_path="/mnt/integrations/lark-cli/config",
|
|
read_only=False,
|
|
)
|
|
]
|
|
|
|
volumes = provisioner_module._build_volumes("thread-1", extra_mounts=extra_mounts)
|
|
|
|
# Three skill projections + user-data (4 base) + 1 extra volume.
|
|
assert len(volumes) == 5
|
|
extra_vol = volumes[-1]
|
|
assert extra_vol.name == "extra-0"
|
|
assert extra_vol.persistent_volume_claim is not None
|
|
assert extra_vol.persistent_volume_claim.claim_name == "userdata-pvc"
|
|
assert extra_vol.host_path is None
|
|
|
|
def test_extra_mount_rejects_paths_outside_deerflow_state(self, provisioner_module):
|
|
"""Provisioner must not accept arbitrary hostPath mounts from clients."""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
extra_mounts = [
|
|
provisioner_module.ExtraMount(
|
|
host_path="/etc",
|
|
container_path="/mnt/integrations/lark-cli/config",
|
|
read_only=False,
|
|
)
|
|
]
|
|
|
|
with pytest.raises(provisioner_module.HTTPException) as exc_info:
|
|
provisioner_module._build_volumes("thread-1", extra_mounts=extra_mounts)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
# ── _build_volume_mounts ───────────────────────────────────────────────
|
|
|
|
|
|
class TestBuildVolumeMounts:
|
|
"""Tests for _build_volume_mounts: three-way mount paths and subPath."""
|
|
|
|
# ── hostPath mode ──────────────────────────────────────────────────
|
|
|
|
def test_hostpath_uses_three_projection_mounts(self, provisioner_module):
|
|
"""hostPath mode always mounts stable public/custom/legacy views."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
mounts = provisioner_module._build_volume_mounts("thread-1")
|
|
assert len(mounts) == 4
|
|
|
|
def test_hostpath_skills_public_mount(self, provisioner_module):
|
|
"""Public skills mount at /mnt/skills/public, read-only."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
mounts = provisioner_module._build_volume_mounts("thread-1")
|
|
assert mounts[0].name == "skills-public"
|
|
assert mounts[0].mount_path == "/mnt/skills/public"
|
|
assert mounts[0].read_only is True
|
|
|
|
def test_hostpath_skills_custom_mount(self, provisioner_module):
|
|
"""Per-user custom skills mount at /mnt/skills/custom, read-only."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
mounts = provisioner_module._build_volume_mounts("thread-1")
|
|
assert mounts[1].name == "skills-custom"
|
|
assert mounts[1].mount_path == "/mnt/skills/custom"
|
|
assert mounts[1].read_only is True
|
|
|
|
def test_hostpath_skills_legacy_mount(self, provisioner_module):
|
|
"""Legacy skills mount at /mnt/skills/legacy, read-only."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
mounts = provisioner_module._build_volume_mounts(
|
|
"thread-1",
|
|
include_legacy_skills=True,
|
|
)
|
|
assert mounts[2].name == "skills-legacy"
|
|
assert mounts[2].mount_path == "/mnt/skills/legacy"
|
|
assert mounts[2].read_only is True
|
|
|
|
def test_hostpath_without_legacy_flag_still_has_legacy_mount(self, provisioner_module):
|
|
"""Hidden legacy content is represented by an empty mounted view."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
mounts = provisioner_module._build_volume_mounts("thread-1")
|
|
assert [mount.name for mount in mounts] == [
|
|
"skills-public",
|
|
"skills-custom",
|
|
"skills-legacy",
|
|
"user-data",
|
|
]
|
|
|
|
def test_hostpath_userdata_read_write(self, provisioner_module):
|
|
"""User-data mount should always be read-write."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
mounts = provisioner_module._build_volume_mounts("thread-1")
|
|
userdata = mounts[-1]
|
|
assert userdata.name == "user-data"
|
|
assert userdata.mount_path == "/mnt/user-data"
|
|
assert userdata.read_only is False
|
|
|
|
# ── PVC mode ───────────────────────────────────────────────────────
|
|
|
|
def test_pvc_returns_two_mounts(self, provisioner_module):
|
|
"""PVC mode falls back to 1 skills mount + 1 user-data mount."""
|
|
provisioner_module.SKILLS_PVC_NAME = "x"
|
|
mounts = provisioner_module._build_volume_mounts("thread-1")
|
|
assert len(mounts) == 2
|
|
|
|
def test_pvc_skills_mount_is_single_root(self, provisioner_module):
|
|
"""PVC mode skills mount is at /mnt/skills."""
|
|
provisioner_module.SKILLS_PVC_NAME = "x"
|
|
mounts = provisioner_module._build_volume_mounts("thread-1")
|
|
assert mounts[0].mount_path == "/mnt/skills"
|
|
|
|
def test_pvc_no_subpath_on_userdata(self, provisioner_module):
|
|
"""hostPath mode should not set sub_path on user-data mount."""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
mounts = provisioner_module._build_volume_mounts("thread-1")
|
|
userdata_mount = mounts[-1]
|
|
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):
|
|
"""PVC mode should include user_id in the user-data subPath."""
|
|
provisioner_module.USERDATA_PVC_NAME = "my-pvc"
|
|
mounts = provisioner_module._build_volume_mounts("thread-42", user_id="user-7")
|
|
userdata_mount = mounts[-1]
|
|
assert userdata_mount.sub_path == "deer-flow/users/user-7/threads/thread-42/user-data"
|
|
|
|
def test_pvc_defaults_to_default_user_subpath(self, provisioner_module):
|
|
"""Older callers should still land under a stable default user namespace."""
|
|
provisioner_module.USERDATA_PVC_NAME = "my-pvc"
|
|
mounts = provisioner_module._build_volume_mounts("thread-42")
|
|
userdata_mount = mounts[-1]
|
|
assert userdata_mount.sub_path == "deer-flow/users/default/threads/thread-42/user-data"
|
|
|
|
# ── Managed integration extra mounts ───────────────────────────────
|
|
|
|
def test_extra_mount_adds_volume_mount(self, provisioner_module):
|
|
"""Controlled extra mounts should be mounted at their requested container path."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
extra_mounts = [
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/config",
|
|
container_path="/mnt/integrations/lark-cli/config",
|
|
read_only=False,
|
|
)
|
|
]
|
|
|
|
mounts = provisioner_module._build_volume_mounts("thread-1", extra_mounts=extra_mounts)
|
|
|
|
extra_mount = mounts[-1]
|
|
assert extra_mount.name == "extra-0"
|
|
assert extra_mount.mount_path == "/mnt/integrations/lark-cli/config"
|
|
assert extra_mount.read_only is False
|
|
assert extra_mount.sub_path is None
|
|
|
|
def test_extra_mount_uses_pvc_subpath(self, provisioner_module):
|
|
"""PVC extra mounts should point at the same user-scoped DeerFlow path."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
extra_mounts = [
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/config",
|
|
container_path="/mnt/integrations/lark-cli/config",
|
|
read_only=False,
|
|
)
|
|
]
|
|
|
|
mounts = provisioner_module._build_volume_mounts("thread-1", extra_mounts=extra_mounts)
|
|
|
|
extra_mount = mounts[-1]
|
|
assert extra_mount.name == "extra-0"
|
|
assert extra_mount.sub_path == "deer-flow/users/alice/integrations/lark-cli/config"
|
|
|
|
def test_extra_mount_rejects_unknown_container_path(self, provisioner_module):
|
|
"""Only first-party managed mount paths are accepted."""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
extra_mounts = [
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/config",
|
|
container_path="/mnt/secrets",
|
|
read_only=False,
|
|
)
|
|
]
|
|
|
|
with pytest.raises(provisioner_module.HTTPException) as exc_info:
|
|
provisioner_module._build_volume_mounts("thread-1", extra_mounts=extra_mounts)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
# ── _build_pod integration ─────────────────────────────────────────────
|
|
|
|
|
|
class TestBuildPodVolumes:
|
|
"""Integration: _build_pod should wire volumes and mounts correctly."""
|
|
|
|
def test_pod_hostpath_has_four_volumes(self, provisioner_module):
|
|
"""hostPath Pod spec includes all three stable projection categories."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
pod = provisioner_module._build_pod("sandbox-1", "thread-1")
|
|
assert len(pod.spec.volumes) == 4
|
|
|
|
def test_pod_hostpath_has_four_mounts(self, provisioner_module):
|
|
"""hostPath container includes all three stable projection categories."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
pod = provisioner_module._build_pod("sandbox-1", "thread-1")
|
|
assert len(pod.spec.containers[0].volume_mounts) == 4
|
|
|
|
def test_pod_hostpath_with_legacy_has_four_volumes(self, provisioner_module):
|
|
"""Legacy volume should be present when the backend requests it."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
include_legacy_skills=True,
|
|
)
|
|
assert len(pod.spec.volumes) == 4
|
|
|
|
def test_pod_hostpath_with_legacy_has_four_mounts(self, provisioner_module):
|
|
"""Legacy mount should be present when the backend requests it."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
include_legacy_skills=True,
|
|
)
|
|
assert len(pod.spec.containers[0].volume_mounts) == 4
|
|
|
|
def test_pod_pvc_has_two_volumes(self, provisioner_module):
|
|
"""PVC Pod spec should contain exactly 2 volumes."""
|
|
provisioner_module.SKILLS_PVC_NAME = "skills-pvc"
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
pod = provisioner_module._build_pod("sandbox-1", "thread-1")
|
|
assert len(pod.spec.volumes) == 2
|
|
|
|
def test_pod_pvc_has_two_mounts(self, provisioner_module):
|
|
"""PVC container should have exactly 2 volume mounts."""
|
|
provisioner_module.SKILLS_PVC_NAME = "skills-pvc"
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
pod = provisioner_module._build_pod("sandbox-1", "thread-1")
|
|
assert len(pod.spec.containers[0].volume_mounts) == 2
|
|
|
|
def test_pod_pvc_mode_uses_user_scoped_subpath(self, provisioner_module):
|
|
"""Pod should use a user-scoped subPath for PVC user-data."""
|
|
provisioner_module.SKILLS_PVC_NAME = "skills-pvc"
|
|
provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
|
|
pod = provisioner_module._build_pod("sandbox-1", "thread-1", user_id="user-7")
|
|
assert pod.spec.volumes[0].persistent_volume_claim is not None
|
|
assert pod.spec.volumes[-1].persistent_volume_claim is not None
|
|
userdata_mount = pod.spec.containers[0].volume_mounts[-1]
|
|
assert userdata_mount.sub_path == "deer-flow/users/user-7/threads/thread-1/user-data"
|
|
|
|
def test_pod_includes_extra_mounts(self, provisioner_module):
|
|
"""Provisioner-created pods should include managed integration runtime mounts."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
extra_mounts = [
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/config",
|
|
container_path="/mnt/integrations/lark-cli/config",
|
|
read_only=False,
|
|
),
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/data",
|
|
container_path="/mnt/integrations/lark-cli/data",
|
|
read_only=False,
|
|
),
|
|
]
|
|
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
user_id="alice",
|
|
extra_mounts=extra_mounts,
|
|
)
|
|
|
|
# Three skill projections + user-data (4 base) + 2 extra mounts.
|
|
assert len(pod.spec.volumes) == 6
|
|
mount_paths = {mount.mount_path for mount in pod.spec.containers[0].volume_mounts}
|
|
assert "/mnt/integrations/lark-cli/config" in mount_paths
|
|
assert "/mnt/integrations/lark-cli/data" in mount_paths
|
|
|
|
def test_pod_three_way_skills_mount_paths(self, provisioner_module):
|
|
"""Ensure public/custom/legacy mount paths are correct."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
include_legacy_skills=True,
|
|
)
|
|
mount_paths = {m.name: m.mount_path for m in pod.spec.containers[0].volume_mounts}
|
|
assert mount_paths["skills-public"] == "/mnt/skills/public"
|
|
assert mount_paths["skills-custom"] == "/mnt/skills/custom"
|
|
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"
|
|
|
|
|
|
class TestLarkCliInitContainer:
|
|
"""Init-container + emptyDir provisioning of the sandbox lark-cli runtime."""
|
|
|
|
def test_no_init_container_when_image_unset(self, provisioner_module):
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.LARK_CLI_INIT_IMAGE = ""
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
provision_lark_cli_runtime=True,
|
|
)
|
|
assert not pod.spec.init_containers
|
|
volume_names = {v.name for v in pod.spec.volumes}
|
|
assert provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME not in volume_names
|
|
|
|
def test_no_init_container_when_flag_disabled(self, provisioner_module):
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
provision_lark_cli_runtime=False,
|
|
)
|
|
assert not pod.spec.init_containers
|
|
|
|
def test_init_container_and_emptydir_when_enabled(self, provisioner_module):
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
provision_lark_cli_runtime=True,
|
|
)
|
|
|
|
# emptyDir volume shared by init + sandbox containers.
|
|
runtime_volumes = [v for v in pod.spec.volumes if v.name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME]
|
|
assert len(runtime_volumes) == 1
|
|
assert runtime_volumes[0].empty_dir is not None
|
|
|
|
# Exactly one init container, pointing at the configured image and
|
|
# writing the runtime path.
|
|
assert pod.spec.init_containers is not None
|
|
assert len(pod.spec.init_containers) == 1
|
|
init = pod.spec.init_containers[0]
|
|
assert init.image == "deer-flow/lark-cli-init:v1.0.65"
|
|
init_mount = init.volume_mounts[0]
|
|
assert init_mount.name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME
|
|
assert init_mount.mount_path == provisioner_module.LARK_CLI_RUNTIME_CONTAINER_PATH
|
|
assert init_mount.read_only is False
|
|
|
|
# Sandbox container gets a read-only runtime mount at the same path.
|
|
sandbox_runtime_mounts = [m for m in pod.spec.containers[0].volume_mounts if m.name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME]
|
|
assert len(sandbox_runtime_mounts) == 1
|
|
assert sandbox_runtime_mounts[0].mount_path == provisioner_module.LARK_CLI_RUNTIME_CONTAINER_PATH
|
|
assert sandbox_runtime_mounts[0].read_only is True
|
|
|
|
def test_runtime_extra_mount_dropped_when_init_container_enabled(self, provisioner_module):
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
|
|
extra_mounts = [
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/config",
|
|
container_path="/mnt/integrations/lark-cli/config",
|
|
read_only=False,
|
|
),
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/integrations/lark-cli/sandbox-cli",
|
|
container_path="/mnt/integrations/lark-cli/runtime",
|
|
read_only=True,
|
|
),
|
|
]
|
|
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
user_id="alice",
|
|
extra_mounts=extra_mounts,
|
|
provision_lark_cli_runtime=True,
|
|
)
|
|
|
|
# The credential config mount stays; the hostPath runtime extra mount is
|
|
# replaced by the emptyDir supplied by the init container (so the runtime
|
|
# path is not backed by an extra-* hostPath volume).
|
|
runtime_mounts = [m for m in pod.spec.containers[0].volume_mounts if m.mount_path == "/mnt/integrations/lark-cli/runtime"]
|
|
assert len(runtime_mounts) == 1
|
|
assert runtime_mounts[0].name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME
|
|
mount_paths = {m.mount_path for m in pod.spec.containers[0].volume_mounts}
|
|
assert "/mnt/integrations/lark-cli/config" in mount_paths
|
|
|
|
|
|
class TestLarkCliBrokerSidecar:
|
|
"""Broker sidecar provisioning of the sandbox lark-cli runtime (Pattern B)."""
|
|
|
|
@staticmethod
|
|
def _credential_mounts(provisioner_module):
|
|
return [
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/config",
|
|
container_path="/mnt/integrations/lark-cli/config",
|
|
read_only=True,
|
|
),
|
|
provisioner_module.ExtraMount(
|
|
host_path="/state/users/alice/integrations/lark-cli/data",
|
|
container_path="/mnt/integrations/lark-cli/data",
|
|
read_only=False,
|
|
),
|
|
]
|
|
|
|
def test_no_sidecar_when_broker_image_unset(self, provisioner_module):
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.LARK_CLI_BROKER_IMAGE = ""
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
provision_lark_cli_broker=True,
|
|
)
|
|
container_names = {c.name for c in pod.spec.containers}
|
|
assert "lark-cli-broker" not in container_names
|
|
|
|
def test_no_sidecar_when_flag_disabled(self, provisioner_module):
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.LARK_CLI_BROKER_IMAGE = "deer-flow/lark-cli-broker:v1.0.65"
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
provision_lark_cli_broker=False,
|
|
)
|
|
container_names = {c.name for c in pod.spec.containers}
|
|
assert "lark-cli-broker" not in container_names
|
|
|
|
def test_broker_sidecar_and_shim_when_enabled(self, provisioner_module):
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
provisioner_module.LARK_CLI_BROKER_IMAGE = "deer-flow/lark-cli-broker:v1.0.65"
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
user_id="alice",
|
|
extra_mounts=self._credential_mounts(provisioner_module),
|
|
provision_lark_cli_broker=True,
|
|
)
|
|
|
|
# Shim init container from the broker image.
|
|
assert pod.spec.init_containers is not None
|
|
assert len(pod.spec.init_containers) == 1
|
|
init = pod.spec.init_containers[0]
|
|
assert init.name == "lark-cli-shim-init"
|
|
assert init.image == "deer-flow/lark-cli-broker:v1.0.65"
|
|
assert init.args == ["install-shim", provisioner_module.LARK_CLI_RUNTIME_CONTAINER_PATH]
|
|
|
|
# Broker sidecar alongside the sandbox container.
|
|
sidecars = [c for c in pod.spec.containers if c.name == "lark-cli-broker"]
|
|
assert len(sidecars) == 1
|
|
sidecar = sidecars[0]
|
|
assert sidecar.image == "deer-flow/lark-cli-broker:v1.0.65"
|
|
assert sidecar.args == ["serve"]
|
|
# Credentials mounted into the sidecar only.
|
|
sidecar_paths = {m.mount_path for m in sidecar.volume_mounts}
|
|
assert provisioner_module.LARK_BROKER_SIDECAR_CONFIG_PATH in sidecar_paths
|
|
assert provisioner_module.LARK_BROKER_SIDECAR_DATA_PATH in sidecar_paths
|
|
|
|
# Sandbox container: runtime shim mount + broker URL env, NO config/data.
|
|
sandbox = pod.spec.containers[0]
|
|
sandbox_paths = {m.mount_path for m in sandbox.volume_mounts}
|
|
assert provisioner_module.LARK_CLI_RUNTIME_CONTAINER_PATH in sandbox_paths
|
|
assert "/mnt/integrations/lark-cli/config" not in sandbox_paths
|
|
assert "/mnt/integrations/lark-cli/data" not in sandbox_paths
|
|
env = {e.name: e.value for e in (sandbox.env or [])}
|
|
assert env.get("DEERFLOW_LARK_BROKER_URL") == provisioner_module.LARK_BROKER_URL
|
|
|
|
def test_broker_supersedes_init_container(self, provisioner_module):
|
|
"""Both images set + both flags on → broker wins (shim init, sidecar)."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
|
|
provisioner_module.LARK_CLI_BROKER_IMAGE = "deer-flow/lark-cli-broker:v1.0.65"
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
user_id="alice",
|
|
extra_mounts=self._credential_mounts(provisioner_module),
|
|
provision_lark_cli_runtime=True,
|
|
provision_lark_cli_broker=True,
|
|
)
|
|
assert pod.spec.init_containers[0].name == "lark-cli-shim-init"
|
|
assert any(c.name == "lark-cli-broker" for c in pod.spec.containers)
|
|
|
|
def test_broker_sidecar_forwards_subcommand_denylist_when_configured(self, provisioner_module):
|
|
"""When DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS is set on the provisioner,
|
|
it is forwarded to the broker sidecar so it can refuse secret-dump
|
|
subcommands (issue #4338 hardening)."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
provisioner_module.LARK_CLI_BROKER_IMAGE = "deer-flow/lark-cli-broker:v1.0.65"
|
|
provisioner_module.LARK_CLI_BROKER_DENY_SUBCOMMANDS = "config show, auth token"
|
|
try:
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
user_id="alice",
|
|
extra_mounts=self._credential_mounts(provisioner_module),
|
|
provision_lark_cli_broker=True,
|
|
)
|
|
sidecar = next(c for c in pod.spec.containers if c.name == "lark-cli-broker")
|
|
env = {e.name: e.value for e in (sidecar.env or [])}
|
|
assert env.get("DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS") == "config show, auth token"
|
|
finally:
|
|
provisioner_module.LARK_CLI_BROKER_DENY_SUBCOMMANDS = ""
|
|
|
|
def test_broker_sidecar_omits_denylist_env_when_unset(self, provisioner_module):
|
|
"""Empty denylist ⇒ no env var (nothing blocked, no behavior change)."""
|
|
provisioner_module.SKILLS_PVC_NAME = ""
|
|
provisioner_module.USERDATA_PVC_NAME = ""
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
|
|
provisioner_module.LARK_CLI_BROKER_IMAGE = "deer-flow/lark-cli-broker:v1.0.65"
|
|
provisioner_module.LARK_CLI_BROKER_DENY_SUBCOMMANDS = ""
|
|
pod = provisioner_module._build_pod(
|
|
"sandbox-1",
|
|
"thread-1",
|
|
user_id="alice",
|
|
extra_mounts=self._credential_mounts(provisioner_module),
|
|
provision_lark_cli_broker=True,
|
|
)
|
|
sidecar = next(c for c in pod.spec.containers if c.name == "lark-cli-broker")
|
|
env = {e.name for e in (sidecar.env or [])}
|
|
assert "DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS" not in env
|