fix: stack profile file mounts onto profile git-mounts to avoid masking

When a config profile declares both a git_mount and a mounts entry for the
same directory (e.g. ~/.pi), the generated bind-mounts would mask each other
inside the container. Instead, copy the static profile files into the
instance-scoped git-mount source directory so the container sees both the
cloned repo contents and the static files through a single bind-mount.

- Add _stack_profile_mounts_with_git_mounts helper to merge overlapping
  profile mounts into git-mount sources.
- Integrate stacking into start_tool_instance after resolving both mount
  types.
- Add unit tests for exact, descendant, non-overlapping, and file cases.
This commit is contained in:
Developer
2026-06-15 12:50:19 +00:00
parent 6a61669294
commit 3358af57c2
2 changed files with 236 additions and 2 deletions
+82 -2
View File
@@ -5,6 +5,7 @@ import contextlib
import glob as glob_module
import logging
import os
import shutil
import subprocess
import uuid
from datetime import datetime
@@ -139,6 +140,79 @@ def _chown_staged_mounts(
_chown_path(source, uid, gid)
def _relative_under(parent: str, child: str) -> str | None:
"""Return the relative path of ``child`` under ``parent`` if it is inside.
Returns ``""`` when the paths are equal. Returns ``None`` when ``child``
is not under ``parent``.
"""
parent = os.path.normpath(parent)
child = os.path.normpath(child)
if child == parent:
return ""
prefix = parent + os.sep
if child.startswith(prefix):
return child[len(prefix) :]
return None
def _stack_profile_mounts_with_git_mounts(
profile_mounts: list[dict],
git_mount_volumes: list[dict],
) -> list[dict]:
"""Merge profile file mounts into overlapping git-mount sources.
When a config profile mounts static files to the same directory as a
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile
would mask the cloned repository. Instead, copy the profile files into
the git-mount source directory so the container sees both sets of files
through a single bind mount.
Profile mounts whose target is a child of a git-mount target are copied
into the corresponding subdirectory. Mounts that do not overlap are
returned unchanged.
"""
remaining: list[dict] = []
for pvol in profile_mounts:
p_source = pvol.get("source", "")
p_target = pvol.get("target", "")
if not p_source or not os.path.exists(p_source):
remaining.append(pvol)
continue
merged = False
for gvol in git_mount_volumes:
g_source = gvol.get("source", "")
g_target = gvol.get("target", "")
if not g_source or not os.path.isdir(g_source):
continue
rel = _relative_under(g_target, p_target)
if rel is None:
continue
dst = os.path.join(g_source, rel) if rel else g_source
if os.path.isdir(p_source):
shutil.copytree(p_source, dst, dirs_exist_ok=True)
else:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(p_source, dst)
logger.debug(
"Stacked profile mount %s into git mount %s at %s",
p_target,
g_target,
dst,
)
merged = True
break
if not merged:
remaining.append(pvol)
return remaining
async def resolve_git_mounts(
session: AsyncSession,
resolved: ResolvedProfile,
@@ -1323,19 +1397,25 @@ async def start_tool_instance(
port_override = profile_hints["port_override"]
env_vars.update(profile_env)
config_files.update(profile_files)
extra_volumes.extend(profile_mounts)
git_mount_volumes = await resolve_git_mounts(
session, resolved, instance_dir, working_directory, home_dir
)
# Stack static file mounts on top of git repo mounts so they do
# not mask each other when they target the same directory.
stacked_profile_mounts = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
extra_volumes.extend(stacked_profile_mounts)
extra_volumes.extend(git_mount_volumes)
logger.debug(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d, stacked=%d)",
resolved.profile_name,
instance.id,
len(profile_env),
len(profile_files),
len(profile_mounts),
len(git_mount_volumes),
len(profile_mounts) - len(stacked_profile_mounts),
)
except ConfigProfileCycleError as exc:
logger.error(