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:
|
||||
|
||||
Reference in New Issue
Block a user