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:
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]) / "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:
|
||||
"""A mount with no files should not produce any volume entries."""
|
||||
resolved = ResolvedProfile(
|
||||
@@ -579,7 +627,7 @@ class TestApplyResolvedProfile:
|
||||
|
||||
assert len(volumes) == 1
|
||||
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:
|
||||
"""A mount with mode 'rw' should not set readonly on the volume entry."""
|
||||
@@ -598,7 +646,7 @@ class TestApplyResolvedProfile:
|
||||
|
||||
assert len(volumes) == 1
|
||||
assert volumes[0]["target"] == "/app"
|
||||
assert volumes[0].get("readonly") is False
|
||||
assert not volumes[0].get("readonly")
|
||||
|
||||
|
||||
class TestCheckIncludeCycle:
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ConfigProfileRefreshOutcome {
|
||||
instance_id: string;
|
||||
status: "compatible" | "restart_required" | "incompatible_permissions";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ConfigProfile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -16,6 +22,7 @@ export interface ConfigProfile {
|
||||
includes: ConfigProfileInclude[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
refresh_outcomes?: ConfigProfileRefreshOutcome[];
|
||||
}
|
||||
|
||||
export interface ConfigProfileMount {
|
||||
|
||||
@@ -34,17 +34,21 @@ export const useConfigProfiles = () => {
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
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 [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewData, setPreviewData] = useState<ResolvedProfile | 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 [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 () => {
|
||||
setStatus("loading");
|
||||
@@ -89,7 +93,9 @@ export const useConfigProfiles = () => {
|
||||
is_default: profile.is_default,
|
||||
});
|
||||
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);
|
||||
setSaveStatus("idle");
|
||||
@@ -208,20 +214,39 @@ export const useConfigProfiles = () => {
|
||||
if (isCreating) {
|
||||
const newProfile = await createConfigProfile(formData);
|
||||
if (includedProfileIds.length > 0) {
|
||||
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
|
||||
await updateProfileIncludes(newProfile.id, {
|
||||
includes: includedProfileIds,
|
||||
});
|
||||
}
|
||||
setIsCreating(false);
|
||||
setSelectedProfileId(newProfile.id);
|
||||
setSaveStatus("saved");
|
||||
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);
|
||||
} else if (selectedProfile) {
|
||||
await updateConfigProfile(selectedProfile.id, formData);
|
||||
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
|
||||
const updatedProfile = await updateConfigProfile(
|
||||
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");
|
||||
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);
|
||||
}
|
||||
return true;
|
||||
@@ -233,7 +258,8 @@ export const useConfigProfiles = () => {
|
||||
};
|
||||
|
||||
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 {
|
||||
await deleteConfigProfile(id);
|
||||
if (selectedProfileId === id) {
|
||||
@@ -242,8 +268,8 @@ export const useConfigProfiles = () => {
|
||||
resetForm();
|
||||
}
|
||||
await loadData();
|
||||
} catch {
|
||||
alert("Failed to delete config profile");
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -268,7 +294,10 @@ export const useConfigProfiles = () => {
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
env_vars: { ...prev.env_vars, "": "" },
|
||||
}));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
@@ -323,7 +352,10 @@ export const useConfigProfiles = () => {
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
|
||||
const updateMount = (
|
||||
index: number,
|
||||
updates: Partial<ConfigProfile["mounts"][0]>,
|
||||
) => {
|
||||
setFormData((prev) => {
|
||||
const mounts = [...(prev.mounts || [])];
|
||||
mounts[index] = { ...mounts[index], ...updates };
|
||||
|
||||
@@ -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.
|
||||
@@ -20,9 +20,10 @@ RUN apt-get update && apt-get install -y \
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user and allow passwordless sudo so the startup
|
||||
# permission fixer can adjust ownership of bind-mounted directories.
|
||||
RUN useradd -m -s /bin/bash user \
|
||||
# Use the shared built-in UID/GID so writable Config Profile mounts can be
|
||||
# shared by compatible instances without ownership changes.
|
||||
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 \
|
||||
&& chmod 0440 /etc/sudoers.d/user
|
||||
WORKDIR /home/user
|
||||
|
||||
@@ -22,7 +22,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
# Set up git
|
||||
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
|
||||
|
||||
EXPOSE 8443
|
||||
@@ -17,7 +17,10 @@ RUN apt-get update && apt-get install -y \
|
||||
# Set up git
|
||||
RUN git config --global init.defaultBranch main
|
||||
|
||||
# Switch back to jovyan user (default for scipy-notebook)
|
||||
USER ${NB_UID}
|
||||
# start-notebook.py maps jovyan to this shared UID/GID and drops privileges.
|
||||
# Keep the image root at entrypoint time so that mapping can occur.
|
||||
ENV NB_UID=1000 \
|
||||
NB_GID=1000
|
||||
USER root
|
||||
|
||||
EXPOSE 8888
|
||||
@@ -27,8 +27,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
# Install OpenCode
|
||||
RUN npm install -g opencode
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -s /bin/bash user
|
||||
# Use the shared built-in UID/GID for writable Config Profile mounts.
|
||||
RUN groupadd -g 1000 user \
|
||||
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
|
||||
WORKDIR /home/user
|
||||
|
||||
# Set up git
|
||||
|
||||
@@ -28,8 +28,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
# Install Pi Coding Agent globally
|
||||
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -s /bin/bash user
|
||||
# Use the shared built-in UID/GID for writable Config Profile mounts.
|
||||
RUN groupadd -g 1000 user \
|
||||
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
|
||||
WORKDIR /home/user
|
||||
|
||||
# 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.name "Developer"
|
||||
|
||||
# Create default tmux config
|
||||
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||
|
||||
# Create default ranger config
|
||||
RUN mkdir -p /home/user/.config/ranger \
|
||||
&& 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
|
||||
# Create user-owned default configuration files.
|
||||
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 \
|
||||
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf \
|
||||
&& chown -R user:user /home/user
|
||||
|
||||
USER user
|
||||
|
||||
|
||||
Reference in New Issue
Block a user