fix: sort mount volumes by specificity to prevent parent mounts hiding children

When git repo mounts and regular file mounts have overlapping target
paths, broader parent mounts hide deeper child mounts because Docker
Compose applies volumes in array order.

- Add sort_volumes_by_specificity() to docker.py:
  - Sorts by target path depth (parent paths first, child paths last)
  - Logs warnings for duplicate targets
  - Handles :bind and :ro suffixes correctly

- Integrate into manifest flow (compile_compose):
  - Sorts manifest mounts + EXTRA_VOLUMES before writing compose

- Integrate into legacy flow (_modify_compose_file):
  - Sorts after appending extra_volumes to existing template volumes

- Add 6 unit tests covering parent/child ordering, stable sort,
  type suffixes, empty list, single volume, and duplicate warnings.

Quality gates: pytest (214 passed, 6 pre-existing), tsc --noEmit (clean)
This commit is contained in:
Alex Blank
2026-05-29 11:35:27 +02:00
parent 787e8844bc
commit 0952aa8217
10 changed files with 378 additions and 2 deletions
+7
View File
@@ -56,6 +56,7 @@ from src.services.docker import (
get_container_status,
recreate_tunnel,
render_compose_template,
sort_volumes_by_specificity,
start_cloudflared_tunnel,
stop_cloudflared_tunnel,
wait_for_container_running,
@@ -599,6 +600,12 @@ def _modify_compose_file(
else:
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
# Sort volumes so parent paths come before child paths
if service_config.get("volumes"):
service_config["volumes"] = sort_volumes_by_specificity(
service_config["volumes"]
)
break # Only modify the first service
# Write back
+42
View File
@@ -1,12 +1,54 @@
"""Docker service for managing tool instances."""
import logging
import os
import re
import subprocess
import time
from collections import Counter
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
"""Sort volume strings so parent paths come before child paths.
Docker Compose mounts volumes in array order. A later mount at a parent
path hides earlier mounts at child paths. By sorting shallow paths first
and deep paths last, deeper (more specific) mounts overlay correctly.
Volume format: source:target or source:target:type
Args:
volumes: List of Docker volume mount strings.
Returns:
Sorted list with parent paths before child paths.
"""
def _target_depth(vol: str) -> int:
parts = vol.split(":")
if len(parts) < 2:
return 0
target = parts[1].rstrip("/")
if not target or target == "/":
return 0
return target.count("/")
# Detect duplicate targets and warn
targets = []
for vol in volumes:
parts = vol.split(":")
targets.append(parts[1] if len(parts) > 1 else "")
dupes = [t for t, c in Counter(targets).items() if c > 1]
if dupes:
logger.warning("Duplicate mount targets detected: %s", dupes)
# Stable sort: parent paths first, child paths last
return sorted(volumes, key=_target_depth)
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
+3 -1
View File
@@ -8,6 +8,8 @@ from typing import Any
import yaml
from src.services.docker import sort_volumes_by_specificity
def resolve_base(manifest: dict) -> dict:
"""Merge a base definition into a tool manifest.
@@ -303,7 +305,7 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
volumes.append(vol_str)
if volumes:
service["volumes"] = volumes
service["volumes"] = sort_volumes_by_specificity(volumes)
compose = {"services": {"app": service}}
return yaml.dump(compose, default_flow_style=False)
+61 -1
View File
@@ -2,7 +2,13 @@
from unittest.mock import MagicMock, patch
from src.services.docker import get_container_id, get_container_name
import logging
from src.services.docker import (
get_container_id,
get_container_name,
sort_volumes_by_specificity,
)
class TestGetContainerId:
@@ -50,3 +56,57 @@ class TestGetContainerName:
result = get_container_name("missing")
assert result is None
class TestSortVolumesBySpecificity:
"""Tests for sort_volumes_by_specificity."""
def test_parent_before_child(self) -> None:
"""A repo mount to /workspace/x should come before a file mount to /workspace/x/y/config.json."""
volumes = [
"/repo/x/y/config.json:/workspace/x/y/config.json",
"/repo/x:/workspace/x",
]
result = sort_volumes_by_specificity(volumes)
assert result[0] == "/repo/x:/workspace/x"
assert result[1] == "/repo/x/y/config.json:/workspace/x/y/config.json"
def test_stable_sort_for_equal_depth(self) -> None:
"""Mounts at the same depth preserve input order."""
volumes = [
"/a:/workspace/a",
"/b:/workspace/b",
"/c:/workspace/c",
]
result = sort_volumes_by_specificity(volumes)
assert result == volumes
def test_with_type_suffix(self) -> None:
"""Volume strings with :bind or :ro suffixes are parsed correctly."""
volumes = [
"/repo/x/y/config.json:/workspace/x/y/config.json:bind",
"/repo/x:/workspace/x:bind",
]
result = sort_volumes_by_specificity(volumes)
assert result[0] == "/repo/x:/workspace/x:bind"
assert result[1] == "/repo/x/y/config.json:/workspace/x/y/config.json:bind"
def test_empty_list(self) -> None:
"""Empty list returns empty list."""
assert sort_volumes_by_specificity([]) == []
def test_single_volume(self) -> None:
"""Single volume returns unchanged."""
volumes = ["/repo:/workspace"]
assert sort_volumes_by_specificity(volumes) == volumes
def test_duplicate_target_warning(self, caplog) -> None:
"""Duplicate targets trigger a warning."""
with caplog.at_level(logging.WARNING, logger="src.services.docker"):
volumes = [
"/a:/workspace/x",
"/b:/workspace/x",
]
sort_volumes_by_specificity(volumes)
assert "Duplicate mount targets detected" in caplog.text
assert "/workspace/x" in caplog.text