diff --git a/apps/api/src/services/config/config_profile_resolver.py b/apps/api/src/services/config/config_profile_resolver.py index e04f164..e1f24d3 100644 --- a/apps/api/src/services/config/config_profile_resolver.py +++ b/apps/api/src/services/config/config_profile_resolver.py @@ -515,7 +515,13 @@ def apply_resolved_profile( files_dir = profile_dir / "files" 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 try: 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) return None 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( mode="w", encoding="utf-8", dir=path.parent, delete=False ) as temporary_file: @@ -534,7 +546,9 @@ def apply_resolved_profile( # Top-level profile files are individual bind mounts under the working # directory. They therefore cannot mask the workspace directory itself. 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: continue volume_mounts.append( diff --git a/apps/api/tests/unit/test_config_profile_resolver.py b/apps/api/tests/unit/test_config_profile_resolver.py index 9c9ee70..b0e94a6 100644 --- a/apps/api/tests/unit/test_config_profile_resolver.py +++ b/apps/api/tests/unit/test_config_profile_resolver.py @@ -36,7 +36,7 @@ class TestMergeFunctions: def test_merge_env_vars_tracks_overrides(self) -> None: """Test that env var overrides are tracked.""" - overrides = {} + overrides: dict[str, str] = {} _merge_env_vars( {"A": "1"}, {"A": "2"}, @@ -93,7 +93,7 @@ class TestMergeFunctions: """Test that mount mode conflicts are resolved (later wins).""" from src.services.config.config_profile_resolver import ResolvedMount - overrides = {} + overrides: dict[str, str] = {} result = _merge_mounts( {"/app": ResolvedMount(target="/app", mode="rw", files={})}, [{"target": "/app", "mode": "ro", "files": {}}], @@ -562,6 +562,28 @@ class TestApplyResolvedProfile: ] 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: """Different instance paths resolve a profile to one canonical source.""" profile_id = uuid.uuid4()