fix(config-profiles): preserve bound file inodes
Overwrite individually bind-mounted profile files in place so editor saves remain visible to running containers.
This commit is contained in:
@@ -515,7 +515,13 @@ def apply_resolved_profile(
|
|||||||
files_dir = profile_dir / "files"
|
files_dir = profile_dir / "files"
|
||||||
mounts_dir = profile_dir / "mounts"
|
mounts_dir = profile_dir / "mounts"
|
||||||
|
|
||||||
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
|
def write_canonical_file(
|
||||||
|
root: Path,
|
||||||
|
relative_path: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
preserve_inode: bool = False,
|
||||||
|
) -> Path | None:
|
||||||
path = root / relative_path
|
path = root / relative_path
|
||||||
try:
|
try:
|
||||||
path.resolve().relative_to(root.resolve())
|
path.resolve().relative_to(root.resolve())
|
||||||
@@ -523,6 +529,12 @@ def apply_resolved_profile(
|
|||||||
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
|
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
|
||||||
return None
|
return None
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if preserve_inode and path.is_file():
|
||||||
|
# A file bind mount follows its inode, not its directory entry.
|
||||||
|
# Replacing this path would leave a running container attached to
|
||||||
|
# the old inode, so overwrite the existing file in place.
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return path
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
||||||
) as temporary_file:
|
) as temporary_file:
|
||||||
@@ -534,7 +546,9 @@ def apply_resolved_profile(
|
|||||||
# Top-level profile files are individual bind mounts under the working
|
# Top-level profile files are individual bind mounts under the working
|
||||||
# directory. They therefore cannot mask the workspace directory itself.
|
# directory. They therefore cannot mask the workspace directory itself.
|
||||||
for file_path, content in resolved.files.items():
|
for file_path, content in resolved.files.items():
|
||||||
canonical_file = write_canonical_file(files_dir, file_path, content)
|
canonical_file = write_canonical_file(
|
||||||
|
files_dir, file_path, content, preserve_inode=True
|
||||||
|
)
|
||||||
if canonical_file is None:
|
if canonical_file is None:
|
||||||
continue
|
continue
|
||||||
volume_mounts.append(
|
volume_mounts.append(
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class TestMergeFunctions:
|
|||||||
|
|
||||||
def test_merge_env_vars_tracks_overrides(self) -> None:
|
def test_merge_env_vars_tracks_overrides(self) -> None:
|
||||||
"""Test that env var overrides are tracked."""
|
"""Test that env var overrides are tracked."""
|
||||||
overrides = {}
|
overrides: dict[str, str] = {}
|
||||||
_merge_env_vars(
|
_merge_env_vars(
|
||||||
{"A": "1"},
|
{"A": "1"},
|
||||||
{"A": "2"},
|
{"A": "2"},
|
||||||
@@ -93,7 +93,7 @@ class TestMergeFunctions:
|
|||||||
"""Test that mount mode conflicts are resolved (later wins)."""
|
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||||
from src.services.config.config_profile_resolver import ResolvedMount
|
from src.services.config.config_profile_resolver import ResolvedMount
|
||||||
|
|
||||||
overrides = {}
|
overrides: dict[str, str] = {}
|
||||||
result = _merge_mounts(
|
result = _merge_mounts(
|
||||||
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||||
[{"target": "/app", "mode": "ro", "files": {}}],
|
[{"target": "/app", "mode": "ro", "files": {}}],
|
||||||
@@ -562,6 +562,28 @@ class TestApplyResolvedProfile:
|
|||||||
]
|
]
|
||||||
assert canonical_file.read_text() == "setting = true"
|
assert canonical_file.read_text() == "setting = true"
|
||||||
|
|
||||||
|
def test_top_level_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
|
||||||
|
"""An individually bind-mounted file must update in place."""
|
||||||
|
profile_id = uuid.uuid4()
|
||||||
|
instance_root = tmp_path / "instances"
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
profile_name="test",
|
||||||
|
files={"settings.toml": "value = 1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||||
|
canonical_file = (
|
||||||
|
instance_root / "config-profiles" / str(profile_id) / "files" / "settings.toml"
|
||||||
|
)
|
||||||
|
original_inode = canonical_file.stat().st_ino
|
||||||
|
|
||||||
|
resolved.files["settings.toml"] = "value = 2"
|
||||||
|
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||||
|
|
||||||
|
assert canonical_file.stat().st_ino == original_inode
|
||||||
|
assert canonical_file.read_text() == "value = 2"
|
||||||
|
|
||||||
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
|
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
|
||||||
"""Different instance paths resolve a profile to one canonical source."""
|
"""Different instance paths resolve a profile to one canonical source."""
|
||||||
profile_id = uuid.uuid4()
|
profile_id = uuid.uuid4()
|
||||||
|
|||||||
Reference in New Issue
Block a user