|
| 1 | +"""Regression tests for provisioner PVC volume support.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import importlib.util |
| 6 | +from pathlib import Path |
| 7 | + |
| 8 | +import pytest |
| 9 | + |
| 10 | + |
| 11 | +@pytest.fixture() |
| 12 | +def provisioner_module(): |
| 13 | + """Load docker/provisioner/app.py as an importable test module.""" |
| 14 | + repo_root = Path(__file__).resolve().parents[2] |
| 15 | + module_path = repo_root / "docker" / "provisioner" / "app.py" |
| 16 | + spec = importlib.util.spec_from_file_location("provisioner_app_test", module_path) |
| 17 | + assert spec is not None |
| 18 | + assert spec.loader is not None |
| 19 | + module = importlib.util.module_from_spec(spec) |
| 20 | + spec.loader.exec_module(module) |
| 21 | + return module |
| 22 | + |
| 23 | + |
| 24 | +# ── _build_volumes ───────────────────────────────────────────────────── |
| 25 | + |
| 26 | + |
| 27 | +class TestBuildVolumes: |
| 28 | + """Tests for _build_volumes: PVC vs hostPath selection.""" |
| 29 | + |
| 30 | + def test_default_uses_hostpath_for_skills(self, provisioner_module): |
| 31 | + """When SKILLS_PVC_NAME is empty, skills volume should use hostPath.""" |
| 32 | + provisioner_module.SKILLS_PVC_NAME = "" |
| 33 | + volumes = provisioner_module._build_volumes("thread-1") |
| 34 | + skills_vol = volumes[0] |
| 35 | + assert skills_vol.host_path is not None |
| 36 | + assert skills_vol.host_path.path == provisioner_module.SKILLS_HOST_PATH |
| 37 | + assert skills_vol.host_path.type == "Directory" |
| 38 | + assert skills_vol.persistent_volume_claim is None |
| 39 | + |
| 40 | + def test_default_uses_hostpath_for_userdata(self, provisioner_module): |
| 41 | + """When USERDATA_PVC_NAME is empty, user-data volume should use hostPath.""" |
| 42 | + provisioner_module.USERDATA_PVC_NAME = "" |
| 43 | + volumes = provisioner_module._build_volumes("thread-1") |
| 44 | + userdata_vol = volumes[1] |
| 45 | + assert userdata_vol.host_path is not None |
| 46 | + assert userdata_vol.persistent_volume_claim is None |
| 47 | + |
| 48 | + def test_hostpath_userdata_includes_thread_id(self, provisioner_module): |
| 49 | + """hostPath user-data path should include thread_id.""" |
| 50 | + provisioner_module.USERDATA_PVC_NAME = "" |
| 51 | + volumes = provisioner_module._build_volumes("my-thread-42") |
| 52 | + userdata_vol = volumes[1] |
| 53 | + path = userdata_vol.host_path.path |
| 54 | + assert "my-thread-42" in path |
| 55 | + assert path.endswith("user-data") |
| 56 | + assert userdata_vol.host_path.type == "DirectoryOrCreate" |
| 57 | + |
| 58 | + def test_skills_pvc_overrides_hostpath(self, provisioner_module): |
| 59 | + """When SKILLS_PVC_NAME is set, skills volume should use PVC.""" |
| 60 | + provisioner_module.SKILLS_PVC_NAME = "my-skills-pvc" |
| 61 | + volumes = provisioner_module._build_volumes("thread-1") |
| 62 | + skills_vol = volumes[0] |
| 63 | + assert skills_vol.persistent_volume_claim is not None |
| 64 | + assert skills_vol.persistent_volume_claim.claim_name == "my-skills-pvc" |
| 65 | + assert skills_vol.persistent_volume_claim.read_only is True |
| 66 | + assert skills_vol.host_path is None |
| 67 | + |
| 68 | + def test_userdata_pvc_overrides_hostpath(self, provisioner_module): |
| 69 | + """When USERDATA_PVC_NAME is set, user-data volume should use PVC.""" |
| 70 | + provisioner_module.USERDATA_PVC_NAME = "my-userdata-pvc" |
| 71 | + volumes = provisioner_module._build_volumes("thread-1") |
| 72 | + userdata_vol = volumes[1] |
| 73 | + assert userdata_vol.persistent_volume_claim is not None |
| 74 | + assert userdata_vol.persistent_volume_claim.claim_name == "my-userdata-pvc" |
| 75 | + assert userdata_vol.host_path is None |
| 76 | + |
| 77 | + def test_both_pvc_set(self, provisioner_module): |
| 78 | + """When both PVC names are set, both volumes use PVC.""" |
| 79 | + provisioner_module.SKILLS_PVC_NAME = "skills-pvc" |
| 80 | + provisioner_module.USERDATA_PVC_NAME = "userdata-pvc" |
| 81 | + volumes = provisioner_module._build_volumes("thread-1") |
| 82 | + assert volumes[0].persistent_volume_claim is not None |
| 83 | + assert volumes[1].persistent_volume_claim is not None |
| 84 | + |
| 85 | + def test_returns_two_volumes(self, provisioner_module): |
| 86 | + """Should always return exactly two volumes.""" |
| 87 | + provisioner_module.SKILLS_PVC_NAME = "" |
| 88 | + provisioner_module.USERDATA_PVC_NAME = "" |
| 89 | + assert len(provisioner_module._build_volumes("t")) == 2 |
| 90 | + |
| 91 | + provisioner_module.SKILLS_PVC_NAME = "a" |
| 92 | + provisioner_module.USERDATA_PVC_NAME = "b" |
| 93 | + assert len(provisioner_module._build_volumes("t")) == 2 |
| 94 | + |
| 95 | + def test_volume_names_are_stable(self, provisioner_module): |
| 96 | + """Volume names must stay 'skills' and 'user-data'.""" |
| 97 | + volumes = provisioner_module._build_volumes("thread-1") |
| 98 | + assert volumes[0].name == "skills" |
| 99 | + assert volumes[1].name == "user-data" |
| 100 | + |
| 101 | + |
| 102 | +# ── _build_volume_mounts ─────────────────────────────────────────────── |
| 103 | + |
| 104 | + |
| 105 | +class TestBuildVolumeMounts: |
| 106 | + """Tests for _build_volume_mounts: mount paths and subPath behavior.""" |
| 107 | + |
| 108 | + def test_default_no_subpath(self, provisioner_module): |
| 109 | + """hostPath mode should not set sub_path on user-data mount.""" |
| 110 | + provisioner_module.USERDATA_PVC_NAME = "" |
| 111 | + mounts = provisioner_module._build_volume_mounts("thread-1") |
| 112 | + userdata_mount = mounts[1] |
| 113 | + assert userdata_mount.sub_path is None |
| 114 | + |
| 115 | + def test_pvc_sets_subpath(self, provisioner_module): |
| 116 | + """PVC mode should set sub_path to threads/{thread_id}/user-data.""" |
| 117 | + provisioner_module.USERDATA_PVC_NAME = "my-pvc" |
| 118 | + mounts = provisioner_module._build_volume_mounts("thread-42") |
| 119 | + userdata_mount = mounts[1] |
| 120 | + assert userdata_mount.sub_path == "threads/thread-42/user-data" |
| 121 | + |
| 122 | + def test_skills_mount_read_only(self, provisioner_module): |
| 123 | + """Skills mount should always be read-only.""" |
| 124 | + mounts = provisioner_module._build_volume_mounts("thread-1") |
| 125 | + assert mounts[0].read_only is True |
| 126 | + |
| 127 | + def test_userdata_mount_read_write(self, provisioner_module): |
| 128 | + """User-data mount should always be read-write.""" |
| 129 | + mounts = provisioner_module._build_volume_mounts("thread-1") |
| 130 | + assert mounts[1].read_only is False |
| 131 | + |
| 132 | + def test_mount_paths_are_stable(self, provisioner_module): |
| 133 | + """Mount paths must stay /mnt/skills and /mnt/user-data.""" |
| 134 | + mounts = provisioner_module._build_volume_mounts("thread-1") |
| 135 | + assert mounts[0].mount_path == "/mnt/skills" |
| 136 | + assert mounts[1].mount_path == "/mnt/user-data" |
| 137 | + |
| 138 | + def test_mount_names_match_volumes(self, provisioner_module): |
| 139 | + """Mount names should match the volume names.""" |
| 140 | + mounts = provisioner_module._build_volume_mounts("thread-1") |
| 141 | + assert mounts[0].name == "skills" |
| 142 | + assert mounts[1].name == "user-data" |
| 143 | + |
| 144 | + def test_returns_two_mounts(self, provisioner_module): |
| 145 | + """Should always return exactly two mounts.""" |
| 146 | + assert len(provisioner_module._build_volume_mounts("t")) == 2 |
| 147 | + |
| 148 | + |
| 149 | +# ── _build_pod integration ───────────────────────────────────────────── |
| 150 | + |
| 151 | + |
| 152 | +class TestBuildPodVolumes: |
| 153 | + """Integration: _build_pod should wire volumes and mounts correctly.""" |
| 154 | + |
| 155 | + def test_pod_spec_has_volumes(self, provisioner_module): |
| 156 | + """Pod spec should contain exactly 2 volumes.""" |
| 157 | + provisioner_module.SKILLS_PVC_NAME = "" |
| 158 | + provisioner_module.USERDATA_PVC_NAME = "" |
| 159 | + pod = provisioner_module._build_pod("sandbox-1", "thread-1") |
| 160 | + assert len(pod.spec.volumes) == 2 |
| 161 | + |
| 162 | + def test_pod_spec_has_volume_mounts(self, provisioner_module): |
| 163 | + """Container should have exactly 2 volume mounts.""" |
| 164 | + provisioner_module.SKILLS_PVC_NAME = "" |
| 165 | + provisioner_module.USERDATA_PVC_NAME = "" |
| 166 | + pod = provisioner_module._build_pod("sandbox-1", "thread-1") |
| 167 | + assert len(pod.spec.containers[0].volume_mounts) == 2 |
| 168 | + |
| 169 | + def test_pod_pvc_mode(self, provisioner_module): |
| 170 | + """Pod should use PVC volumes when PVC names are configured.""" |
| 171 | + provisioner_module.SKILLS_PVC_NAME = "skills-pvc" |
| 172 | + provisioner_module.USERDATA_PVC_NAME = "userdata-pvc" |
| 173 | + pod = provisioner_module._build_pod("sandbox-1", "thread-1") |
| 174 | + assert pod.spec.volumes[0].persistent_volume_claim is not None |
| 175 | + assert pod.spec.volumes[1].persistent_volume_claim is not None |
| 176 | + # subPath should be set on user-data mount |
| 177 | + userdata_mount = pod.spec.containers[0].volume_mounts[1] |
| 178 | + assert userdata_mount.sub_path == "threads/thread-1/user-data" |
0 commit comments