feat: refresh shared Git config mounts live
- Use profile-scoped canonical Git clone sources with locked refreshes - Mount shared Git configuration read-only and isolate profile content - Add API and desktop/mobile actions for live Git mount refresh Quality gates: frontend build and backend py_compile passed. Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import fcntl
|
||||
import glob as glob_module
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -158,57 +160,23 @@ 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.
|
||||
"""Leave profile and Git sources isolated.
|
||||
|
||||
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.
|
||||
Git mount sources are shared, read-only canonical checkouts. Profile
|
||||
content must never be copied into one because doing so dirties the
|
||||
checkout and leaks one profile's content to every instance using it.
|
||||
"""
|
||||
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
|
||||
for profile_mount in profile_mounts:
|
||||
profile_target = profile_mount.get("target", "")
|
||||
for git_mount in git_mount_volumes:
|
||||
if _relative_under(git_mount.get("target", ""), profile_target) is not None:
|
||||
logger.warning(
|
||||
"Config profile mount %s overlaps Git mount %s; keeping sources isolated",
|
||||
profile_target,
|
||||
git_mount.get("target"),
|
||||
)
|
||||
break
|
||||
return profile_mounts
|
||||
|
||||
|
||||
async def resolve_git_mounts(
|
||||
@@ -227,12 +195,18 @@ async def resolve_git_mounts(
|
||||
if not resolved.git_mounts:
|
||||
return []
|
||||
|
||||
# Git mount sources are profile-scoped, not instance-scoped, so compatible
|
||||
# instances bind the same canonical checkout.
|
||||
clone_parent = os.path.join(
|
||||
os.path.dirname(instance_dir or ""), "config-profiles", str(resolved.profile_id)
|
||||
)
|
||||
|
||||
# Process all git mounts concurrently
|
||||
tasks = []
|
||||
for git_mount in resolved.git_mounts:
|
||||
tasks.append(
|
||||
resolve_single_git_mount(
|
||||
session, git_mount, instance_dir, working_directory, home_dir
|
||||
session, git_mount, clone_parent, working_directory, home_dir
|
||||
)
|
||||
)
|
||||
|
||||
@@ -265,6 +239,37 @@ def normalize_git_mount(entry: dict) -> dict:
|
||||
return entry
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _git_mount_lock(clone_parent: str, remote_url: str, branch: str | None):
|
||||
"""Serialize clone and refresh operations for one canonical Git source."""
|
||||
lock_dir = os.path.join(clone_parent, "git-mounts")
|
||||
identity = f"{remote_url}:{branch or 'default'}"
|
||||
lock_path = os.path.join(
|
||||
lock_dir, f".{hashlib.sha256(identity.encode()).hexdigest()}.lock"
|
||||
)
|
||||
try:
|
||||
os.makedirs(lock_dir, exist_ok=True)
|
||||
with open(lock_path, "a+", encoding="utf-8") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Cannot lock Git mount source: {lock_path}") from exc
|
||||
|
||||
|
||||
def clone_git_repo_locked(
|
||||
remote_url: str,
|
||||
branch: str | None,
|
||||
clone_parent: str,
|
||||
project_name: str | None = None,
|
||||
) -> str:
|
||||
"""Clone or refresh a canonical source while holding its process lock."""
|
||||
with _git_mount_lock(clone_parent, remote_url, branch):
|
||||
return clone_git_repo(remote_url, branch, clone_parent, project_name)
|
||||
|
||||
|
||||
def clone_git_repo(
|
||||
remote_url: str,
|
||||
branch: str | None,
|
||||
@@ -425,7 +430,7 @@ def resolve_git_mount_mappings(
|
||||
async def resolve_single_git_mount(
|
||||
session: AsyncSession,
|
||||
git_mount: dict,
|
||||
instance_dir: str | None = None,
|
||||
clone_parent: str | None = None,
|
||||
working_directory: str | None = None,
|
||||
home_dir: str = "/root",
|
||||
) -> list[dict]:
|
||||
@@ -447,15 +452,15 @@ async def resolve_single_git_mount(
|
||||
logger.warning("Invalid git mount skipped: no mappings")
|
||||
return []
|
||||
|
||||
if not instance_dir:
|
||||
logger.warning("Git mount skipped: no instance_dir provided for cloning")
|
||||
if not clone_parent:
|
||||
logger.warning("Git mount skipped: no canonical profile directory provided")
|
||||
return []
|
||||
|
||||
# Clone or pull the repository. Git mounts are auxiliary, so they keep
|
||||
# using the repository URL basename rather than the project name.
|
||||
try:
|
||||
repo_path = await asyncio.to_thread(
|
||||
clone_git_repo, remote_url, branch, instance_dir
|
||||
clone_git_repo_locked, remote_url, branch, clone_parent
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -468,7 +473,12 @@ async def resolve_single_git_mount(
|
||||
return []
|
||||
|
||||
# Resolve all mappings from the cloned repo
|
||||
return resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir)
|
||||
volumes = resolve_git_mount_mappings(
|
||||
repo_path, mappings, working_directory, home_dir
|
||||
)
|
||||
for volume in volumes:
|
||||
volume["readonly"] = True
|
||||
return volumes
|
||||
|
||||
|
||||
def checkout_branch(repo_path: str, branch: str) -> bool:
|
||||
@@ -1481,7 +1491,15 @@ async def start_tool_instance(
|
||||
|
||||
if ssh_keys_to_mount:
|
||||
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
|
||||
os.makedirs(ssh_dir, exist_ok=True)
|
||||
try:
|
||||
os.makedirs(ssh_dir, exist_ok=True)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to create SSH mount directory for instance %s: %s",
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
ssh_keys_to_mount = []
|
||||
|
||||
key_filenames = []
|
||||
for ssh_key in ssh_keys_to_mount:
|
||||
@@ -2194,7 +2212,13 @@ async def delete_tool_instance(
|
||||
if os.path.exists(instance_dir):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(instance_dir)
|
||||
try:
|
||||
shutil.rmtree(instance_dir)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to remove instance directory %s: %s", instance_dir, exc
|
||||
)
|
||||
raise RuntimeError("Failed to remove instance files") from exc
|
||||
|
||||
await publish_lifecycle_event(
|
||||
event_bus=_event_bus,
|
||||
|
||||
Reference in New Issue
Block a user