feat: add live config profile refresh

- Standardize built-in tool users for shared writable profile mounts
- Mount canonical non-Git profile sources across compatible instances
- Report restart-required outcomes and guard active profile deletion
- Surface restart feedback in config profile editing

Quality gates: frontend build passed; backend py_compile and LSP passed.
Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
This commit is contained in:
Developer
2026-07-21 11:12:41 +00:00
parent f9f9372ee7
commit add7c1b500
19 changed files with 546 additions and 160 deletions
+42 -2
View File
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
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 (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
@@ -43,6 +43,34 @@ logger = logging.getLogger(__name__)
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])
async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"),
@@ -140,8 +168,10 @@ async def update_config_profile(
)
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)
return profile_to_response(profile)
return response
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -161,6 +191,16 @@ async def delete_config_profile(
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.commit()
@@ -240,6 +240,12 @@ class ConfigProfileIncludeUpdate(BaseModel):
return v
class ConfigProfileRefreshOutcome(BaseModel):
instance_id: str
status: str
reason: str | None = None
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
@@ -256,6 +262,7 @@ class ConfigProfileResponse(BaseModel):
includes: list[dict]
created_at: str
updated_at: str
refresh_outcomes: list[ConfigProfileRefreshOutcome] = Field(default_factory=list)
class DefaultProfilesUpdate(BaseModel):
+118 -82
View File
@@ -5,10 +5,125 @@ import logging
from sqlalchemy import select, text
from src.database import SessionLocal
from src.models import ToolType
from src.models import ToolDefinitionManifest, ToolType
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:
"""Check if a table exists in the database."""
@@ -45,88 +160,9 @@ async def seed_builtin_tool_types():
)
return
builtin_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
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"],
},
]
await _standardize_pi_agent_manifest(session)
for tool_data in builtin_types:
for tool_data in BUILTIN_TOOL_TYPES:
existing = await session.scalar(
select(ToolType).where(ToolType.name == tool_data["name"])
)
@@ -6,6 +6,7 @@ and cycle protection.
import logging
import os
import tempfile
import uuid
from dataclasses import dataclass, field
from typing import Any
@@ -481,6 +482,7 @@ def apply_resolved_profile(
instance_dir: str,
resolved: ResolvedProfile,
home_dir: str = "/root",
working_dir: str | None = None,
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
"""Apply a resolved profile to an instance directory.
@@ -489,6 +491,8 @@ def apply_resolved_profile(
Args:
instance_dir: Path to the instance directory.
resolved: The resolved profile.
home_dir: Container home directory used for path expansion.
working_dir: Container working directory for top-level profile files.
Returns:
Tuple of (env_vars, files, volume_mounts, runtime_hints).
@@ -501,49 +505,57 @@ def apply_resolved_profile(
instance_path = Path(instance_dir)
env_vars = dict(resolved.env_vars)
files = dict(resolved.files)
working_dir = working_dir or home_dir
volume_mounts = []
# Write profile files to instance directory
for file_path, content in files.items():
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
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)
# Profile content belongs to the profile, not an individual tool instance.
# Keeping it beside the instance root gives every compatible instance the
# same host source while retaining the existing instance storage setting.
profile_dir = instance_path.parent / "config-profiles" / str(resolved.profile_id)
files_dir = profile_dir / "files"
mounts_dir = profile_dir / "mounts"
# Stage mount directories and prepare directory-level volume mounts.
# Each ResolvedMount targets a container directory; we stage all of its
# files under a single host directory and bind-mount that directory. This
# keeps the target directory writable by the container user, instead of
# having Docker create a root-owned parent directory when only individual
# files are mounted.
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
path = root / relative_path
try:
path.resolve().relative_to(root.resolve())
except ValueError:
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():
if not mount.files:
continue
expanded_target = os.path.normpath(
expand_container_path(mount.target, home_dir)
)
mount_dir = (
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
)
mount_dir.mkdir(parents=True, exist_ok=True)
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
for file_path, content in mount.files.items():
full_path = mount_dir / file_path
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)
write_canonical_file(mount_dir, file_path, content)
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:
+10 -5
View File
@@ -1402,17 +1402,22 @@ async def start_tool_instance(
resolved = await resolve_profile(
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 = (
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
# need the final working directory to resolve relative target paths.
if profile_hints.get("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"):
port_override = profile_hints["port_override"]
env_vars.update(profile_env)