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:
Developer
2026-07-21 14:37:42 +00:00
parent 3c25fffd49
commit 886c863260
11 changed files with 254 additions and 81 deletions
@@ -1,6 +1,7 @@
"""Config profile API endpoints."""
import logging
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -33,6 +34,7 @@ from src.services.config.crud_service import (
update_profile,
validate_default_profiles,
)
from src.services.tool.instance_service import resolve_git_mounts
from src.services.config.resolver_service import (
resolve_default_profile,
validate_git_url,
@@ -174,6 +176,43 @@ async def update_config_profile(
return response
@router.post("/{profile_id}/refresh-git-mounts")
async def refresh_profile_git_mounts(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Refresh canonical Git mount sources used by running profile instances."""
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
)
if profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
outcomes = await _running_profile_outcomes(session, profile.id)
for outcome in outcomes:
instance = await session.get(ToolInstance, uuid.UUID(outcome["instance_id"]))
if (
instance is None
or not instance.compose_path
or instance.selected_config_profile_id is None
):
continue
resolved = await resolve_profile(session, instance.selected_config_profile_id)
await resolve_git_mounts(
session,
resolved,
os.path.dirname(instance.compose_path),
)
outcome["status"] = "refreshed"
outcome["reason"] = "Canonical Git mount source refreshed in place"
return {"refresh_outcomes": outcomes}
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_profile(
profile_id: str,
+81 -57
View File
@@ -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,