148 lines
5.2 KiB
Python
148 lines
5.2 KiB
Python
"""Guards the standalone GPU compose files against drift.
|
|
|
|
Stack-management UIs (Portainer, Coolify, Dockhand, ...) often accept only a
|
|
single compose file and do not honor COMPOSE_FILE or multiple ``-f`` overlays,
|
|
so the repo ships standalone ``docker-compose.gpu-*.yml`` files that inline the
|
|
GPU overlay. The base ``docker-compose.yml`` plus ``docker/gpu.*.yml`` overlays
|
|
remain the source of truth; these tests assert each standalone file equals the
|
|
base compose with only the matching overlay merged into the ``odysseus``
|
|
service. No Docker / docker compose is required — everything is pure YAML.
|
|
"""
|
|
|
|
import copy
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
BASE = ROOT / "docker-compose.yml"
|
|
NVIDIA_OVERLAY = ROOT / "docker" / "gpu.nvidia.yml"
|
|
AMD_OVERLAY = ROOT / "docker" / "gpu.amd.yml"
|
|
NVIDIA_STANDALONE = ROOT / "docker-compose.gpu-nvidia.yml"
|
|
AMD_STANDALONE = ROOT / "docker-compose.gpu-amd.yml"
|
|
|
|
SERVICE = "odysseus"
|
|
|
|
|
|
def _load(path: Path) -> dict:
|
|
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _deep_merge(base: dict, overlay: dict) -> dict:
|
|
"""Mirror docker compose overlay semantics for the keys these files use.
|
|
|
|
Mappings merge recursively; list-valued service fields are concatenated
|
|
(compose appends override sequences such as ``environment`` rather than
|
|
replacing them); scalars are overwritten. The overlays here only append to
|
|
``environment`` and add otherwise-absent keys (``deploy``, ``devices``,
|
|
``group_add``), so this keeps the expected merge explicit without invoking
|
|
docker compose.
|
|
"""
|
|
result = copy.deepcopy(base)
|
|
for key, value in overlay.items():
|
|
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
result[key] = _deep_merge(result[key], value)
|
|
elif isinstance(value, list) and isinstance(result.get(key), list):
|
|
result[key] = copy.deepcopy(result[key]) + copy.deepcopy(value)
|
|
else:
|
|
result[key] = copy.deepcopy(value)
|
|
return result
|
|
|
|
|
|
def _merge_overlay_into_base(base: dict, overlay: dict) -> dict:
|
|
"""Build the expected standalone config: base + overlay on odysseus only."""
|
|
expected = copy.deepcopy(base)
|
|
overlay_service = overlay["services"][SERVICE]
|
|
expected["services"][SERVICE] = _deep_merge(
|
|
expected["services"][SERVICE], overlay_service
|
|
)
|
|
return expected
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def base():
|
|
return _load(BASE)
|
|
|
|
|
|
# --- Equivalence: standalone == base + overlay -----------------------------
|
|
|
|
|
|
def test_nvidia_standalone_equals_base_plus_overlay(base):
|
|
overlay = _load(NVIDIA_OVERLAY)
|
|
standalone = _load(NVIDIA_STANDALONE)
|
|
assert standalone == _merge_overlay_into_base(base, overlay)
|
|
|
|
|
|
def test_amd_standalone_equals_base_plus_overlay(base):
|
|
overlay = _load(AMD_OVERLAY)
|
|
standalone = _load(AMD_STANDALONE)
|
|
assert standalone == _merge_overlay_into_base(base, overlay)
|
|
|
|
|
|
# --- Non-odysseus services and volumes untouched ---------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("standalone_path", [NVIDIA_STANDALONE, AMD_STANDALONE])
|
|
def test_non_odysseus_services_match_base(base, standalone_path):
|
|
standalone = _load(standalone_path)
|
|
for name, definition in base["services"].items():
|
|
if name == SERVICE:
|
|
continue
|
|
assert standalone["services"][name] == definition
|
|
assert set(standalone["services"]) == set(base["services"])
|
|
|
|
|
|
@pytest.mark.parametrize("standalone_path", [NVIDIA_STANDALONE, AMD_STANDALONE])
|
|
def test_top_level_volumes_match_base(base, standalone_path):
|
|
standalone = _load(standalone_path)
|
|
assert standalone.get("volumes") == base.get("volumes")
|
|
|
|
|
|
# --- odysseus = base service + only the overlay additions ------------------
|
|
|
|
|
|
def test_nvidia_odysseus_adds_only_overlay(base):
|
|
standalone = _load(NVIDIA_STANDALONE)
|
|
svc = standalone["services"][SERVICE]
|
|
base_svc = base["services"][SERVICE]
|
|
|
|
# Base environment preserved, plus exactly the two NVIDIA variables.
|
|
assert "NVIDIA_VISIBLE_DEVICES=all" in svc["environment"]
|
|
assert "NVIDIA_DRIVER_CAPABILITIES=compute,utility" in svc["environment"]
|
|
added_env = set(svc["environment"]) - set(base_svc["environment"])
|
|
assert added_env == {
|
|
"NVIDIA_VISIBLE_DEVICES=all",
|
|
"NVIDIA_DRIVER_CAPABILITIES=compute,utility",
|
|
}
|
|
|
|
# deploy block is new and matches the overlay's GPU reservation exactly.
|
|
assert "deploy" not in base_svc
|
|
devices = svc["deploy"]["resources"]["reservations"]["devices"]
|
|
assert devices == [
|
|
{"driver": "nvidia", "count": "all", "capabilities": ["gpu"]}
|
|
]
|
|
|
|
# No AMD-only keys leaked in.
|
|
assert "devices" not in svc
|
|
assert "group_add" not in svc
|
|
|
|
|
|
def test_amd_odysseus_adds_only_overlay(base):
|
|
standalone = _load(AMD_STANDALONE)
|
|
svc = standalone["services"][SERVICE]
|
|
base_svc = base["services"][SERVICE]
|
|
|
|
# Environment is unchanged from base for AMD.
|
|
assert svc["environment"] == base_svc["environment"]
|
|
|
|
# devices and group_add are new and match the overlay exactly.
|
|
assert "devices" not in base_svc
|
|
assert "group_add" not in base_svc
|
|
assert svc["devices"] == ["/dev/kfd", "/dev/dri"]
|
|
assert svc["group_add"] == ["video", "${RENDER_GID:-render}"]
|
|
|
|
# No NVIDIA-only keys leaked in.
|
|
assert "deploy" not in svc
|