fix: mount config profile files individually instead of replacing directories
The previous sorting fix exposed a deeper bug: ResolvedMount always mounted its staging directory as a single bind mount. When a config profile mount targeted /workspace/x/y and contained a single file z.json, the staging directory (containing only z.json) replaced the ENTIRE /workspace/x/y directory, hiding all sibling files from git repo mounts. - Change apply_resolved_profile to mount each file individually: - source: staging_dir/relative_path - target: expanded_target/relative_path - Sibling files from other mounts are preserved. - Empty mounts produce no volume entries. - Keep volume sorting (parent paths before child paths) which is still necessary for directory mounts and ensures parent dirs exist before file mounts inside them. - Add 4 unit tests for file-level mount behavior. Quality gates: pytest (218 passed, 6 pre-existing), tsc --noEmit (clean)
This commit is contained in:
@@ -494,13 +494,16 @@ def apply_resolved_profile(
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
volume_mounts.append(
|
||||
{
|
||||
"source": str(mount_dir),
|
||||
"target": expanded_target,
|
||||
"type": "bind",
|
||||
}
|
||||
)
|
||||
# Mount each file individually so sibling files from other mounts
|
||||
# (e.g. git repo directories) are preserved.
|
||||
file_target = os.path.join(expanded_target, file_path)
|
||||
volume_mounts.append(
|
||||
{
|
||||
"source": str(full_path),
|
||||
"target": file_target,
|
||||
"type": "bind",
|
||||
}
|
||||
)
|
||||
|
||||
return env_vars, files, volume_mounts, resolved.runtime_hints
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.services.config_profile_resolver import (
|
||||
ConfigProfileCycleError,
|
||||
ConfigProfileNotFoundError,
|
||||
ResolvedMount,
|
||||
ResolvedProfile,
|
||||
apply_resolved_profile,
|
||||
check_include_cycle,
|
||||
resolve_profile,
|
||||
_merge_env_vars,
|
||||
@@ -479,6 +482,81 @@ class TestResolveProfile:
|
||||
await resolve_profile(db_session, uuid.uuid4())
|
||||
|
||||
|
||||
class TestApplyResolvedProfile:
|
||||
"""Unit tests for apply_resolved_profile file-level mount behavior."""
|
||||
|
||||
def test_mounts_individual_files_not_directory(self, tmp_path) -> None:
|
||||
"""Each file in a ResolvedMount should be mounted individually, not the staging dir."""
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=uuid.uuid4(),
|
||||
profile_name="test",
|
||||
mounts={
|
||||
"/app": ResolvedMount(
|
||||
target="/app",
|
||||
mode="rw",
|
||||
files={"config.json": '{"key": "value"}', "nested/file.txt": "hello"},
|
||||
)
|
||||
},
|
||||
)
|
||||
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||
|
||||
assert len(volumes) == 2
|
||||
targets = {v["target"] for v in volumes}
|
||||
assert "/app/config.json" in targets
|
||||
assert "/app/nested/file.txt" in targets
|
||||
# No directory-level mount
|
||||
assert "/app" not in targets
|
||||
|
||||
def test_file_mount_preserves_sibling_files(self, tmp_path) -> None:
|
||||
"""File-level mounts should not hide sibling files from other mounts."""
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=uuid.uuid4(),
|
||||
profile_name="test",
|
||||
mounts={
|
||||
"/workspace/x/y": ResolvedMount(
|
||||
target="/workspace/x/y",
|
||||
mode="rw",
|
||||
files={"z.json": "override"},
|
||||
)
|
||||
},
|
||||
)
|
||||
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||
|
||||
assert len(volumes) == 1
|
||||
assert volumes[0]["target"] == "/workspace/x/y/z.json"
|
||||
assert volumes[0]["source"].endswith("z.json")
|
||||
|
||||
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
||||
"""A mount with no files should not produce any volume entries."""
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=uuid.uuid4(),
|
||||
profile_name="test",
|
||||
mounts={
|
||||
"/app": ResolvedMount(target="/app", mode="rw", files={})
|
||||
},
|
||||
)
|
||||
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||
assert volumes == []
|
||||
|
||||
def test_home_expansion_in_file_mount_target(self, tmp_path) -> None:
|
||||
"""~ in mount target should be expanded to home_dir for file mounts."""
|
||||
resolved = ResolvedProfile(
|
||||
profile_id=uuid.uuid4(),
|
||||
profile_name="test",
|
||||
mounts={
|
||||
"~/.config": ResolvedMount(
|
||||
target="~/.config",
|
||||
mode="rw",
|
||||
files={"app.toml": "setting = 1"},
|
||||
)
|
||||
},
|
||||
)
|
||||
env, files, volumes, hints = apply_resolved_profile(
|
||||
str(tmp_path), resolved, home_dir="/home/user"
|
||||
)
|
||||
assert volumes[0]["target"] == "/home/user/.config/app.toml"
|
||||
|
||||
|
||||
class TestCheckIncludeCycle:
|
||||
"""Unit tests for include cycle checking."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user