fix: stage config-profile mounts as directories so ~/.config stays writable

- Switch apply_resolved_profile from per-file bind mounts to one
  directory-level bind mount per ResolvedMount target.
- Stage all configured files under instance_dir/mounts/<sanitized_target>
  and bind-mount that directory, so Docker no longer creates a root-owned
  parent directory such as ~/.config.
- Propagate read-only mode ('ro') as the 'readonly' flag on volume entries.
- Update unit tests to expect directory-level mounts and add coverage for
  readonly/writable flags.

Quality gates: python3 -m py_compile, pytest (313 passed, 34 skipped),
npm run typecheck, npm run lint.
This commit is contained in:
Developer
2026-06-13 11:56:48 +00:00
parent d78ca8a9d5
commit d395aaf574
21 changed files with 206 additions and 53 deletions
@@ -1,4 +1,6 @@
import uuid
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
@@ -483,10 +485,10 @@ class TestResolveProfile:
class TestApplyResolvedProfile:
"""Unit tests for apply_resolved_profile file-level mount behavior."""
"""Unit tests for apply_resolved_profile directory-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."""
def test_mounts_directory_not_individual_files(self, tmp_path) -> None:
"""Each ResolvedMount should produce one directory-level bind mount."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
@@ -503,15 +505,15 @@ class TestApplyResolvedProfile:
)
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
assert len(volumes) == 1
assert volumes[0]["target"] == "/app"
assert Path(volumes[0]["source"]).name == "app"
assert Path(volumes[0]["source"]).is_dir()
assert (Path(volumes[0]["source"]) / "config.json").exists()
assert (Path(volumes[0]["source"]) / "nested" / "file.txt").exists()
def test_file_mount_preserves_sibling_files(self, tmp_path) -> None:
"""File-level mounts should not hide sibling files from other mounts."""
def test_directory_mount_target(self, tmp_path) -> None:
"""A directory-level mount targets the configured directory path."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
@@ -526,8 +528,9 @@ class TestApplyResolvedProfile:
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")
assert volumes[0]["target"] == "/workspace/x/y"
assert Path(volumes[0]["source"]).name == "workspace_x_y"
assert (Path(volumes[0]["source"]) / "z.json").exists()
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
"""A mount with no files should not produce any volume entries."""
@@ -539,8 +542,8 @@ class TestApplyResolvedProfile:
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."""
def test_home_expansion_in_directory_mount_target(self, tmp_path) -> None:
"""~ in mount target should be expanded to home_dir for directory mounts."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
@@ -555,7 +558,47 @@ class TestApplyResolvedProfile:
env, files, volumes, hints = apply_resolved_profile(
str(tmp_path), resolved, home_dir="/home/user"
)
assert volumes[0]["target"] == "/home/user/.config/app.toml"
assert len(volumes) == 1
assert volumes[0]["target"] == "/home/user/.config"
assert (Path(volumes[0]["source"]) / "app.toml").exists()
def test_readonly_mount_sets_readonly_flag(self, tmp_path) -> None:
"""A mount with mode 'ro' should set readonly on the volume entry."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
mounts={
"/etc/app": ResolvedMount(
target="/etc/app",
mode="ro",
files={"config.cfg": "value"},
)
},
)
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
assert len(volumes) == 1
assert volumes[0]["target"] == "/etc/app"
assert volumes[0].get("readonly") is True
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
"""A mount with mode 'rw' should not set readonly on the volume entry."""
resolved = ResolvedProfile(
profile_id=uuid.uuid4(),
profile_name="test",
mounts={
"/app": ResolvedMount(
target="/app",
mode="rw",
files={"config.json": "{}"},
)
},
)
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
assert len(volumes) == 1
assert volumes[0]["target"] == "/app"
assert volumes[0].get("readonly") is False
class TestCheckIncludeCycle: