Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 900a8e47a5 | |||
| 25e870ba43 | |||
| 16984b7cf6 | |||
| fc52353b2e | |||
| a63a983116 | |||
| 886c863260 | |||
| 3c25fffd49 |
@@ -1,6 +1,7 @@
|
|||||||
"""Config profile API endpoints."""
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
@@ -174,6 +175,47 @@ async def update_config_profile(
|
|||||||
return response
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import lazily: instance_service imports tool schemas that transitively
|
||||||
|
# load API routers, so importing it during router initialization cycles.
|
||||||
|
from src.services.tool.instance_service import resolve_git_mounts
|
||||||
|
|
||||||
|
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)
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_config_profile(
|
async def delete_config_profile(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import fcntl
|
||||||
import glob as glob_module
|
import glob as glob_module
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -125,11 +127,11 @@ def _chown_staged_mounts(
|
|||||||
uid: int,
|
uid: int,
|
||||||
gid: int,
|
gid: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Recursively chown staged mount sources to the container user.
|
"""Recursively chown instance-local mount sources to the container user.
|
||||||
|
|
||||||
Config-profile mounts, git mounts, and SSH key mounts are staged under
|
Profile copies, profile/Git composites, and SSH key mounts are created by
|
||||||
instance_dir by the API process (root). Without this, the container
|
the API process. Their sources must be owned by the target container user
|
||||||
user cannot write into bind-mounted directories such as ~/.config.
|
before Docker bind-mounts them into writable paths.
|
||||||
"""
|
"""
|
||||||
for vol in extra_volumes:
|
for vol in extra_volumes:
|
||||||
source = vol.get("source", "")
|
source = vol.get("source", "")
|
||||||
@@ -154,61 +156,180 @@ def _relative_under(parent: str, child: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_profile_mounts(profile_mounts: list[dict], instance_dir: str) -> list[dict]:
|
||||||
|
"""Copy profile bind sources into an instance-local, writable staging area."""
|
||||||
|
staged_mounts: list[dict] = []
|
||||||
|
staging_root = os.path.join(instance_dir, "mounts", "profiles")
|
||||||
|
|
||||||
|
for mount in profile_mounts:
|
||||||
|
source = mount.get("source", "")
|
||||||
|
target = mount.get("target", "")
|
||||||
|
if not source or not target:
|
||||||
|
continue
|
||||||
|
if not os.path.exists(source):
|
||||||
|
logger.warning("Skipping missing config profile mount source: %s", source)
|
||||||
|
continue
|
||||||
|
|
||||||
|
digest = hashlib.sha256(f"{source}\0{target}".encode()).hexdigest()[:16]
|
||||||
|
staged_source = os.path.join(staging_root, digest)
|
||||||
|
try:
|
||||||
|
if os.path.lexists(staged_source):
|
||||||
|
if os.path.isdir(staged_source):
|
||||||
|
shutil.rmtree(staged_source)
|
||||||
|
else:
|
||||||
|
os.unlink(staged_source)
|
||||||
|
os.makedirs(os.path.dirname(staged_source), exist_ok=True)
|
||||||
|
|
||||||
|
if os.path.isdir(source):
|
||||||
|
shutil.copytree(source, staged_source, symlinks=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, staged_source, follow_symlinks=False)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Failed to stage config profile mount %s: %s", source, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
staged_mount = dict(mount)
|
||||||
|
staged_mount["source"] = staged_source
|
||||||
|
staged_mounts.append(staged_mount)
|
||||||
|
|
||||||
|
return staged_mounts
|
||||||
|
|
||||||
|
|
||||||
|
def _mounts_overlap(first_target: str, second_target: str) -> bool:
|
||||||
|
"""Return whether two normalized container mount targets intersect."""
|
||||||
|
return (
|
||||||
|
_relative_under(first_target, second_target) is not None
|
||||||
|
or _relative_under(second_target, first_target) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_mount_source(source: str, destination: str) -> None:
|
||||||
|
"""Copy a bind-mount source into its destination in a composite tree."""
|
||||||
|
try:
|
||||||
|
if os.path.isdir(source):
|
||||||
|
os.makedirs(destination, exist_ok=True)
|
||||||
|
for entry in os.listdir(source):
|
||||||
|
source_entry = os.path.join(source, entry)
|
||||||
|
destination_entry = os.path.join(destination, entry)
|
||||||
|
if os.path.isdir(source_entry):
|
||||||
|
shutil.copytree(
|
||||||
|
source_entry,
|
||||||
|
destination_entry,
|
||||||
|
dirs_exist_ok=True,
|
||||||
|
symlinks=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.makedirs(os.path.dirname(destination_entry), exist_ok=True)
|
||||||
|
shutil.copy2(source_entry, destination_entry, follow_symlinks=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||||
|
shutil.copy2(source, destination, follow_symlinks=False)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Unable to compose mount source {source}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def _stack_profile_mounts_with_git_mounts(
|
def _stack_profile_mounts_with_git_mounts(
|
||||||
profile_mounts: list[dict],
|
profile_mounts: list[dict],
|
||||||
git_mount_volumes: list[dict],
|
git_mount_volumes: list[dict],
|
||||||
|
instance_dir: str,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Merge profile file mounts into overlapping git-mount sources.
|
"""Build instance-local composite mounts for overlapping profile and Git paths.
|
||||||
|
|
||||||
When a config profile mounts static files to the same directory as a
|
Docker applies one bind mount per target; it never merges their contents.
|
||||||
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile
|
A composite therefore copies shared Git content first and profile content
|
||||||
would mask the cloned repository. Instead, copy the profile files into
|
second, so profile files extend (and intentionally override) the Git tree
|
||||||
the git-mount source directory so the container sees both sets of files
|
without dirtying the shared clone.
|
||||||
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] = []
|
records: list[tuple[str, dict]] = [
|
||||||
for pvol in profile_mounts:
|
("profile", mount) for mount in profile_mounts
|
||||||
p_source = pvol.get("source", "")
|
] + [("git", mount) for mount in git_mount_volumes]
|
||||||
p_target = pvol.get("target", "")
|
components: list[list[int]] = []
|
||||||
if not p_source or not os.path.exists(p_source):
|
remaining: set[int] = set(range(len(records)))
|
||||||
remaining.append(pvol)
|
|
||||||
|
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)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
merged = False
|
targets = [
|
||||||
for gvol in git_mount_volumes:
|
os.path.normpath(mount["target"]) for _kind, mount in component_records
|
||||||
g_source = gvol.get("source", "")
|
]
|
||||||
g_target = gvol.get("target", "")
|
composite_target = os.path.commonpath(targets)
|
||||||
if not g_source or not os.path.isdir(g_source):
|
if any(
|
||||||
continue
|
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)
|
||||||
|
|
||||||
rel = _relative_under(g_target, p_target)
|
digest_input = "\0".join(
|
||||||
if rel is None:
|
f"{kind}:{mount['source']}:{mount['target']}"
|
||||||
continue
|
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
|
||||||
|
|
||||||
dst = os.path.join(g_source, rel) if rel else g_source
|
for kind in ("git", "profile"):
|
||||||
if os.path.isdir(p_source):
|
for record_kind, mount in component_records:
|
||||||
shutil.copytree(p_source, dst, dirs_exist_ok=True)
|
if record_kind != kind:
|
||||||
else:
|
continue
|
||||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
relative_target = _relative_under(composite_target, mount["target"])
|
||||||
shutil.copy2(p_source, dst)
|
destination = (
|
||||||
|
composite_source
|
||||||
|
if relative_target == ""
|
||||||
|
else os.path.join(composite_source, relative_target or "")
|
||||||
|
)
|
||||||
|
_copy_mount_source(mount["source"], destination)
|
||||||
|
|
||||||
logger.debug(
|
result.append(
|
||||||
"Stacked profile mount %s into git mount %s at %s",
|
{
|
||||||
p_target,
|
"source": composite_source,
|
||||||
g_target,
|
"target": composite_target,
|
||||||
dst,
|
"type": "bind",
|
||||||
)
|
"readonly": all(
|
||||||
merged = True
|
mount.get("readonly", False) for _kind, mount in component_records
|
||||||
break
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Composed %d profile/Git mounts at %s into %s",
|
||||||
|
len(component_records),
|
||||||
|
composite_target,
|
||||||
|
composite_source,
|
||||||
|
)
|
||||||
|
|
||||||
if not merged:
|
return result
|
||||||
remaining.append(pvol)
|
|
||||||
|
|
||||||
return remaining
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_git_mounts(
|
async def resolve_git_mounts(
|
||||||
@@ -227,12 +348,18 @@ async def resolve_git_mounts(
|
|||||||
if not resolved.git_mounts:
|
if not resolved.git_mounts:
|
||||||
return []
|
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
|
# Process all git mounts concurrently
|
||||||
tasks = []
|
tasks = []
|
||||||
for git_mount in resolved.git_mounts:
|
for git_mount in resolved.git_mounts:
|
||||||
tasks.append(
|
tasks.append(
|
||||||
resolve_single_git_mount(
|
resolve_single_git_mount(
|
||||||
session, git_mount, instance_dir, working_directory, home_dir
|
session, git_mount, clone_parent, working_directory, home_dir
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -265,6 +392,37 @@ def normalize_git_mount(entry: dict) -> dict:
|
|||||||
return entry
|
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(
|
def clone_git_repo(
|
||||||
remote_url: str,
|
remote_url: str,
|
||||||
branch: str | None,
|
branch: str | None,
|
||||||
@@ -425,7 +583,7 @@ def resolve_git_mount_mappings(
|
|||||||
async def resolve_single_git_mount(
|
async def resolve_single_git_mount(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
git_mount: dict,
|
git_mount: dict,
|
||||||
instance_dir: str | None = None,
|
clone_parent: str | None = None,
|
||||||
working_directory: str | None = None,
|
working_directory: str | None = None,
|
||||||
home_dir: str = "/root",
|
home_dir: str = "/root",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
@@ -447,15 +605,15 @@ async def resolve_single_git_mount(
|
|||||||
logger.warning("Invalid git mount skipped: no mappings")
|
logger.warning("Invalid git mount skipped: no mappings")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not instance_dir:
|
if not clone_parent:
|
||||||
logger.warning("Git mount skipped: no instance_dir provided for cloning")
|
logger.warning("Git mount skipped: no canonical profile directory provided")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Clone or pull the repository. Git mounts are auxiliary, so they keep
|
# Clone or pull the repository. Git mounts are auxiliary, so they keep
|
||||||
# using the repository URL basename rather than the project name.
|
# using the repository URL basename rather than the project name.
|
||||||
try:
|
try:
|
||||||
repo_path = await asyncio.to_thread(
|
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:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -468,7 +626,12 @@ async def resolve_single_git_mount(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Resolve all mappings from the cloned repo
|
# 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:
|
def checkout_branch(repo_path: str, branch: str) -> bool:
|
||||||
@@ -1425,22 +1588,22 @@ async def start_tool_instance(
|
|||||||
git_mount_volumes = await resolve_git_mounts(
|
git_mount_volumes = await resolve_git_mounts(
|
||||||
session, resolved, instance_dir, working_directory, home_dir
|
session, resolved, instance_dir, working_directory, home_dir
|
||||||
)
|
)
|
||||||
# Stack static file mounts on top of git repo mounts so they do
|
staged_profile_mounts = _stage_profile_mounts(profile_mounts, instance_dir)
|
||||||
# not mask each other when they target the same directory.
|
composed_mounts = _stack_profile_mounts_with_git_mounts(
|
||||||
stacked_profile_mounts = _stack_profile_mounts_with_git_mounts(
|
staged_profile_mounts,
|
||||||
profile_mounts, git_mount_volumes
|
git_mount_volumes,
|
||||||
|
instance_dir,
|
||||||
)
|
)
|
||||||
extra_volumes.extend(stacked_profile_mounts)
|
extra_volumes.extend(composed_mounts)
|
||||||
extra_volumes.extend(git_mount_volumes)
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d, stacked=%d)",
|
"Applied config profile %s to instance %s (env=%d, files=%d, profile_mounts=%d, git_mounts=%d, composed_mounts=%d)",
|
||||||
resolved.profile_name,
|
resolved.profile_name,
|
||||||
instance.id,
|
instance.id,
|
||||||
len(profile_env),
|
len(profile_env),
|
||||||
len(profile_files),
|
len(profile_files),
|
||||||
len(profile_mounts),
|
len(staged_profile_mounts),
|
||||||
len(git_mount_volumes),
|
len(git_mount_volumes),
|
||||||
len(profile_mounts) - len(stacked_profile_mounts),
|
len(composed_mounts),
|
||||||
)
|
)
|
||||||
except ConfigProfileCycleError as exc:
|
except ConfigProfileCycleError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -1481,7 +1644,15 @@ async def start_tool_instance(
|
|||||||
|
|
||||||
if ssh_keys_to_mount:
|
if ssh_keys_to_mount:
|
||||||
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
|
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 = []
|
key_filenames = []
|
||||||
for ssh_key in ssh_keys_to_mount:
|
for ssh_key in ssh_keys_to_mount:
|
||||||
@@ -2194,7 +2365,13 @@ async def delete_tool_instance(
|
|||||||
if os.path.exists(instance_dir):
|
if os.path.exists(instance_dir):
|
||||||
import shutil
|
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(
|
await publish_lifecycle_event(
|
||||||
event_bus=_event_bus,
|
event_bus=_event_bus,
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, AsyncMock
|
from unittest.mock import MagicMock, AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.services.tool.instance_service import (
|
from src.services.tool.instance_service import (
|
||||||
|
_chown_staged_mounts,
|
||||||
_get_repository_mount_name,
|
_get_repository_mount_name,
|
||||||
_stack_profile_mounts_with_git_mounts,
|
_stack_profile_mounts_with_git_mounts,
|
||||||
|
_stage_profile_mounts,
|
||||||
clone_git_repo,
|
clone_git_repo,
|
||||||
modify_compose_file,
|
modify_compose_file,
|
||||||
prepare_manifest_instance,
|
prepare_manifest_instance,
|
||||||
@@ -80,12 +83,7 @@ class TestCloneGitRepo:
|
|||||||
branch = None
|
branch = None
|
||||||
clone_parent = str(tmp_path)
|
clone_parent = str(tmp_path)
|
||||||
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
|
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
|
||||||
repo_path = (
|
repo_path = tmp_path / "git-mounts" / f"dotfiles-{url_hash}" / "repo-clone"
|
||||||
tmp_path
|
|
||||||
/ "git-mounts"
|
|
||||||
/ f"dotfiles-{url_hash}"
|
|
||||||
/ "repo-clone"
|
|
||||||
)
|
|
||||||
(repo_path / ".git").mkdir(parents=True)
|
(repo_path / ".git").mkdir(parents=True)
|
||||||
|
|
||||||
clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
|
clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
|
||||||
@@ -99,7 +97,9 @@ class TestCloneGitRepo:
|
|||||||
clone.assert_not_called()
|
clone.assert_not_called()
|
||||||
pull.assert_called_once_with(str(repo_path), remote_url)
|
pull.assert_called_once_with(str(repo_path), remote_url)
|
||||||
|
|
||||||
def test_replaces_incomplete_clone_before_retry(self, monkeypatch, tmp_path) -> None:
|
def test_replaces_incomplete_clone_before_retry(
|
||||||
|
self, monkeypatch, tmp_path
|
||||||
|
) -> None:
|
||||||
from src.services.tool import instance_service
|
from src.services.tool import instance_service
|
||||||
|
|
||||||
remote_url = "https://gitlab.com/example/dotfiles"
|
remote_url = "https://gitlab.com/example/dotfiles"
|
||||||
@@ -126,153 +126,185 @@ class TestCloneGitRepo:
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestStackProfileMountsWithGitMounts:
|
class TestStackProfileMountsWithGitMounts:
|
||||||
"""Tests for _stack_profile_mounts_with_git_mounts."""
|
"""Tests for composing profile and Git mounts without Docker masking."""
|
||||||
|
|
||||||
def test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) -> None:
|
def test_stages_profile_source_under_instance_directory(self, tmp_path) -> None:
|
||||||
"""When a profile mount targets the same directory as a git mount,
|
"""Profile sources must be instance-local before ownership is fixed."""
|
||||||
the profile files should be copied into the git-mount source so the
|
instance_dir = tmp_path / "instance"
|
||||||
container sees both sets of files through one bind mount."""
|
profile_source = tmp_path / "profile" / "settings.json"
|
||||||
|
profile_source.parent.mkdir(parents=True)
|
||||||
|
profile_source.write_text("{}")
|
||||||
|
|
||||||
|
staged = _stage_profile_mounts(
|
||||||
|
[{"source": str(profile_source), "target": "/home/user/.pi/settings.json"}],
|
||||||
|
str(instance_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert staged[0]["source"].startswith(str(instance_dir))
|
||||||
|
assert staged[0]["source"] != str(profile_source)
|
||||||
|
assert Path(staged[0]["source"]).read_text() == "{}"
|
||||||
|
|
||||||
|
def test_staged_profile_source_is_chowned_for_container_user(
|
||||||
|
self, monkeypatch, tmp_path
|
||||||
|
) -> None:
|
||||||
|
"""The ownership pass must include the instance-local profile copy."""
|
||||||
|
from src.services.tool import instance_service
|
||||||
|
|
||||||
|
instance_dir = tmp_path / "instance"
|
||||||
|
profile_source = tmp_path / "profile"
|
||||||
|
profile_source.mkdir()
|
||||||
|
(profile_source / "settings.json").write_text("{}")
|
||||||
|
staged = _stage_profile_mounts(
|
||||||
|
[{"source": str(profile_source), "target": "/home/user/.pi"}],
|
||||||
|
str(instance_dir),
|
||||||
|
)
|
||||||
|
chown = MagicMock()
|
||||||
|
monkeypatch.setattr(instance_service, "_chown_path", chown)
|
||||||
|
|
||||||
|
_chown_staged_mounts(staged, str(instance_dir), 1000, 1000)
|
||||||
|
|
||||||
|
chown.assert_called_once_with(staged[0]["source"], 1000, 1000)
|
||||||
|
|
||||||
|
def test_exact_overlap_creates_instance_local_composite(self, tmp_path) -> None:
|
||||||
|
"""Profile files extend a Git root without mutating its shared clone."""
|
||||||
|
instance_dir = tmp_path / "instance"
|
||||||
git_source = tmp_path / "git" / "repo-clone"
|
git_source = tmp_path / "git" / "repo-clone"
|
||||||
git_source.mkdir(parents=True)
|
git_source.mkdir(parents=True)
|
||||||
(git_source / "existing.txt").write_text("from git")
|
(git_source / "existing.txt").write_text("from git")
|
||||||
|
profile_source = tmp_path / "profile"
|
||||||
profile_source = tmp_path / "profile" / "home_user_.pi"
|
profile_source.mkdir()
|
||||||
profile_source.mkdir(parents=True)
|
|
||||||
(profile_source / "settings.json").write_text("{}")
|
(profile_source / "settings.json").write_text("{}")
|
||||||
|
|
||||||
profile_mounts = [
|
profile_mounts = _stage_profile_mounts(
|
||||||
{
|
[
|
||||||
"source": str(profile_source),
|
{
|
||||||
"target": "/home/user/.pi",
|
"source": str(profile_source),
|
||||||
"type": "bind",
|
"target": "/home/user/.pi",
|
||||||
"readonly": False,
|
"type": "bind",
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
git_mount_volumes = [
|
str(instance_dir),
|
||||||
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
|
)
|
||||||
]
|
|
||||||
|
|
||||||
result = _stack_profile_mounts_with_git_mounts(
|
result = _stack_profile_mounts_with_git_mounts(
|
||||||
profile_mounts, git_mount_volumes
|
profile_mounts,
|
||||||
|
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
|
||||||
|
str(instance_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert len(result) == 1
|
||||||
assert (git_source / "existing.txt").read_text() == "from git"
|
composite = result[0]
|
||||||
assert (git_source / "settings.json").read_text() == "{}"
|
assert composite["target"] == "/home/user/.pi"
|
||||||
|
assert composite["source"].startswith(str(instance_dir))
|
||||||
|
assert not (tmp_path / "git" / "repo-clone" / "settings.json").exists()
|
||||||
|
assert (
|
||||||
|
tmp_path / "git" / "repo-clone" / "existing.txt"
|
||||||
|
).read_text() == "from git"
|
||||||
|
assert (tmp_path / "instance" / "mounts" / "composites").exists()
|
||||||
|
assert (Path(composite["source"]) / "existing.txt").read_text() == "from git"
|
||||||
|
assert (Path(composite["source"]) / "settings.json").read_text() == "{}"
|
||||||
|
|
||||||
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None:
|
def test_descendant_profile_mount_extends_git_root(self, tmp_path) -> None:
|
||||||
"""Profile mounts targeting a child directory are copied into the
|
"""Nested targets are composed at the Git root, preserving siblings."""
|
||||||
corresponding subdirectory of the git-mount source."""
|
instance_dir = tmp_path / "instance"
|
||||||
git_source = tmp_path / "git"
|
git_source = tmp_path / "git"
|
||||||
git_source.mkdir()
|
git_source.mkdir()
|
||||||
(git_source / "README").write_text("repo")
|
(git_source / "README").write_text("repo")
|
||||||
|
profile_source = tmp_path / "profile"
|
||||||
profile_source = tmp_path / "profile" / "agent"
|
profile_source.mkdir()
|
||||||
profile_source.mkdir(parents=True)
|
|
||||||
(profile_source / "settings.json").write_text("x")
|
(profile_source / "settings.json").write_text("x")
|
||||||
|
|
||||||
profile_mounts = [
|
profile_mounts = _stage_profile_mounts(
|
||||||
{
|
[{"source": str(profile_source), "target": "/home/user/.pi/agent"}],
|
||||||
"source": str(profile_source),
|
str(instance_dir),
|
||||||
"target": "/home/user/.pi/agent",
|
)
|
||||||
"type": "bind",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
git_mount_volumes = [
|
|
||||||
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
|
|
||||||
]
|
|
||||||
|
|
||||||
result = _stack_profile_mounts_with_git_mounts(
|
result = _stack_profile_mounts_with_git_mounts(
|
||||||
profile_mounts, git_mount_volumes
|
profile_mounts,
|
||||||
|
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
|
||||||
|
str(instance_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert len(result) == 1
|
||||||
assert (git_source / "agent" / "settings.json").read_text() == "x"
|
composite = Path(result[0]["source"])
|
||||||
assert (git_source / "README").read_text() == "repo"
|
assert result[0]["target"] == "/home/user/.pi"
|
||||||
|
assert (composite / "README").read_text() == "repo"
|
||||||
|
assert (composite / "agent" / "settings.json").read_text() == "x"
|
||||||
|
assert not (git_source / "agent").exists()
|
||||||
|
|
||||||
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
|
def test_file_profile_mount_extends_git_root(self, tmp_path) -> None:
|
||||||
"""Profile mounts that do not overlap a git mount are returned as-is."""
|
"""A file bind mount is composed into the Git directory, not masked."""
|
||||||
|
instance_dir = tmp_path / "instance"
|
||||||
git_source = tmp_path / "git"
|
git_source = tmp_path / "git"
|
||||||
git_source.mkdir()
|
git_source.mkdir()
|
||||||
|
(git_source / "README").write_text("repo")
|
||||||
profile_source = tmp_path / "profile"
|
|
||||||
profile_source.mkdir()
|
|
||||||
(profile_source / "config").write_text("c")
|
|
||||||
|
|
||||||
profile_mounts = [
|
|
||||||
{
|
|
||||||
"source": str(profile_source),
|
|
||||||
"target": "/home/user/.config",
|
|
||||||
"type": "bind",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
git_mount_volumes = [
|
|
||||||
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
|
|
||||||
]
|
|
||||||
|
|
||||||
result = _stack_profile_mounts_with_git_mounts(
|
|
||||||
profile_mounts, git_mount_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == profile_mounts
|
|
||||||
|
|
||||||
def test_git_source_file_does_not_consume_profile_mount(self, tmp_path) -> None:
|
|
||||||
"""If the overlapping git-mount source is a file, the profile mount
|
|
||||||
cannot be merged and must be kept."""
|
|
||||||
git_source = tmp_path / "file.txt"
|
|
||||||
git_source.write_text("file")
|
|
||||||
|
|
||||||
profile_source = tmp_path / "profile"
|
|
||||||
profile_source.mkdir()
|
|
||||||
(profile_source / "settings.json").write_text("{}")
|
|
||||||
|
|
||||||
profile_mounts = [
|
|
||||||
{
|
|
||||||
"source": str(profile_source),
|
|
||||||
"target": "/home/user/.pi",
|
|
||||||
"type": "bind",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
git_mount_volumes = [
|
|
||||||
{
|
|
||||||
"source": str(git_source),
|
|
||||||
"target": "/home/user/.pi/file.txt",
|
|
||||||
"type": "bind",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
result = _stack_profile_mounts_with_git_mounts(
|
|
||||||
profile_mounts, git_mount_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == profile_mounts
|
|
||||||
|
|
||||||
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None:
|
|
||||||
"""A profile mount that supplies a single file is copied into the
|
|
||||||
git-mount source directory."""
|
|
||||||
git_source = tmp_path / "git"
|
|
||||||
git_source.mkdir()
|
|
||||||
|
|
||||||
profile_source = tmp_path / "settings.json"
|
profile_source = tmp_path / "settings.json"
|
||||||
profile_source.write_text("{}")
|
profile_source.write_text("{}")
|
||||||
|
|
||||||
profile_mounts = [
|
profile_mounts = _stage_profile_mounts(
|
||||||
{
|
[
|
||||||
"source": str(profile_source),
|
{
|
||||||
"target": "/home/user/.pi/settings.json",
|
"source": str(profile_source),
|
||||||
"type": "bind",
|
"target": "/home/user/.pi/settings.json",
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
git_mount_volumes = [
|
str(instance_dir),
|
||||||
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
|
)
|
||||||
]
|
|
||||||
|
|
||||||
result = _stack_profile_mounts_with_git_mounts(
|
result = _stack_profile_mounts_with_git_mounts(
|
||||||
profile_mounts, git_mount_volumes
|
profile_mounts,
|
||||||
|
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
|
||||||
|
str(instance_dir),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert len(result) == 1
|
||||||
assert (git_source / "settings.json").read_text() == "{}"
|
composite = Path(result[0]["source"])
|
||||||
|
assert result[0]["target"] == "/home/user/.pi"
|
||||||
|
assert (composite / "README").read_text() == "repo"
|
||||||
|
assert (composite / "settings.json").read_text() == "{}"
|
||||||
|
|
||||||
|
def test_parent_profile_mount_extends_nested_git_mount(self, tmp_path) -> None:
|
||||||
|
"""A profile parent mount keeps Git content and its own sibling files."""
|
||||||
|
instance_dir = tmp_path / "instance"
|
||||||
|
git_source = tmp_path / "git"
|
||||||
|
git_source.mkdir()
|
||||||
|
(git_source / "plugin.toml").write_text("git")
|
||||||
|
profile_source = tmp_path / "profile"
|
||||||
|
profile_source.mkdir()
|
||||||
|
(profile_source / "config.toml").write_text("profile")
|
||||||
|
|
||||||
|
profile_mounts = _stage_profile_mounts(
|
||||||
|
[{"source": str(profile_source), "target": "/home/user"}], str(instance_dir)
|
||||||
|
)
|
||||||
|
result = _stack_profile_mounts_with_git_mounts(
|
||||||
|
profile_mounts,
|
||||||
|
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
|
||||||
|
str(instance_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
composite = Path(result[0]["source"])
|
||||||
|
assert result[0]["target"] == "/home/user"
|
||||||
|
assert (composite / "config.toml").read_text() == "profile"
|
||||||
|
assert (composite / ".pi" / "plugin.toml").read_text() == "git"
|
||||||
|
|
||||||
|
def test_non_overlapping_mounts_remain_separate(self, tmp_path) -> None:
|
||||||
|
"""Unrelated profile and Git mounts retain their independent sources."""
|
||||||
|
instance_dir = tmp_path / "instance"
|
||||||
|
git_source = tmp_path / "git"
|
||||||
|
git_source.mkdir()
|
||||||
|
profile_source = tmp_path / "profile"
|
||||||
|
profile_source.mkdir()
|
||||||
|
|
||||||
|
profile_mounts = _stage_profile_mounts(
|
||||||
|
[{"source": str(profile_source), "target": "/home/user/.config"}],
|
||||||
|
str(instance_dir),
|
||||||
|
)
|
||||||
|
git_mounts = [{"source": str(git_source), "target": "/home/user/.pi"}]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
_stack_profile_mounts_with_git_mounts(
|
||||||
|
profile_mounts, git_mounts, str(instance_dir)
|
||||||
|
)
|
||||||
|
== profile_mounts + git_mounts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { apiClient } from "./client";
|
|||||||
|
|
||||||
export interface ConfigProfileRefreshOutcome {
|
export interface ConfigProfileRefreshOutcome {
|
||||||
instance_id: string;
|
instance_id: string;
|
||||||
status: "compatible" | "restart_required" | "incompatible_permissions";
|
status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +145,15 @@ export const deleteConfigProfile = async (id: string): Promise<void> => {
|
|||||||
await apiClient.delete(`/config-profiles/${id}`);
|
await apiClient.delete(`/config-profiles/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const refreshConfigProfileGitMounts = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<{ refresh_outcomes: ConfigProfileRefreshOutcome[] }> => {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
refresh_outcomes: ConfigProfileRefreshOutcome[];
|
||||||
|
}>(`/config-profiles/${id}/refresh-git-mounts`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const updateProfileIncludes = async (
|
export const updateProfileIncludes = async (
|
||||||
id: string,
|
id: string,
|
||||||
data: UpdateIncludesRequest,
|
data: UpdateIncludesRequest,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface Props {
|
|||||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
previewData: ResolvedProfile | null;
|
previewData: ResolvedProfile | null;
|
||||||
previewingId: string | null;
|
previewingId: string | null;
|
||||||
|
isRefreshingGitMounts: boolean;
|
||||||
projects: ProjectWithRepos[];
|
projects: ProjectWithRepos[];
|
||||||
toolTypes: ToolType[];
|
toolTypes: ToolType[];
|
||||||
availableProfiles: ConfigProfile[];
|
availableProfiles: ConfigProfile[];
|
||||||
@@ -23,6 +24,7 @@ interface Props {
|
|||||||
onSubmit: (e?: React.FormEvent) => void;
|
onSubmit: (e?: React.FormEvent) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
|
onRefreshGitMounts: () => void;
|
||||||
onAddInclude: (id: string) => void;
|
onAddInclude: (id: string) => void;
|
||||||
onRemoveInclude: (index: number) => void;
|
onRemoveInclude: (index: number) => void;
|
||||||
onDragStart: (e: React.DragEvent, index: number) => void;
|
onDragStart: (e: React.DragEvent, index: number) => void;
|
||||||
@@ -54,6 +56,7 @@ export const ConfigProfileEditorPanel = ({
|
|||||||
saveStatus,
|
saveStatus,
|
||||||
previewData,
|
previewData,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
isRefreshingGitMounts,
|
||||||
projects,
|
projects,
|
||||||
toolTypes,
|
toolTypes,
|
||||||
availableProfiles,
|
availableProfiles,
|
||||||
@@ -63,6 +66,7 @@ export const ConfigProfileEditorPanel = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onReset,
|
onReset,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onRefreshGitMounts,
|
||||||
onAddInclude,
|
onAddInclude,
|
||||||
onRemoveInclude,
|
onRemoveInclude,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
@@ -114,6 +118,17 @@ export const ConfigProfileEditorPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
{!isCreating && selectedProfile && (
|
{!isCreating && selectedProfile && (
|
||||||
<div className="row row-sm">
|
<div className="row row-sm">
|
||||||
|
<button className="btn btn-secondary" onClick={onRefreshGitMounts} disabled={isRefreshingGitMounts}>
|
||||||
|
{isRefreshingGitMounts ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" /> Refreshing Git mounts...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
|
||||||
{previewingId === selectedProfile.id ? (
|
{previewingId === selectedProfile.id ? (
|
||||||
<><Icon name="loading" size="sm" /> Previewing...</>
|
<><Icon name="loading" size="sm" /> Previewing...</>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface Props {
|
|||||||
availableProfiles: ConfigProfile[];
|
availableProfiles: ConfigProfile[];
|
||||||
previewData: ResolvedProfile | null;
|
previewData: ResolvedProfile | null;
|
||||||
previewingId: string | null;
|
previewingId: string | null;
|
||||||
|
isRefreshingGitMounts: boolean;
|
||||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onViewChange: (view: MobileView) => void;
|
onViewChange: (view: MobileView) => void;
|
||||||
@@ -64,6 +65,7 @@ interface Props {
|
|||||||
) => void;
|
) => void;
|
||||||
onRemoveMountFile: (mountIndex: number, path: string) => void;
|
onRemoveMountFile: (mountIndex: number, path: string) => void;
|
||||||
onPreview: (id: string) => void;
|
onPreview: (id: string) => void;
|
||||||
|
onRefreshGitMounts: (id: string) => void;
|
||||||
onClosePreview: () => void;
|
onClosePreview: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ export const ConfigProfilesMobileView = ({
|
|||||||
availableProfiles,
|
availableProfiles,
|
||||||
previewData,
|
previewData,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
isRefreshingGitMounts,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
error,
|
error,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
@@ -104,6 +107,7 @@ export const ConfigProfilesMobileView = ({
|
|||||||
onUpdateMountFile,
|
onUpdateMountFile,
|
||||||
onRemoveMountFile,
|
onRemoveMountFile,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onRefreshGitMounts,
|
||||||
onClosePreview,
|
onClosePreview,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -363,6 +367,23 @@ export const ConfigProfilesMobileView = ({
|
|||||||
onDelete={handleDeleteClick}
|
onDelete={handleDeleteClick}
|
||||||
>
|
>
|
||||||
<div className="mobile-detail-actions-extra">
|
<div className="mobile-detail-actions-extra">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={isRefreshingGitMounts}
|
||||||
|
onClick={() => onRefreshGitMounts(selectedProfile.id)}
|
||||||
|
>
|
||||||
|
{isRefreshingGitMounts ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Refreshing Git mounts...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="refresh" size="sm" /> Refresh Git mounts
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
deleteConfigProfile,
|
deleteConfigProfile,
|
||||||
listConfigProfiles,
|
listConfigProfiles,
|
||||||
previewConfigProfile,
|
previewConfigProfile,
|
||||||
|
refreshConfigProfileGitMounts,
|
||||||
updateConfigProfile,
|
updateConfigProfile,
|
||||||
updateProfileIncludes,
|
updateProfileIncludes,
|
||||||
type ConfigProfile,
|
type ConfigProfile,
|
||||||
@@ -42,6 +43,7 @@ export const useConfigProfiles = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
|
||||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
|
const [isRefreshingGitMounts, setIsRefreshingGitMounts] = useState(false);
|
||||||
const [formData, setFormData] =
|
const [formData, setFormData] =
|
||||||
useState<CreateConfigProfileRequest>(defaultForm);
|
useState<CreateConfigProfileRequest>(defaultForm);
|
||||||
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
||||||
@@ -273,6 +275,30 @@ export const useConfigProfiles = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
|
||||||
|
if (isRefreshingGitMounts) return false;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsRefreshingGitMounts(true);
|
||||||
|
try {
|
||||||
|
const result = await refreshConfigProfileGitMounts(id);
|
||||||
|
const refreshed = result.refresh_outcomes.filter(
|
||||||
|
(outcome) => outcome.status === "refreshed",
|
||||||
|
);
|
||||||
|
setError(
|
||||||
|
refreshed.length
|
||||||
|
? `Refreshed Git mounts for ${refreshed.length} running instance${refreshed.length === 1 ? "" : "s"}.`
|
||||||
|
: "No running instances currently use this profile's Git mounts.",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsRefreshingGitMounts(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePreview = async (id: string) => {
|
const handlePreview = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
setPreviewingId(id);
|
setPreviewingId(id);
|
||||||
@@ -425,6 +451,7 @@ export const useConfigProfiles = () => {
|
|||||||
error,
|
error,
|
||||||
previewData,
|
previewData,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
isRefreshingGitMounts,
|
||||||
formData,
|
formData,
|
||||||
includedProfileIds,
|
includedProfileIds,
|
||||||
dragOverIndex,
|
dragOverIndex,
|
||||||
@@ -434,6 +461,7 @@ export const useConfigProfiles = () => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
|
handleRefreshGitMounts,
|
||||||
updateFormField,
|
updateFormField,
|
||||||
addEnvVar,
|
addEnvVar,
|
||||||
updateEnvVar,
|
updateEnvVar,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
error,
|
error,
|
||||||
previewData,
|
previewData,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
isRefreshingGitMounts,
|
||||||
formData,
|
formData,
|
||||||
includedProfileIds,
|
includedProfileIds,
|
||||||
dragOverIndex,
|
dragOverIndex,
|
||||||
@@ -32,6 +33,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
|
handleRefreshGitMounts,
|
||||||
updateFormField,
|
updateFormField,
|
||||||
addEnvVar,
|
addEnvVar,
|
||||||
updateEnvVar,
|
updateEnvVar,
|
||||||
@@ -110,6 +112,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
availableProfiles={availableProfilesForInclude()}
|
availableProfiles={availableProfilesForInclude()}
|
||||||
previewData={previewData}
|
previewData={previewData}
|
||||||
previewingId={previewingId}
|
previewingId={previewingId}
|
||||||
|
isRefreshingGitMounts={isRefreshingGitMounts}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
error={error}
|
error={error}
|
||||||
onViewChange={setMobileView}
|
onViewChange={setMobileView}
|
||||||
@@ -135,6 +138,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
onUpdateMountFile={updateMountFile}
|
onUpdateMountFile={updateMountFile}
|
||||||
onRemoveMountFile={removeMountFile}
|
onRemoveMountFile={removeMountFile}
|
||||||
onPreview={handlePreview}
|
onPreview={handlePreview}
|
||||||
|
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
|
||||||
onClosePreview={() => setPreviewData(null)}
|
onClosePreview={() => setPreviewData(null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -167,6 +171,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
previewData={previewData}
|
previewData={previewData}
|
||||||
previewingId={previewingId}
|
previewingId={previewingId}
|
||||||
|
isRefreshingGitMounts={isRefreshingGitMounts}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
toolTypes={toolTypes}
|
toolTypes={toolTypes}
|
||||||
availableProfiles={availableProfilesForInclude()}
|
availableProfiles={availableProfilesForInclude()}
|
||||||
@@ -176,6 +181,9 @@ export const ConfigProfilesPage = () => {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
|
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
|
||||||
|
onRefreshGitMounts={() =>
|
||||||
|
selectedProfile && handleRefreshGitMounts(selectedProfile.id)
|
||||||
|
}
|
||||||
onAddInclude={addInclude}
|
onAddInclude={addInclude}
|
||||||
onRemoveInclude={removeInclude}
|
onRemoveInclude={removeInclude}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ After implementing configurable tool container home directories, new `pi-agent`
|
|||||||
4. `npm_global` packages are installed with `RUN npm install -g ...` as root into the system npm prefix, so the non-root container user cannot update them.
|
4. `npm_global` packages are installed with `RUN npm install -g ...` as root into the system npm prefix, so the non-root container user cannot update them.
|
||||||
5. Once the repo mount moves out of `/workspace`, the generated `/workspace` compatibility symlink is created in the image as root. The non-root entrypoint cannot replace it (write permission is required on `/`), so container startup fails.
|
5. Once the repo mount moves out of `/workspace`, the generated `/workspace` compatibility symlink is created in the image as root. The non-root entrypoint cannot replace it (write permission is required on `/`), so container startup fails.
|
||||||
6. Older images baked a literal `{{WORKSPACE_NAME}}` directory into `/home/user`, which survives alongside the real repo-named mount directory.
|
6. Older images baked a literal `{{WORKSPACE_NAME}}` directory into `/home/user`, which survives alongside the real repo-named mount directory.
|
||||||
|
7. Config-profile file mounts use canonical profile storage owned by the API process. A non-root container user therefore cannot write to writable bind mounts. When a directory-level profile mount and a Git mount share or nest under the same target, Docker bind mounting masks the earlier source rather than merging their files.
|
||||||
|
|
||||||
## Fix
|
## Fix
|
||||||
|
|
||||||
@@ -40,7 +41,9 @@ After implementing configurable tool container home directories, new `pi-agent`
|
|||||||
6. Remove the explicit repo mount from the built-in `pi-agent` manifest so the repo mount is synthesized by `compile_compose` rather than depending on tool config. Add a follow-up Alembic data migration that strips the `source_type: repo` mount from the manifest.
|
6. Remove the explicit repo mount from the built-in `pi-agent` manifest so the repo mount is synthesized by `compile_compose` rather than depending on tool config. Add a follow-up Alembic data migration that strips the `source_type: repo` mount from the manifest.
|
||||||
7. Add `_get_repository_mount_name()` helper. When the instance is bound to a workspace, the helper returns the basename of `workspace.path`. For legacy repo-only instances it falls back to parsing the remote URL like `git clone` would, then to the user-provided repository name.
|
7. Add `_get_repository_mount_name()` helper. When the instance is bound to a workspace, the helper returns the basename of `workspace.path`. For legacy repo-only instances it falls back to parsing the remote URL like `git clone` would, then to the user-provided repository name.
|
||||||
8. Switch workspace storage layout to `/data/working-copies/{workspace_id}/{repo_name}/` so `git clone` creates the repo-named directory naturally, making `workspace.path.basename` the correct container mount name. This replaces the previous `/data/working-copies/{repo_id}/{workspace_name}/` layout.
|
8. Switch workspace storage layout to `/data/working-copies/{workspace_id}/{repo_name}/` so `git clone` creates the repo-named directory naturally, making `workspace.path.basename` the correct container mount name. This replaces the previous `/data/working-copies/{repo_id}/{workspace_name}/` layout.
|
||||||
9. Update unit tests for the new behavior.
|
9. Stage every config-profile bind-mount source into the instance directory before compose generation. This makes writable mounts user-owned without changing the shared canonical profile source.
|
||||||
|
10. Replace overlapping profile and Git bind mounts with a per-instance composite source. The composite copies Git content first and profile content second, preserving Git siblings while allowing profile files to override matching paths; it is then chowned with the other staged mounts. This removes duplicate/nested Docker mounts rather than relying on mount order to merge them.
|
||||||
|
11. Update unit tests for the new behavior.
|
||||||
|
|
||||||
## Affected files
|
## Affected files
|
||||||
|
|
||||||
|
|||||||
@@ -12,5 +12,8 @@
|
|||||||
- [x] Remove compose-level `user: 0:0` override so entrypoint can drop privileges
|
- [x] Remove compose-level `user: 0:0` override so entrypoint can drop privileges
|
||||||
- [x] Pass manifest-declared container user to terminal sessions via `docker exec --user`
|
- [x] Pass manifest-declared container user to terminal sessions via `docker exec --user`
|
||||||
- [x] Update unit tests for container user/terminal changes
|
- [x] Update unit tests for container user/terminal changes
|
||||||
|
- [x] Stage profile bind mounts per instance so writable sources are owned by the container user
|
||||||
|
- [x] Composite overlapping profile and Git mounts into one per-instance bind source
|
||||||
|
- [x] Add regression tests for mount composition, ownership staging, and mount order
|
||||||
- [ ] Run quality gates for container user/terminal changes
|
- [ ] Run quality gates for container user/terminal changes
|
||||||
- [ ] Commit and push
|
- [ ] Commit and push
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Live Git Config Mount Refresh
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Git-backed Config Profile mounts are currently cloned per instance. Their content cannot be refreshed consistently for running sessions, and writable container mounts can dirty the checkout.
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
Move Git Config Profile mounts to profile-scoped canonical host clones. Bind the already-mounted directory sources read-only into compatible instances and refresh a stable branch/ref checkout in place under a per-clone lock.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Canonical clone identity: profile, normalized remote, requested ref, and credential scope.
|
||||||
|
- In-place refresh for existing directory mounts only.
|
||||||
|
- Explicit outcomes for live refresh, restart-required topology changes, and refresh failures.
|
||||||
|
- Read-only container Git config mounts.
|
||||||
|
- An in-progress indicator that prevents duplicate refresh requests in desktop and mobile Config Profile views.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Writable shared Git configuration mounts.
|
||||||
|
- Global cross-user clone sharing.
|
||||||
|
- Live mount-topology changes, direct-file mappings, or glob match-set changes.
|
||||||
|
- Atomic all-files revision switching for processes already reading the mount.
|
||||||
|
- Automatic refresh of an editor buffer that has already loaded a mounted file.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Design: Live Git Config Mount Refresh
|
||||||
|
|
||||||
|
## Canonical source
|
||||||
|
|
||||||
|
Each selected Config Profile owns canonical Git clone directories beneath:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<instance-root>/config-profiles/<profile-id>/git-mounts/<identity>/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
`identity` is a stable hash of normalized remote URL, requested ref, and credential scope. Sources are deliberately profile-scoped; clones are never shared across users.
|
||||||
|
|
||||||
|
## Runtime behavior
|
||||||
|
|
||||||
|
1. Resolve Git mounts and map them to canonical sources.
|
||||||
|
2. Acquire an exclusive lock for clone, fetch, ref resolution, and checkout.
|
||||||
|
3. Clone into a temporary sibling, then rename on initial creation.
|
||||||
|
4. For refresh, fetch and update the existing working tree in place.
|
||||||
|
5. Bind directory mappings read-only. Existing containers see changed directory contents without recreation.
|
||||||
|
6. When profile and Git mount paths overlap, the instance-local composite source must be synchronized in place during refresh; replacing its root directory would leave a running bind mount attached to the old inode.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- URL/ref/source/target/mode changes, direct-file mappings, and changed glob result sets return `restart_required`.
|
||||||
|
- Refresh failure is reported without mutating a known-good checkout.
|
||||||
|
- No non-Git profile content may be copied into a Git checkout; overlapping targets are rejected or reported.
|
||||||
|
- Containers must not write to shared Git mount sources.
|
||||||
|
- A browser editor that already has a file open is not a filesystem watcher; the user must reload that editor buffer after the mounted source changes.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Host Git operations use only an authorized server-side credential source. Credentials are not part of the mounted checkout and are not exposed to containers.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Live Git Config Mount Refresh — Tasks
|
||||||
|
|
||||||
|
- [x] Add canonical profile-scoped Git clone source planning and clone identity helpers.
|
||||||
|
- [x] Make Git Config Profile mounts read-only and prevent profile-content copy into Git sources.
|
||||||
|
- [x] Add lock-protected clone/fetch/ref checkout refresh that preserves a known-good checkout on failure.
|
||||||
|
- [x] Add save/refresh outcomes for live refresh, restart-required topology, and failures.
|
||||||
|
- [x] Add an in-progress desktop/mobile indicator that disables duplicate Git-mount refresh requests.
|
||||||
|
- [ ] Synchronize affected instance-local composite mounts in place so live refresh reaches running containers.
|
||||||
|
- [ ] Add focused resolver/service/API/frontend tests.
|
||||||
|
- [x] Run available verification and document skipped checks.
|
||||||
|
|
||||||
|
## Verification Notes
|
||||||
|
|
||||||
|
- Passed: frontend production build and Python compilation for changed backend modules.
|
||||||
|
- Skipped: backend pytest and Ruff are unavailable in this environment; Docker/manual live-session checks were not approved.
|
||||||
|
- Known tooling limitation: project-map patching fails before execution because its runtime sends an unsupported `temperature` parameter.
|
||||||
Reference in New Issue
Block a user