merge: sort mounts by specificity

This commit is contained in:
Alex Blank
2026-05-29 11:35:36 +02:00
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
@@ -0,0 +1,4 @@
name: mount-specificity-ordering
status: completed
type: fix
priority: high
@@ -0,0 +1,55 @@
# Design: Mount Specificity Ordering
## Helper Function
Add `sort_volumes_by_specificity` to a shared utilities module. The most appropriate location is `apps/api/src/services/docker.py` since it already contains Docker/Compose helpers, or a new small module. We'll add it to `apps/api/src/services/docker.py` to keep the change minimal.
## Sorting Logic
```python
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("/")
```
Stable sort: `sorted(volumes, key=_target_depth)`.
## Warning Logic
```python
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
from collections import Counter
targets = [v.split(":")[1] if ":" in v else "" for v in volumes]
duplicates = [t for t, c in Counter(targets).items() if c > 1]
if duplicates:
logger.warning("Duplicate mount targets detected: %s", duplicates)
return sorted(volumes, key=_target_depth)
```
## Call Sites
### manifest_compiler.py
In `compile_compose()`, after building the `volumes` list and before assigning:
```python
from src.services.docker import sort_volumes_by_specificity
volumes = sort_volumes_by_specificity(volumes)
service["volumes"] = volumes
```
### tool_instances.py
In `_modify_compose_file()`, after appending `extra_volumes` and before write:
```python
from src.services.docker import sort_volumes_by_specificity
service_config["volumes"] = sort_volumes_by_specificity(service_config["volumes"])
```
## Files Changed
- `apps/api/src/services/docker.py` — add helper
- `apps/api/src/services/manifest_compiler.py` — sort manifest volumes
- `apps/api/src/api/tool_instances.py` — sort legacy volumes
- `apps/api/tests/unit/test_docker_service.py` — add tests
@@ -0,0 +1,130 @@
# Exploration: Mount Specificity Ordering
## Problem Statement
When a tool instance uses both git repo mounts and regular file mounts, overlapping
target paths can cause the broader mount to hide the more specific one.
Example:
- Git repo mount: `repo/x/``/workspace/x` (directory)
- Regular file mount: `config.json``/workspace/x/y/config.json` (single file)
Expected: `/workspace/x/y/config.json` contains the file mount contents.
Actual: The git mount overwrites `/workspace/x`, hiding `/workspace/x/y/config.json`.
## Root Cause
Docker Compose mounts volumes in the order they appear in the `volumes` array.
In Linux, a later mount at a parent path hides earlier mounts at child paths.
Current code ordering:
1. `compile_compose` adds manifest-defined mounts first
2. `EXTRA_VOLUMES` (profile + git mounts) appended after
Within `EXTRA_VOLUMES` in `start_instance`:
1. `profile_mounts` from `apply_resolved_profile`
2. `git_mount_volumes` from `_resolve_git_mounts`
Since git mounts are appended after regular mounts, a broad git mount
(e.g. `/workspace/x`) overwrites a specific regular mount
(e.g. `/workspace/x/y/config.json`).
## Affected Code Paths
1. **Manifest flow**: `compile_compose()` in `manifest_compiler.py`
- Manifest mounts → `EXTRA_VOLUMES`
- All appended to compose `volumes` list in that order
2. **Legacy flow**: `_modify_compose_file()` in `tool_instances.py`
- Existing template volumes → `extra_volumes` appended
- `extra_volumes` = profile_mounts + git_mount_volumes
3. **Both flows**: Volume entries are strings like `source:target` or `source:target:bind`
- No structured sorting happens before write
## Options
### Option A: Sort by path depth (recommended)
Sort all volume entries by target path specificity before writing compose.
- Shorter / parent paths first
- Deeper / child paths last
- Deeper mounts "win" by being layered on top
**Pros:**
- Simple, predictable rule
- Works for all mount types (manifest, git, profile, template)
- Minimal code change
**Cons:**
- Sorting by string length is naive (edge cases with similar paths)
- Need proper path-segment counting
- Doesn't handle exact same target conflicts
### Option B: Detect and warn on overlaps
Before writing compose, detect when any two mounts have overlapping target paths.
Log a warning and optionally fail fast.
**Pros:**
- Surfaces conflicts to user early
- No silent data loss
**Cons:**
- Doesn't actually fix the problem; user has to redesign mounts
- False positives for legitimate use cases (mounting different files into same tree)
### Option C: Merge overlapping mounts into a single staging directory
Instead of mounting multiple sources, stage all files into a single merged
directory on disk, then mount that single directory.
**Pros:**
- Eliminates Docker mount ordering entirely
- Natural specificity: later file writes overwrite earlier ones
**Cons:**
- Complex to implement correctly
- Git mounts would need to be cloned into staging area
- Breaks live file editing (bind mounts from host)
- Large refactor
### Option D: Annotate mount specificity and merge in compiler
Add a `priority` or `specificity` field to mount definitions.
Compiler sorts by this field.
**Pros:**
- Explicit control
**Cons:**
- Adds schema complexity
- Users must understand mount ordering
- Overkill for this use case
## Recommendation
**Option A** — sort by path depth.
Rationale:
- Mount specificity should "just work" without user intervention
- Path depth is a natural proxy for specificity
- A parent directory mount is almost always less specific than a child file mount
- Implementation is ~20 lines in the compose write path
- Can be combined with Option B (warn on exact conflicts) for safety
## Acceptance Criteria
1. A git repo mount to `/workspace/x` and a regular mount to `/workspace/x/y/config.json`
both work: the config.json file contains the regular mount contents.
2. Multiple overlapping mounts sort consistently (deterministic).
3. Exact same-target conflicts are logged as warnings.
4. Both manifest and legacy flows behave correctly.
5. Unit tests cover overlap scenarios.
## Files to Modify
- `apps/api/src/services/manifest_compiler.py``compile_compose()` sorting
- `apps/api/src/api/tool_instances.py``_modify_compose_file()` sorting
- `apps/api/tests/unit/test_manifest_compiler.py` — new tests
- `apps/api/tests/unit/test_tool_instances.py` — new tests (or `test_tool_instances_legacy.py`)
@@ -0,0 +1,19 @@
# Proposal: Mount Specificity Ordering
## Problem
When git repo mounts and regular file mounts have overlapping target paths, the broader mount hides the more specific one because Docker Compose applies volumes in array order.
Example: repo → `/workspace/x` (directory) hides file → `/workspace/x/y/config.json`.
## Solution
Sort all volume entries by target path depth before writing the compose file. Parent paths first, child paths last, so deeper mounts overlay correctly.
## Scope
- `manifest_compiler.py``compile_compose()`
- `tool_instances.py``_modify_compose_file()`
- Unit tests for overlap scenarios
## Impact
- Fixes silent mount hiding
- Deterministic ordering
- No user-facing API or schema changes
@@ -0,0 +1,45 @@
# Spec: Mount Specificity Ordering
## Requirements
1. All volume mount entries written to compose files must be sorted by target path depth.
2. Shorter / parent target paths appear **before** deeper / child target paths.
3. Deeper mounts are applied later by Docker, overlaying parent mounts correctly.
4. Exact same-target overlaps are logged as warnings.
5. Works for both manifest and legacy compose generation paths.
## Algorithm
```python
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
"""
Sort volume strings so parent paths come before child paths.
Volume format: source:target or source:target:type
"""
```
1. Parse each volume string to extract the target path (second colon-delimited field).
2. Normalize the target: strip trailing `/`, collapse `//`.
3. Compute depth = number of `/`-separated segments.
4. Sort ascending by depth. Stable sort preserves input order for equal depths.
5. Detect exact same-target strings and log warnings.
## Compose Format Handling
- `source:target` → target is second field
- `source:target:bind` → target is second field
- `source:target:ro` → target is second field
- Split by `:` into at most 3 parts. Target is always index 1.
## Integration Points
- `compile_compose()` in `manifest_compiler.py`: sort `volumes` list before assigning to `service["volumes"]`.
- `_modify_compose_file()` in `tool_instances.py`: sort `service_config["volumes"]` after appending extra volumes.
## Tests
- Repo mount `/workspace/x` + file mount `/workspace/x/y/config.json` → file mount comes after.
- Same depth mounts → stable order preserved.
- Exact same target → warning logged.
- Empty volumes list → no-op.
- Volume with `type` suffix → parsed correctly.
@@ -0,0 +1,12 @@
# Tasks: Mount Specificity Ordering
- [x] Exploration written
- [x] Proposal written
- [x] Spec written
- [x] Design written
- [x] Implement `sort_volumes_by_specificity` in `docker.py`
- [x] Integrate sorting into `compile_compose` (manifest flow)
- [x] Integrate sorting into `_modify_compose_file` (legacy flow)
- [x] Add unit tests for helper and overlap scenarios
- [x] Run quality gates (pytest, tsc)
- [x] Commit and merge