fix(config-profiles): synchronize shared mount working copies

Use canonical profile files and writable Git working copies so editor and container changes share one source. Require confirmation before destructive Git refreshes and overlay profile files without composite snapshots.
This commit is contained in:
2026-07-22 11:23:19 +02:00
parent 0d6c1926ae
commit 2247ec47c9
14 changed files with 285 additions and 159 deletions
+58 -104
View File
@@ -127,15 +127,20 @@ def _chown_staged_mounts(
uid: int,
gid: int,
) -> None:
"""Recursively chown instance-local mount sources to the container user.
"""Recursively chown writable profile and instance mount sources.
Profile copies, profile/Git composites, and SSH key mounts are created by
the API process. Their sources must be owned by the target container user
before Docker bind-mounts them into writable paths.
Canonical non-Git profile sources are shared by compatible instances, so
they must be writable by the container user rather than copied per
instance. Instance-local composites and SSH mounts remain supported.
"""
canonical_profile_root = os.path.join(
os.path.dirname(instance_dir), "config-profiles"
)
for vol in extra_volumes:
source = vol.get("source", "")
if not source or not source.startswith(instance_dir):
if not source or not (
source.startswith(instance_dir) or source.startswith(canonical_profile_root)
):
continue
_chown_path(source, uid, gid)
@@ -234,100 +239,38 @@ def _stack_profile_mounts_with_git_mounts(
git_mount_volumes: list[dict],
instance_dir: str,
) -> list[dict]:
"""Build instance-local composite mounts for overlapping profile and Git paths.
"""Overlay canonical profile files on Git directories without snapshots.
Docker applies one bind mount per target; it never merges their contents.
A composite therefore copies shared Git content first and profile content
second, so profile files extend (and intentionally override) the Git tree
without dirtying the shared clone.
An overlapping profile directory is expanded into individual child-file
mounts. Docker then mounts the Git working directory first and the more
specific canonical profile files last, preserving shared writable sources
instead of constructing an instance-local composite copy.
"""
records: list[tuple[str, dict]] = [
("profile", mount) for mount in profile_mounts
] + [("git", mount) for mount in git_mount_volumes]
components: list[list[int]] = []
remaining: set[int] = set(range(len(records)))
del instance_dir
result: list[dict] = list(git_mount_volumes)
while remaining:
component_indices: set[int] = {min(remaining)}
remaining.difference_update(component_indices)
pending = list(component_indices)
while pending:
current_index = pending.pop()
current_target = records[current_index][1].get("target", "")
for candidate_index in list(remaining):
candidate_target = records[candidate_index][1].get("target", "")
if _mounts_overlap(current_target, candidate_target):
remaining.remove(candidate_index)
component_indices.add(candidate_index)
pending.append(candidate_index)
components.append(sorted(component_indices))
result: list[dict] = []
for component_indexes in components:
component_records = [records[index] for index in component_indexes]
kinds = {kind for kind, _mount in component_records}
if kinds != {"profile", "git"}:
result.extend(mount for _kind, mount in component_records)
for profile_mount in profile_mounts:
source = profile_mount.get("source", "")
target = profile_mount.get("target", "")
overlaps_git = any(
_mounts_overlap(target, git_mount.get("target", ""))
for git_mount in git_mount_volumes
)
if not overlaps_git or not os.path.isdir(source):
result.append(profile_mount)
continue
targets = [
os.path.normpath(mount["target"]) for _kind, mount in component_records
]
composite_target = os.path.commonpath(targets)
if any(
not os.path.isdir(mount["source"])
and os.path.normpath(mount["target"]) == composite_target
for _kind, mount in component_records
):
composite_target = os.path.dirname(composite_target)
digest_input = "\0".join(
f"{kind}:{mount['source']}:{mount['target']}"
for kind, mount in component_records
)
composite_source = os.path.join(
instance_dir,
"mounts",
"composites",
hashlib.sha256(digest_input.encode()).hexdigest()[:16],
)
try:
if os.path.lexists(composite_source):
shutil.rmtree(composite_source)
os.makedirs(composite_source, exist_ok=True)
except OSError as exc:
raise RuntimeError(
f"Unable to prepare composite mount directory {composite_source}: {exc}"
) from exc
for kind in ("git", "profile"):
for record_kind, mount in component_records:
if record_kind != kind:
continue
relative_target = _relative_under(composite_target, mount["target"])
destination = (
composite_source
if relative_target == ""
else os.path.join(composite_source, relative_target or "")
for root, _dirs, files in os.walk(source):
for filename in files:
file_source = os.path.join(root, filename)
relative_path = os.path.relpath(file_source, source)
result.append(
{
**profile_mount,
"source": file_source,
"target": os.path.join(target, relative_path),
}
)
_copy_mount_source(mount["source"], destination)
result.append(
{
"source": composite_source,
"target": composite_target,
"type": "bind",
"readonly": all(
mount.get("readonly", False) for _kind, mount in component_records
),
}
)
logger.info(
"Composed %d profile/Git mounts at %s into %s",
len(component_records),
composite_target,
composite_source,
)
return result
@@ -629,8 +572,10 @@ async def resolve_single_git_mount(
volumes = resolve_git_mount_mappings(
repo_path, mappings, working_directory, home_dir
)
# Profile-scoped Git working copies are writable and shared by compatible
# containers. Explicit refresh replaces local edits with the remote ref.
for volume in volumes:
volume["readonly"] = True
volume["readonly"] = False
return volumes
@@ -675,10 +620,10 @@ def checkout_branch(repo_path: str, branch: str) -> bool:
def pull_repository_updates(repo_path: str, remote_url: str) -> None:
"""Pull latest updates from remote repository.
"""Replace a profile working copy with its current remote branch.
Used when starting a new container with an existing cloned repository
to ensure the latest code is mounted.
Git profile mounts are writable shared working copies. Refresh discards
local container/editor edits after fetching the remote baseline.
"""
import subprocess
@@ -692,15 +637,22 @@ def pull_repository_updates(repo_path: str, remote_url: str) -> None:
if result.returncode != 0:
raise RuntimeError(f"Failed to fetch updates: {result.stderr}")
# Pull changes for current branch
result = subprocess.run(
["git", "-C", repo_path, "pull", "origin"],
branch_result = subprocess.run(
["git", "-C", repo_path, "branch", "--show-current"],
capture_output=True,
text=True,
)
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else ""
if not branch:
raise RuntimeError("Unable to determine Git working-copy branch")
result = subprocess.run(
["git", "-C", repo_path, "reset", "--hard", f"origin/{branch}"],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to pull updates: {result.stderr}")
raise RuntimeError(f"Failed to reset working copy: {result.stderr}")
def expand_glob_source(source_path: str, repo_path: str) -> list[str]:
@@ -1588,9 +1540,11 @@ async def start_tool_instance(
git_mount_volumes = await resolve_git_mounts(
session, resolved, instance_dir, working_directory, home_dir
)
staged_profile_mounts = _stage_profile_mounts(profile_mounts, instance_dir)
# Non-Git profile mounts bind directly to canonical profile
# storage so edits made by one compatible container are visible to
# every other container and the profile editor readback path.
composed_mounts = _stack_profile_mounts_with_git_mounts(
staged_profile_mounts,
profile_mounts,
git_mount_volumes,
instance_dir,
)
@@ -1601,7 +1555,7 @@ async def start_tool_instance(
instance.id,
len(profile_env),
len(profile_files),
len(staged_profile_mounts),
len(profile_mounts),
len(git_mount_volumes),
len(composed_mounts),
)