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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user