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