Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc52353b2e | |||
| a63a983116 | |||
| 886c863260 | |||
| 3c25fffd49 | |||
| add7c1b500 |
@@ -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
|
||||||
@@ -9,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
|
from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
|
||||||
from src.schemas.config import (
|
from src.schemas.config import (
|
||||||
ConfigProfileCreate,
|
ConfigProfileCreate,
|
||||||
ConfigProfileIncludeUpdate,
|
ConfigProfileIncludeUpdate,
|
||||||
@@ -43,6 +44,34 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _running_profile_outcomes(
|
||||||
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Report running instances that must restart to adopt a profile revision."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance).where(ToolInstance.status == "running")
|
||||||
|
)
|
||||||
|
outcomes: list[dict[str, str]] = []
|
||||||
|
for instance in result.scalars().all():
|
||||||
|
if instance.selected_config_profile_id is None:
|
||||||
|
continue
|
||||||
|
resolved = await resolve_profile(session, instance.selected_config_profile_id)
|
||||||
|
dependencies = {resolved.profile_id} | {
|
||||||
|
uuid.UUID(item["id"])
|
||||||
|
for item in resolved.included_profiles
|
||||||
|
if item.get("id")
|
||||||
|
}
|
||||||
|
if profile_id in dependencies:
|
||||||
|
outcomes.append(
|
||||||
|
{
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"status": "restart_required",
|
||||||
|
"reason": "Existing instance must restart to adopt shared profile mounts",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return outcomes
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ConfigProfileResponse])
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
@@ -140,8 +169,51 @@ async def update_config_profile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
profile = await update_profile(session, profile, data)
|
profile = await update_profile(session, profile, data)
|
||||||
|
response = profile_to_response(profile)
|
||||||
|
response["refresh_outcomes"] = await _running_profile_outcomes(session, profile.id)
|
||||||
logger.debug("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return profile_to_response(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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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)
|
||||||
@@ -161,6 +233,16 @@ async def delete_config_profile(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
outcomes = await _running_profile_outcomes(session, profile.id)
|
||||||
|
if outcomes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": "Profile is still used by running instances",
|
||||||
|
"outcomes": outcomes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,12 @@ class ConfigProfileIncludeUpdate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileRefreshOutcome(BaseModel):
|
||||||
|
instance_id: str
|
||||||
|
status: str
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileResponse(BaseModel):
|
class ConfigProfileResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
@@ -256,6 +262,7 @@ class ConfigProfileResponse(BaseModel):
|
|||||||
includes: list[dict]
|
includes: list[dict]
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
refresh_outcomes: list[ConfigProfileRefreshOutcome] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class DefaultProfilesUpdate(BaseModel):
|
class DefaultProfilesUpdate(BaseModel):
|
||||||
|
|||||||
@@ -5,10 +5,125 @@ import logging
|
|||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.models import ToolType
|
from src.models import ToolDefinitionManifest, ToolType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# All supported built-in tools run their long-lived process with these IDs.
|
||||||
|
# Canonical writable Config Profile mounts can therefore be shared without
|
||||||
|
# per-instance ownership changes.
|
||||||
|
BUILTIN_USER_UID = 1000
|
||||||
|
BUILTIN_USER_GID = 1000
|
||||||
|
|
||||||
|
BUILTIN_TOOL_TYPES = [
|
||||||
|
{
|
||||||
|
"name": "code-server",
|
||||||
|
"display_name": "VS Code Server",
|
||||||
|
"description": "VS Code running in the browser via code-server",
|
||||||
|
"category": "editor",
|
||||||
|
"interface_type": "web",
|
||||||
|
"compose_template": """version: "3.8"
|
||||||
|
services:
|
||||||
|
code-server:
|
||||||
|
image: lscr.io/linuxserver/code-server:latest
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
- TZ=Europe/London
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/config/workspace
|
||||||
|
ports:
|
||||||
|
- "8443:8443"
|
||||||
|
restart: 'no'""",
|
||||||
|
"default_port": 8443,
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "jupyter-notebook",
|
||||||
|
"display_name": "Jupyter Notebook",
|
||||||
|
"description": "Jupyter Lab for interactive development",
|
||||||
|
"category": "notebook",
|
||||||
|
"interface_type": "web",
|
||||||
|
"default_port": 8888,
|
||||||
|
"compose_template": """version: "3.8"
|
||||||
|
services:
|
||||||
|
jupyter:
|
||||||
|
image: jupyter/scipy-notebook:latest
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
environment:
|
||||||
|
- JUPYTER_ENABLE_LAB=yes
|
||||||
|
- NB_UID=1000
|
||||||
|
- NB_GID=1000
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/home/jovyan/work
|
||||||
|
ports:
|
||||||
|
- "8888:8888"
|
||||||
|
restart: 'no'""",
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "opencode",
|
||||||
|
"display_name": "OpenCode",
|
||||||
|
"description": "AI coding assistant - run opencode in terminal",
|
||||||
|
"category": "ai-assistant",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"default_port": 3000,
|
||||||
|
"compose_template": """version: "3.8"
|
||||||
|
services:
|
||||||
|
opencode:
|
||||||
|
image: node:20-slim
|
||||||
|
container_name: {{TOOL_NAME}}
|
||||||
|
working_dir: /home/node/{{WORKSPACE_NAME}}
|
||||||
|
volumes:
|
||||||
|
- {{REPO_PATH}}:/home/node/{{WORKSPACE_NAME}}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
command: >
|
||||||
|
sh -ec "apt-get update && apt-get install -y git ca-certificates &&
|
||||||
|
npm install -g opencode-ai &&
|
||||||
|
exec setpriv --reuid=node --regid=node --init-groups opencode server"
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
restart: 'no'""",
|
||||||
|
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _standardize_builtin_manifest_user(manifest: dict) -> bool:
|
||||||
|
"""Set the built-in manifest user to the shared UID/GID in place.
|
||||||
|
|
||||||
|
The helper deliberately recognizes only Headquarter's conventional
|
||||||
|
``user`` account so it cannot rewrite a future custom tool definition.
|
||||||
|
"""
|
||||||
|
user = manifest.get("user")
|
||||||
|
if not isinstance(user, dict) or user.get("name") != "user":
|
||||||
|
return False
|
||||||
|
|
||||||
|
changed = user.get("uid") != BUILTIN_USER_UID or user.get("gid") != BUILTIN_USER_GID
|
||||||
|
if changed:
|
||||||
|
user["uid"] = BUILTIN_USER_UID
|
||||||
|
user["gid"] = BUILTIN_USER_GID
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def _standardize_pi_agent_manifest(session) -> None:
|
||||||
|
"""Bring the built-in Pi Agent manifest in line with shared mount IDs."""
|
||||||
|
manifest_definition = await session.scalar(
|
||||||
|
select(ToolDefinitionManifest).where(
|
||||||
|
ToolDefinitionManifest.name == "pi-agent",
|
||||||
|
ToolDefinitionManifest.created_by_id.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if manifest_definition is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest = dict(manifest_definition.manifest)
|
||||||
|
if _standardize_builtin_manifest_user(manifest):
|
||||||
|
manifest_definition.manifest = manifest
|
||||||
|
logger.info("Standardized built-in Pi Agent user to 1000:1000")
|
||||||
|
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
async def _table_exists(session, table_name: str) -> bool:
|
||||||
"""Check if a table exists in the database."""
|
"""Check if a table exists in the database."""
|
||||||
@@ -45,88 +160,9 @@ async def seed_builtin_tool_types():
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
builtin_types = [
|
await _standardize_pi_agent_manifest(session)
|
||||||
{
|
|
||||||
"name": "code-server",
|
|
||||||
"display_name": "VS Code Server",
|
|
||||||
"description": "VS Code running in the browser via code-server",
|
|
||||||
"category": "editor",
|
|
||||||
"interface_type": "web",
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
code-server:
|
|
||||||
image: lscr.io/linuxserver/code-server:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
- TZ=Europe/London
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/config/workspace
|
|
||||||
ports:
|
|
||||||
- "8443:8443"
|
|
||||||
restart: 'no'""",
|
|
||||||
"default_port": 8443,
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "jupyter-notebook",
|
|
||||||
"display_name": "Jupyter Notebook",
|
|
||||||
"description": "Jupyter Lab for interactive development",
|
|
||||||
"category": "notebook",
|
|
||||||
"interface_type": "web",
|
|
||||||
"default_port": 8888,
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
jupyter:
|
|
||||||
image: jupyter/scipy-notebook:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- JUPYTER_ENABLE_LAB=yes
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/home/jovyan/work
|
|
||||||
ports:
|
|
||||||
- "8888:8888"
|
|
||||||
restart: 'no'""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "opencode",
|
|
||||||
"display_name": "OpenCode",
|
|
||||||
"description": "AI coding assistant - run opencode in terminal",
|
|
||||||
"category": "ai-assistant",
|
|
||||||
"interface_type": "terminal",
|
|
||||||
"default_port": 3000,
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
opencode:
|
|
||||||
image: node:20-slim
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
working_dir: /home/user/{{WORKSPACE_NAME}}
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/home/user/{{WORKSPACE_NAME}}
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
command: >
|
|
||||||
sh -c "set -x &&
|
|
||||||
apt-get update && apt-get install -y git ca-certificates &&
|
|
||||||
echo 'Installing opencode...' &&
|
|
||||||
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
|
||||||
which opencode || echo 'ERROR: opencode not in PATH' &&
|
|
||||||
npm bin -g &&
|
|
||||||
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
|
||||||
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
|
|
||||||
echo 'cd /home/user/{{WORKSPACE_NAME}}' >> /root/.bashrc &&
|
|
||||||
echo 'OpenCode installation complete' &&
|
|
||||||
exec tail -f /dev/null"
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
restart: 'no'""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for tool_data in builtin_types:
|
for tool_data in BUILTIN_TOOL_TYPES:
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(ToolType).where(ToolType.name == tool_data["name"])
|
select(ToolType).where(ToolType.name == tool_data["name"])
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ and cycle protection.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -481,6 +482,7 @@ def apply_resolved_profile(
|
|||||||
instance_dir: str,
|
instance_dir: str,
|
||||||
resolved: ResolvedProfile,
|
resolved: ResolvedProfile,
|
||||||
home_dir: str = "/root",
|
home_dir: str = "/root",
|
||||||
|
working_dir: str | None = None,
|
||||||
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||||
"""Apply a resolved profile to an instance directory.
|
"""Apply a resolved profile to an instance directory.
|
||||||
|
|
||||||
@@ -489,6 +491,8 @@ def apply_resolved_profile(
|
|||||||
Args:
|
Args:
|
||||||
instance_dir: Path to the instance directory.
|
instance_dir: Path to the instance directory.
|
||||||
resolved: The resolved profile.
|
resolved: The resolved profile.
|
||||||
|
home_dir: Container home directory used for path expansion.
|
||||||
|
working_dir: Container working directory for top-level profile files.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
||||||
@@ -501,49 +505,57 @@ def apply_resolved_profile(
|
|||||||
|
|
||||||
instance_path = Path(instance_dir)
|
instance_path = Path(instance_dir)
|
||||||
env_vars = dict(resolved.env_vars)
|
env_vars = dict(resolved.env_vars)
|
||||||
files = dict(resolved.files)
|
working_dir = working_dir or home_dir
|
||||||
volume_mounts = []
|
volume_mounts = []
|
||||||
|
|
||||||
# Write profile files to instance directory
|
# Profile content belongs to the profile, not an individual tool instance.
|
||||||
for file_path, content in files.items():
|
# Keeping it beside the instance root gives every compatible instance the
|
||||||
full_path = instance_path / file_path
|
# same host source while retaining the existing instance storage setting.
|
||||||
try:
|
profile_dir = instance_path.parent / "config-profiles" / str(resolved.profile_id)
|
||||||
full_path.resolve().relative_to(instance_path.resolve())
|
files_dir = profile_dir / "files"
|
||||||
except ValueError:
|
mounts_dir = profile_dir / "mounts"
|
||||||
logger.warning(
|
|
||||||
"Profile file path escapes instance directory: %s", file_path
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
# Stage mount directories and prepare directory-level volume mounts.
|
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
|
||||||
# Each ResolvedMount targets a container directory; we stage all of its
|
path = root / relative_path
|
||||||
# files under a single host directory and bind-mount that directory. This
|
try:
|
||||||
# keeps the target directory writable by the container user, instead of
|
path.resolve().relative_to(root.resolve())
|
||||||
# having Docker create a root-owned parent directory when only individual
|
except ValueError:
|
||||||
# files are mounted.
|
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
|
||||||
|
return None
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", encoding="utf-8", dir=path.parent, delete=False
|
||||||
|
) as temporary_file:
|
||||||
|
temporary_file.write(content)
|
||||||
|
temporary_path = Path(temporary_file.name)
|
||||||
|
temporary_path.replace(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Top-level profile files are individual bind mounts under the working
|
||||||
|
# directory. They therefore cannot mask the workspace directory itself.
|
||||||
|
for file_path, content in resolved.files.items():
|
||||||
|
canonical_file = write_canonical_file(files_dir, file_path, content)
|
||||||
|
if canonical_file is None:
|
||||||
|
continue
|
||||||
|
volume_mounts.append(
|
||||||
|
{
|
||||||
|
"source": str(canonical_file),
|
||||||
|
"target": os.path.normpath(os.path.join(working_dir, file_path)),
|
||||||
|
"type": "bind",
|
||||||
|
"readonly": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Explicit profile mounts remain directory-level bind mounts, but use the
|
||||||
|
# same profile-scoped canonical source for every instance.
|
||||||
for mount in resolved.mounts.values():
|
for mount in resolved.mounts.values():
|
||||||
if not mount.files:
|
if not mount.files:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expanded_target = os.path.normpath(
|
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
|
||||||
expand_container_path(mount.target, home_dir)
|
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
|
||||||
)
|
|
||||||
mount_dir = (
|
|
||||||
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
|
|
||||||
)
|
|
||||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for file_path, content in mount.files.items():
|
for file_path, content in mount.files.items():
|
||||||
full_path = mount_dir / file_path
|
write_canonical_file(mount_dir, file_path, content)
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(mount_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Mount file path escapes mount directory: %s", file_path)
|
|
||||||
continue
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
volume_mounts.append(
|
volume_mounts.append(
|
||||||
{
|
{
|
||||||
@@ -554,7 +566,9 @@ def apply_resolved_profile(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return env_vars, files, volume_mounts, resolved.runtime_hints
|
# Files are now mounted directly from canonical storage, not copied into
|
||||||
|
# the instance directory for write_config_files().
|
||||||
|
return env_vars, {}, volume_mounts, resolved.runtime_hints
|
||||||
|
|
||||||
|
|
||||||
def expand_container_path(path: str, home_dir: str) -> str:
|
def expand_container_path(path: str, home_dir: str) -> 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
|
||||||
@@ -158,57 +160,23 @@ 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],
|
||||||
) -> 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 sources are shared, read-only canonical checkouts. Profile
|
||||||
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile
|
content must never be copied into one because doing so dirties the
|
||||||
would mask the cloned repository. Instead, copy the profile files into
|
checkout and leaks one profile's content to every instance using it.
|
||||||
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 profile_mount in profile_mounts:
|
||||||
for pvol in profile_mounts:
|
profile_target = profile_mount.get("target", "")
|
||||||
p_source = pvol.get("source", "")
|
for git_mount in git_mount_volumes:
|
||||||
p_target = pvol.get("target", "")
|
if _relative_under(git_mount.get("target", ""), profile_target) is not None:
|
||||||
if not p_source or not os.path.exists(p_source):
|
logger.warning(
|
||||||
remaining.append(pvol)
|
"Config profile mount %s overlaps Git mount %s; keeping sources isolated",
|
||||||
continue
|
profile_target,
|
||||||
|
git_mount.get("target"),
|
||||||
merged = False
|
)
|
||||||
for gvol in git_mount_volumes:
|
break
|
||||||
g_source = gvol.get("source", "")
|
return profile_mounts
|
||||||
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(
|
async def resolve_git_mounts(
|
||||||
@@ -227,12 +195,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 +239,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 +430,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 +452,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 +473,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:
|
||||||
@@ -1402,17 +1412,22 @@ async def start_tool_instance(
|
|||||||
resolved = await resolve_profile(
|
resolved = await resolve_profile(
|
||||||
session, instance.selected_config_profile_id
|
session, instance.selected_config_profile_id
|
||||||
)
|
)
|
||||||
|
# Profile working-directory hints determine where individual
|
||||||
|
# canonical profile files are bind-mounted at container creation.
|
||||||
|
profile_hints = resolved.runtime_hints
|
||||||
|
if profile_hints.get("working_directory"):
|
||||||
|
working_directory = expand_container_path(
|
||||||
|
profile_hints["working_directory"], home_dir
|
||||||
|
)
|
||||||
profile_env, profile_files, profile_mounts, profile_hints = (
|
profile_env, profile_files, profile_mounts, profile_hints = (
|
||||||
apply_resolved_profile(instance_dir, resolved, home_dir)
|
apply_resolved_profile(
|
||||||
|
instance_dir, resolved, home_dir, working_directory
|
||||||
|
)
|
||||||
)
|
)
|
||||||
# Profile hints override the manifest/tool defaults, and git mounts
|
# Profile hints override the manifest/tool defaults, and git mounts
|
||||||
# need the final working directory to resolve relative target paths.
|
# need the final working directory to resolve relative target paths.
|
||||||
if profile_hints.get("start_command"):
|
if profile_hints.get("start_command"):
|
||||||
start_command = profile_hints["start_command"]
|
start_command = profile_hints["start_command"]
|
||||||
if profile_hints.get("working_directory"):
|
|
||||||
working_directory = expand_container_path(
|
|
||||||
profile_hints["working_directory"], home_dir
|
|
||||||
)
|
|
||||||
if profile_hints.get("port_override"):
|
if profile_hints.get("port_override"):
|
||||||
port_override = profile_hints["port_override"]
|
port_override = profile_hints["port_override"]
|
||||||
env_vars.update(profile_env)
|
env_vars.update(profile_env)
|
||||||
@@ -1476,7 +1491,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:
|
||||||
@@ -2189,7 +2212,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,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Tests for the shared non-root user used by built-in tools."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.seeds.builtin_tool_types import (
|
||||||
|
BUILTIN_TOOL_TYPES,
|
||||||
|
BUILTIN_USER_GID,
|
||||||
|
BUILTIN_USER_UID,
|
||||||
|
_standardize_builtin_manifest_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_compose_templates_use_shared_runtime_ids() -> None:
|
||||||
|
"""Every legacy built-in Compose tool declares the shared UID/GID."""
|
||||||
|
templates = {
|
||||||
|
str(tool["name"]): str(tool["compose_template"]) for tool in BUILTIN_TOOL_TYPES
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "- PUID=1000" in templates["code-server"]
|
||||||
|
assert "- PGID=1000" in templates["code-server"]
|
||||||
|
assert "- NB_UID=1000" in templates["jupyter-notebook"]
|
||||||
|
assert "- NB_GID=1000" in templates["jupyter-notebook"]
|
||||||
|
assert "setpriv --reuid=node --regid=node --init-groups" in templates["opencode"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_builtin_user_manifest_is_standardized() -> None:
|
||||||
|
"""The startup migration cannot rewrite a future custom tool user."""
|
||||||
|
builtin_manifest = {"user": {"name": "user", "uid": 1001, "gid": 1001}}
|
||||||
|
custom_manifest = {"user": {"name": "custom", "uid": 2000, "gid": 2000}}
|
||||||
|
|
||||||
|
assert _standardize_builtin_manifest_user(builtin_manifest)
|
||||||
|
assert builtin_manifest["user"] == {
|
||||||
|
"name": "user",
|
||||||
|
"uid": BUILTIN_USER_UID,
|
||||||
|
"gid": BUILTIN_USER_GID,
|
||||||
|
}
|
||||||
|
assert not _standardize_builtin_manifest_user(custom_manifest)
|
||||||
|
assert custom_manifest["user"] == {"name": "custom", "uid": 2000, "gid": 2000}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_image_templates_define_shared_ids() -> None:
|
||||||
|
"""Project-owned image templates explicitly create or map UID/GID 1000."""
|
||||||
|
root = Path(__file__).resolve().parents[4]
|
||||||
|
sources = {
|
||||||
|
name: (root / "tool-images" / name).read_text()
|
||||||
|
for name in (
|
||||||
|
"base.dockerfile",
|
||||||
|
"opencode.dockerfile",
|
||||||
|
"pi-agent.dockerfile",
|
||||||
|
"code-server.dockerfile",
|
||||||
|
"jupyter.dockerfile",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in ("base.dockerfile", "opencode.dockerfile", "pi-agent.dockerfile"):
|
||||||
|
assert "groupadd -g 1000 user" in sources[name]
|
||||||
|
assert "useradd -m -u 1000 -g 1000" in sources[name]
|
||||||
|
|
||||||
|
assert "PUID=1000" in sources["code-server.dockerfile"]
|
||||||
|
assert "PGID=1000" in sources["code-server.dockerfile"]
|
||||||
|
assert "NB_UID=1000" in sources["jupyter.dockerfile"]
|
||||||
|
assert "NB_GID=1000" in sources["jupyter.dockerfile"]
|
||||||
@@ -532,6 +532,54 @@ class TestApplyResolvedProfile:
|
|||||||
assert Path(volumes[0]["source"]).name == "workspace_x_y"
|
assert Path(volumes[0]["source"]).name == "workspace_x_y"
|
||||||
assert (Path(volumes[0]["source"]) / "z.json").exists()
|
assert (Path(volumes[0]["source"]) / "z.json").exists()
|
||||||
|
|
||||||
|
def test_top_level_files_use_profile_scoped_direct_bind_mounts(self, tmp_path) -> None:
|
||||||
|
"""Top-level files are shared safely without mounting over a workspace."""
|
||||||
|
profile_id = uuid.uuid4()
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
profile_name="test",
|
||||||
|
files={".tool/config.toml": "setting = true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
instance_root = tmp_path / "instances"
|
||||||
|
_, files, volumes, _ = apply_resolved_profile(
|
||||||
|
str(instance_root / "instance-a"),
|
||||||
|
resolved,
|
||||||
|
working_dir="/workspace/project",
|
||||||
|
)
|
||||||
|
|
||||||
|
canonical_file = (
|
||||||
|
instance_root / "config-profiles" / str(profile_id) / "files" / ".tool" / "config.toml"
|
||||||
|
)
|
||||||
|
assert files == {}
|
||||||
|
assert volumes == [
|
||||||
|
{
|
||||||
|
"source": str(canonical_file),
|
||||||
|
"target": "/workspace/project/.tool/config.toml",
|
||||||
|
"type": "bind",
|
||||||
|
"readonly": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert canonical_file.read_text() == "setting = true"
|
||||||
|
|
||||||
|
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
|
||||||
|
"""Different instance paths resolve a profile to one canonical source."""
|
||||||
|
profile_id = uuid.uuid4()
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
profile_name="test",
|
||||||
|
mounts={"/app": ResolvedMount(target="/app", mode="rw", files={"config.ini": "x"})},
|
||||||
|
)
|
||||||
|
|
||||||
|
instance_root = tmp_path / "instances"
|
||||||
|
_, _, first_volumes, _ = apply_resolved_profile(str(instance_root / "instance-a"), resolved)
|
||||||
|
_, _, second_volumes, _ = apply_resolved_profile(str(instance_root / "instance-b"), resolved)
|
||||||
|
|
||||||
|
assert first_volumes[0]["source"] == second_volumes[0]["source"]
|
||||||
|
assert first_volumes[0]["source"] == str(
|
||||||
|
instance_root / "config-profiles" / str(profile_id) / "mounts" / "app"
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
||||||
"""A mount with no files should not produce any volume entries."""
|
"""A mount with no files should not produce any volume entries."""
|
||||||
resolved = ResolvedProfile(
|
resolved = ResolvedProfile(
|
||||||
@@ -579,7 +627,7 @@ class TestApplyResolvedProfile:
|
|||||||
|
|
||||||
assert len(volumes) == 1
|
assert len(volumes) == 1
|
||||||
assert volumes[0]["target"] == "/etc/app"
|
assert volumes[0]["target"] == "/etc/app"
|
||||||
assert volumes[0].get("readonly") is True
|
assert volumes[0].get("readonly")
|
||||||
|
|
||||||
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
|
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
|
||||||
"""A mount with mode 'rw' should not set readonly on the volume entry."""
|
"""A mount with mode 'rw' should not set readonly on the volume entry."""
|
||||||
@@ -598,7 +646,7 @@ class TestApplyResolvedProfile:
|
|||||||
|
|
||||||
assert len(volumes) == 1
|
assert len(volumes) == 1
|
||||||
assert volumes[0]["target"] == "/app"
|
assert volumes[0]["target"] == "/app"
|
||||||
assert volumes[0].get("readonly") is False
|
assert not volumes[0].get("readonly")
|
||||||
|
|
||||||
|
|
||||||
class TestCheckIncludeCycle:
|
class TestCheckIncludeCycle:
|
||||||
|
|||||||
@@ -80,12 +80,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 +94,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"
|
||||||
@@ -128,10 +125,10 @@ class TestCloneGitRepo:
|
|||||||
class TestStackProfileMountsWithGitMounts:
|
class TestStackProfileMountsWithGitMounts:
|
||||||
"""Tests for _stack_profile_mounts_with_git_mounts."""
|
"""Tests for _stack_profile_mounts_with_git_mounts."""
|
||||||
|
|
||||||
def test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) -> None:
|
def test_exact_overlap_keeps_profile_files_out_of_git_source(
|
||||||
"""When a profile mount targets the same directory as a git mount,
|
self, tmp_path
|
||||||
the profile files should be copied into the git-mount source so the
|
) -> None:
|
||||||
container sees both sets of files through one bind mount."""
|
"""Overlaps must not dirty the shared Git checkout."""
|
||||||
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")
|
||||||
@@ -156,13 +153,12 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
profile_mounts, git_mount_volumes
|
profile_mounts, git_mount_volumes
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert result == profile_mounts
|
||||||
assert (git_source / "existing.txt").read_text() == "from git"
|
assert (git_source / "existing.txt").read_text() == "from git"
|
||||||
assert (git_source / "settings.json").read_text() == "{}"
|
assert not (git_source / "settings.json").exists()
|
||||||
|
|
||||||
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None:
|
def test_descendant_overlap_keeps_sources_isolated(self, tmp_path) -> None:
|
||||||
"""Profile mounts targeting a child directory are copied into the
|
"""A child profile mount must not mutate the shared Git checkout."""
|
||||||
corresponding subdirectory of the git-mount source."""
|
|
||||||
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")
|
||||||
@@ -186,8 +182,8 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
profile_mounts, git_mount_volumes
|
profile_mounts, git_mount_volumes
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert result == profile_mounts
|
||||||
assert (git_source / "agent" / "settings.json").read_text() == "x"
|
assert not (git_source / "agent").exists()
|
||||||
assert (git_source / "README").read_text() == "repo"
|
assert (git_source / "README").read_text() == "repo"
|
||||||
|
|
||||||
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
|
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None:
|
||||||
@@ -247,9 +243,8 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
|
|
||||||
assert result == profile_mounts
|
assert result == profile_mounts
|
||||||
|
|
||||||
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None:
|
def test_profile_source_file_does_not_mutate_git_source(self, tmp_path) -> None:
|
||||||
"""A profile mount that supplies a single file is copied into the
|
"""A profile file must not be copied into a shared Git checkout."""
|
||||||
git-mount source directory."""
|
|
||||||
git_source = tmp_path / "git"
|
git_source = tmp_path / "git"
|
||||||
git_source.mkdir()
|
git_source.mkdir()
|
||||||
|
|
||||||
@@ -271,8 +266,8 @@ class TestStackProfileMountsWithGitMounts:
|
|||||||
profile_mounts, git_mount_volumes
|
profile_mounts, git_mount_volumes
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == []
|
assert result == profile_mounts
|
||||||
assert (git_source / "settings.json").read_text() == "{}"
|
assert not (git_source / "settings.json").exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ConfigProfileRefreshOutcome {
|
||||||
|
instance_id: string;
|
||||||
|
status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigProfile {
|
export interface ConfigProfile {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -16,6 +22,7 @@ export interface ConfigProfile {
|
|||||||
includes: ConfigProfileInclude[];
|
includes: ConfigProfileInclude[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
refresh_outcomes?: ConfigProfileRefreshOutcome[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigProfileMount {
|
export interface ConfigProfileMount {
|
||||||
@@ -138,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,
|
||||||
|
|||||||
@@ -23,6 +23,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;
|
||||||
@@ -63,6 +64,7 @@ export const ConfigProfileEditorPanel = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onReset,
|
onReset,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onRefreshGitMounts,
|
||||||
onAddInclude,
|
onAddInclude,
|
||||||
onRemoveInclude,
|
onRemoveInclude,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
@@ -114,6 +116,9 @@ 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}>
|
||||||
|
<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...</>
|
||||||
|
|||||||
@@ -64,6 +64,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +105,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 +365,13 @@ 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"
|
||||||
|
onClick={() => onRefreshGitMounts(selectedProfile.id)}
|
||||||
|
>
|
||||||
|
<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,
|
||||||
@@ -34,17 +35,21 @@ export const useConfigProfiles = () => {
|
|||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||||
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 [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
|
const [formData, setFormData] =
|
||||||
|
useState<CreateConfigProfileRequest>(defaultForm);
|
||||||
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
|
const selectedProfile =
|
||||||
|
profiles.find((p) => p.id === selectedProfileId) || null;
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -89,7 +94,9 @@ export const useConfigProfiles = () => {
|
|||||||
is_default: profile.is_default,
|
is_default: profile.is_default,
|
||||||
});
|
});
|
||||||
setIncludedProfileIds(
|
setIncludedProfileIds(
|
||||||
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
|
profile.includes.map(
|
||||||
|
(inc: { included_profile_id: string }) => inc.included_profile_id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
@@ -208,20 +215,39 @@ export const useConfigProfiles = () => {
|
|||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
const newProfile = await createConfigProfile(formData);
|
const newProfile = await createConfigProfile(formData);
|
||||||
if (includedProfileIds.length > 0) {
|
if (includedProfileIds.length > 0) {
|
||||||
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
|
await updateProfileIncludes(newProfile.id, {
|
||||||
|
includes: includedProfileIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setSelectedProfileId(newProfile.id);
|
setSelectedProfileId(newProfile.id);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
await loadData();
|
await loadData();
|
||||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
|
const refreshed = (await listConfigProfiles()).find(
|
||||||
|
(p) => p.id === newProfile.id,
|
||||||
|
);
|
||||||
if (refreshed) populateForm(refreshed);
|
if (refreshed) populateForm(refreshed);
|
||||||
} else if (selectedProfile) {
|
} else if (selectedProfile) {
|
||||||
await updateConfigProfile(selectedProfile.id, formData);
|
const updatedProfile = await updateConfigProfile(
|
||||||
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
selectedProfile.id,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
await updateProfileIncludes(selectedProfile.id, {
|
||||||
|
includes: includedProfileIds,
|
||||||
|
});
|
||||||
|
const restartOutcomes = updatedProfile.refresh_outcomes?.filter(
|
||||||
|
(outcome) => outcome.status === "restart_required",
|
||||||
|
);
|
||||||
|
if (restartOutcomes?.length) {
|
||||||
|
setError(
|
||||||
|
`Profile saved. ${restartOutcomes.length} running instance${restartOutcomes.length === 1 ? "" : "s"} must restart to adopt these changes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
await loadData();
|
await loadData();
|
||||||
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
|
const refreshed = (await listConfigProfiles()).find(
|
||||||
|
(p) => p.id === selectedProfile.id,
|
||||||
|
);
|
||||||
if (refreshed) populateForm(refreshed);
|
if (refreshed) populateForm(refreshed);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -233,7 +259,8 @@ export const useConfigProfiles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
|
if (!window.confirm("Are you sure you want to delete this config profile?"))
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
await deleteConfigProfile(id);
|
await deleteConfigProfile(id);
|
||||||
if (selectedProfileId === id) {
|
if (selectedProfileId === id) {
|
||||||
@@ -242,8 +269,27 @@ export const useConfigProfiles = () => {
|
|||||||
resetForm();
|
resetForm();
|
||||||
}
|
}
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch {
|
} catch (err) {
|
||||||
alert("Failed to delete config profile");
|
setError(extractErrorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
|
||||||
|
setError(null);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,7 +314,10 @@ export const useConfigProfiles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addEnvVar = () => {
|
const addEnvVar = () => {
|
||||||
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
env_vars: { ...prev.env_vars, "": "" },
|
||||||
|
}));
|
||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,7 +372,10 @@ export const useConfigProfiles = () => {
|
|||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
const updateMount = (
|
||||||
|
index: number,
|
||||||
|
updates: Partial<ConfigProfile["mounts"][0]>,
|
||||||
|
) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const mounts = [...(prev.mounts || [])];
|
const mounts = [...(prev.mounts || [])];
|
||||||
mounts[index] = { ...mounts[index], ...updates };
|
mounts[index] = { ...mounts[index], ...updates };
|
||||||
@@ -402,6 +454,7 @@ export const useConfigProfiles = () => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
|
handleRefreshGitMounts,
|
||||||
updateFormField,
|
updateFormField,
|
||||||
addEnvVar,
|
addEnvVar,
|
||||||
updateEnvVar,
|
updateEnvVar,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handlePreview,
|
handlePreview,
|
||||||
|
handleRefreshGitMounts,
|
||||||
updateFormField,
|
updateFormField,
|
||||||
addEnvVar,
|
addEnvVar,
|
||||||
updateEnvVar,
|
updateEnvVar,
|
||||||
@@ -135,6 +136,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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -176,6 +178,7 @@ 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}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Live Config Profile Refresh
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Refresh profile-managed configuration for running tool instances when a Config Profile is saved, without recreating the container or terminal session.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Resolve the saved profile and refresh every running instance that selected it, including profiles that include it.
|
||||||
|
- Store non-Git profile configuration as a canonical, host-side working copy shared by every instance using the profile.
|
||||||
|
- Bind-mount canonical profile directories directly into their configured container targets and bind-mount canonical profile files individually under the container working directory, preserving the workspace mount.
|
||||||
|
- Allow UI and container-side edits to the same canonical files; last writer wins, with an overwrite warning when detectable.
|
||||||
|
- Defer Git mount refresh and Git-clone mutation to a separate commit-aware feature.
|
||||||
|
- Standardize all supported tool containers on one shared non-root user/group so canonical writable profile mounts remain accessible across instances.
|
||||||
|
- Return per-instance refresh results to the profile-save UI.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Preserve running containers and terminal sessions.
|
||||||
|
- Preserve the workspace/repository mount.
|
||||||
|
- Docker bind-mount topology is immutable at runtime. Added, removed, retargeted, or mode-changed mounts MUST be reported as requiring restart, not partially applied.
|
||||||
|
- Desktop and mobile profile editors use the same save/refresh behavior.
|
||||||
|
- The standardized container user/group MUST be applied to built-in tool definitions, generated manifests, and image templates; legacy/incompatible tool images must report incompatible permissions rather than silently changing profile mount ownership.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Saving a profile refreshes every eligible running instance with a direct or transitive dependency on that profile.
|
||||||
|
- [ ] Canonical non-Git profile files and mount directories are shared writable working copies across compatible running instances.
|
||||||
|
- [ ] Container-side and UI-side changes become visible to all instances using the profile; last writer wins and detectable overwrites generate a warning.
|
||||||
|
- [ ] Git mount refresh and Git clone mutation are not performed by this feature.
|
||||||
|
- [ ] Built-in supported tool containers use a shared non-root user/group compatible with writable canonical profile mounts.
|
||||||
|
- [ ] Topology changes return a restart-required result without recreating the instance.
|
||||||
|
- [ ] Terminal WebSocket sessions remain connected throughout a successful refresh.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Design: Live Config Profile Refresh
|
||||||
|
|
||||||
|
## Canonical working copies
|
||||||
|
|
||||||
|
Each non-Git Config Profile owns canonical host-side storage. Directory mounts use canonical profile directories; each top-level profile file uses a canonical host file bind-mounted under the container working directory. All compatible instances selected for the profile mount the same sources, so UI and container edits are immediately shared.
|
||||||
|
|
||||||
|
## Container compatibility
|
||||||
|
|
||||||
|
Supported built-in tools standardize on one non-root user/group. Existing instances retain their current image/user and report `restart_required`. Custom tools are not rewritten; tools that do not opt into the shared user/group return `incompatible_permissions`.
|
||||||
|
|
||||||
|
## Save behavior
|
||||||
|
|
||||||
|
A profile save writes canonical profile files atomically per file. The save response reports affected compatible instances, `restart_required` topology/runtime changes, `incompatible_permissions`, and detectable overwrite warnings. Last writer wins; no merge or lock protocol is imposed.
|
||||||
|
|
||||||
|
## Scope boundaries
|
||||||
|
|
||||||
|
Git mount mutation is explicitly deferred. Workspace/repository mounts are never changed. Profile deletion is rejected while running instances still use the profile.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Implementation Plan: Live Config Profile Refresh
|
||||||
|
|
||||||
|
1. Standardize built-in tool user/group definitions and remove ownership-changing behavior for shared profile sources.
|
||||||
|
2. Add canonical profile storage and bind-mount compilation for profile directories and individual working-directory files.
|
||||||
|
3. Add compatibility/topology analysis, running-instance discovery, save-result schema, and deletion guard.
|
||||||
|
4. Wire desktop/mobile profile save results into immediate status/warning feedback.
|
||||||
|
5. Add unit, API, manifest/image, and frontend coverage; run quality gates and manual two-instance QA.
|
||||||
|
|
||||||
|
## Delivery order
|
||||||
|
|
||||||
|
1. Container-user compatibility
|
||||||
|
2. Canonical non-Git profile mounts
|
||||||
|
3. API and deletion semantics
|
||||||
|
4. UI feedback
|
||||||
|
5. Verification and commit
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Git mount refresh and Git clone mutation require a commit-aware follow-up change.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Live Config Profile Refresh — Tasks
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Estimated changed lines | 500–750 |
|
||||||
|
| 400-line budget risk | High |
|
||||||
|
| Chained PRs recommended | Yes |
|
||||||
|
| Suggested split | Container-user standardization → canonical profile mounts → API/UI feedback → verification |
|
||||||
|
| Delivery strategy | feature-branch-chain |
|
||||||
|
| Chain strategy | feature-branch-chain |
|
||||||
|
|
||||||
|
Decision needed before apply: No
|
||||||
|
Chained PRs recommended: Yes
|
||||||
|
Chain strategy: feature-branch-chain
|
||||||
|
400-line budget risk: High
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] **RED/GREEN — shared container user:** standardize built-in tool images, manifests, and permission handling on one shared non-root user/group; detect incompatible legacy images.
|
||||||
|
- [ ] **RED/GREEN — canonical profile storage:** create canonical host-side directories/files per profile and mount them directly into compatible instances, without masking workspace mounts.
|
||||||
|
- [ ] **TRIANGULATE — writable sharing:** prove UI and container edits are shared across instances, with last-writer-wins overwrite warnings.
|
||||||
|
- [ ] **RED/GREEN — topology/API contract:** return restart-required or incompatible-permissions outcomes for paths that cannot mount live; defer Git mount mutation.
|
||||||
|
- [ ] **RED/GREEN — UI feedback:** show shared-working-copy, warning, restart-required, and incompatible-permissions results in desktop and mobile profile editors.
|
||||||
|
- [ ] **Verify:** run targeted backend/frontend tests, typecheck, lint, image/manifest checks, and manual multi-instance permission tests.
|
||||||
|
|
||||||
|
## Verification Notes
|
||||||
|
|
||||||
|
- Passed: frontend production build (`npm run build`), Python compilation for changed backend modules, and targeted LSP diagnostics.
|
||||||
|
- Skipped: backend pytest and Ruff; this environment has no project-managed Python runner, system Python lacks those packages, and the user declined system-package installation.
|
||||||
|
- Skipped: Docker/Compose and manual multi-instance checks; explicit Docker approval was not granted.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Test Plan: Live Config Profile Refresh
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- Canonical file and directory paths are profile-scoped and reject traversal.
|
||||||
|
- Two compatible instances receive the same host mount source.
|
||||||
|
- A container-side file edit is visible through the profile read API and another instance mount.
|
||||||
|
- A profile save updates canonical content and returns overwrite warnings when applicable.
|
||||||
|
- Topology, environment, runtime, and legacy-instance changes return `restart_required`.
|
||||||
|
- Incompatible users return `incompatible_permissions`.
|
||||||
|
- Deleting a profile with running dependents is rejected with their instance identifiers.
|
||||||
|
- Git mount content is unchanged by this feature.
|
||||||
|
|
||||||
|
## Container/image compatibility
|
||||||
|
|
||||||
|
- Each built-in supported image uses the common non-root UID/GID.
|
||||||
|
- Generated manifest Dockerfiles and entrypoints retain that user and writable mount access.
|
||||||
|
- Existing instances are not mutated until restart/recreation.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- Desktop and mobile save flows display refreshed/shared-working-copy, overwrite-warning, restart-required, and incompatible-permissions results.
|
||||||
|
|
||||||
|
## Manual QA
|
||||||
|
|
||||||
|
- Open two compatible instances using one profile; edit a mounted file in one terminal and verify it in the other.
|
||||||
|
- Save a profile edit and verify both running instances see it without terminal disconnection.
|
||||||
|
- Verify profile deletion is blocked while either instance is running.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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,15 @@
|
|||||||
|
# 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 desktop/mobile feedback for refresh outcomes.
|
||||||
|
- [ ] 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.
|
||||||
@@ -20,9 +20,10 @@ RUN apt-get update && apt-get install -y \
|
|||||||
sudo \
|
sudo \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create a non-root user and allow passwordless sudo so the startup
|
# Use the shared built-in UID/GID so writable Config Profile mounts can be
|
||||||
# permission fixer can adjust ownership of bind-mounted directories.
|
# shared by compatible instances without ownership changes.
|
||||||
RUN useradd -m -s /bin/bash user \
|
RUN groupadd -g 1000 user \
|
||||||
|
&& useradd -m -u 1000 -g 1000 -s /bin/bash user \
|
||||||
&& echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \
|
&& echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \
|
||||||
&& chmod 0440 /etc/sudoers.d/user
|
&& chmod 0440 /etc/sudoers.d/user
|
||||||
WORKDIR /home/user
|
WORKDIR /home/user
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||||||
# Set up git
|
# Set up git
|
||||||
RUN git config --global init.defaultBranch main
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
# Code-server runs as abc user by default
|
# The LinuxServer entrypoint maps abc to this shared UID/GID before starting
|
||||||
|
# code-server, so writable Config Profile mounts are compatible with other
|
||||||
|
# built-in tool containers.
|
||||||
|
ENV PUID=1000 \
|
||||||
|
PGID=1000
|
||||||
USER abc
|
USER abc
|
||||||
|
|
||||||
EXPOSE 8443
|
EXPOSE 8443
|
||||||
@@ -17,7 +17,10 @@ RUN apt-get update && apt-get install -y \
|
|||||||
# Set up git
|
# Set up git
|
||||||
RUN git config --global init.defaultBranch main
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
# Switch back to jovyan user (default for scipy-notebook)
|
# start-notebook.py maps jovyan to this shared UID/GID and drops privileges.
|
||||||
USER ${NB_UID}
|
# Keep the image root at entrypoint time so that mapping can occur.
|
||||||
|
ENV NB_UID=1000 \
|
||||||
|
NB_GID=1000
|
||||||
|
USER root
|
||||||
|
|
||||||
EXPOSE 8888
|
EXPOSE 8888
|
||||||
@@ -27,8 +27,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||||||
# Install OpenCode
|
# Install OpenCode
|
||||||
RUN npm install -g opencode
|
RUN npm install -g opencode
|
||||||
|
|
||||||
# Create non-root user
|
# Use the shared built-in UID/GID for writable Config Profile mounts.
|
||||||
RUN useradd -m -s /bin/bash user
|
RUN groupadd -g 1000 user \
|
||||||
|
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
|
||||||
WORKDIR /home/user
|
WORKDIR /home/user
|
||||||
|
|
||||||
# Set up git
|
# Set up git
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|||||||
# Install Pi Coding Agent globally
|
# Install Pi Coding Agent globally
|
||||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
# Create non-root user
|
# Use the shared built-in UID/GID for writable Config Profile mounts.
|
||||||
RUN useradd -m -s /bin/bash user
|
RUN groupadd -g 1000 user \
|
||||||
|
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
|
||||||
WORKDIR /home/user
|
WORKDIR /home/user
|
||||||
|
|
||||||
# Set up git
|
# Set up git
|
||||||
@@ -37,15 +38,11 @@ RUN git config --global init.defaultBranch main \
|
|||||||
&& git config --global user.email "dev@headquarter.local" \
|
&& git config --global user.email "dev@headquarter.local" \
|
||||||
&& git config --global user.name "Developer"
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
# Create default tmux config
|
# Create user-owned default configuration files.
|
||||||
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf \
|
||||||
|
&& mkdir -p /home/user/.config/ranger /home/user/.pi/agent \
|
||||||
# Create default ranger config
|
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf \
|
||||||
RUN mkdir -p /home/user/.config/ranger \
|
&& chown -R user:user /home/user
|
||||||
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
|
|
||||||
|
|
||||||
# Set up Pi config directory
|
|
||||||
RUN mkdir -p /home/user/.pi/agent
|
|
||||||
|
|
||||||
USER user
|
USER user
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user