5331a0f110
Update files within profile directory mounts in place so editor saves reach running containers.
645 lines
20 KiB
Python
645 lines
20 KiB
Python
"""Config profile resolver service.
|
|
|
|
Provides recursive ordered include resolution with deterministic merge rules
|
|
and cycle protection.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.models import ConfigProfile, ConfigProfileInclude
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConfigProfileCycleError(Exception):
|
|
"""Raised when a cycle is detected in profile includes."""
|
|
|
|
pass
|
|
|
|
|
|
class ConfigProfileNotFoundError(Exception):
|
|
"""Raised when a referenced profile is not found."""
|
|
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class ResolvedMount:
|
|
"""A resolved mount with merged files and final mode."""
|
|
|
|
target: str
|
|
mode: str
|
|
files: dict[str, str] = field(default_factory=dict)
|
|
overridden_files: dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class ResolvedProfile:
|
|
"""The fully resolved output of a config profile."""
|
|
|
|
profile_id: uuid.UUID
|
|
profile_name: str
|
|
env_vars: dict[str, str] = field(default_factory=dict)
|
|
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
|
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
|
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
|
files: dict[str, str] = field(default_factory=dict)
|
|
env_overrides: dict[str, str] = field(default_factory=dict)
|
|
hint_overrides: dict[str, str] = field(default_factory=dict)
|
|
file_overrides: dict[str, str] = field(default_factory=dict)
|
|
mount_overrides: dict[str, str] = field(default_factory=dict)
|
|
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
|
|
def _detect_cycle(
|
|
profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]
|
|
) -> bool:
|
|
"""Detect if adding profile_id to path would create a cycle.
|
|
|
|
Args:
|
|
profile_id: The profile ID to check.
|
|
visited: Set of already-visited profile IDs in current resolution.
|
|
path: Current resolution path for error reporting.
|
|
|
|
Returns:
|
|
True if a cycle would be created.
|
|
"""
|
|
if profile_id in visited:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _merge_env_vars(
|
|
base: dict[str, str],
|
|
overlay: dict[str, str],
|
|
overrides: dict[str, str],
|
|
source_name: str,
|
|
) -> dict[str, str]:
|
|
"""Merge env vars, tracking overrides.
|
|
|
|
Later values replace earlier values.
|
|
"""
|
|
result = dict(base)
|
|
for key, value in overlay.items():
|
|
if key in result and result[key] != value:
|
|
overrides[key] = source_name
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _merge_runtime_hints(
|
|
base: dict[str, Any],
|
|
overlay: dict[str, Any],
|
|
overrides: dict[str, str],
|
|
source_name: str,
|
|
) -> dict[str, Any]:
|
|
"""Merge runtime hints, tracking overrides.
|
|
|
|
Later values replace earlier values.
|
|
"""
|
|
result = dict(base)
|
|
for key, value in overlay.items():
|
|
if key in result and result[key] != value:
|
|
overrides[key] = source_name
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _merge_files(
|
|
base: dict[str, str],
|
|
overlay: dict[str, str],
|
|
overrides: dict[str, str],
|
|
source_name: str,
|
|
) -> dict[str, str]:
|
|
"""Merge file maps, tracking overrides.
|
|
|
|
Later relative file paths win.
|
|
"""
|
|
result = dict(base)
|
|
for path, content in overlay.items():
|
|
if path in result and result[path] != content:
|
|
overrides[path] = source_name
|
|
result[path] = content
|
|
return result
|
|
|
|
|
|
def _merge_mounts(
|
|
base: dict[str, ResolvedMount],
|
|
overlay: list[dict[str, Any]],
|
|
overrides: dict[str, str],
|
|
source_name: str,
|
|
) -> dict[str, ResolvedMount]:
|
|
"""Merge mounts, tracking overrides.
|
|
|
|
Mounts with the same target path have their file maps merged and later
|
|
relative file paths win. Mode conflicts: later layer wins.
|
|
"""
|
|
result = dict(base)
|
|
for mount_data in overlay:
|
|
target = mount_data["target"]
|
|
mode = mount_data.get("mode", "rw")
|
|
files = mount_data.get("files", {})
|
|
|
|
if target in result:
|
|
existing = result[target]
|
|
merged_files = dict(existing.files)
|
|
file_overrides = dict(existing.overridden_files)
|
|
for rel_path, content in files.items():
|
|
if rel_path in merged_files and merged_files[rel_path] != content:
|
|
file_overrides[rel_path] = source_name
|
|
merged_files[rel_path] = content
|
|
if existing.mode != mode:
|
|
overrides[target] = source_name
|
|
result[target] = ResolvedMount(
|
|
target=target,
|
|
mode=mode,
|
|
files=merged_files,
|
|
overridden_files=file_overrides,
|
|
)
|
|
else:
|
|
result[target] = ResolvedMount(
|
|
target=target,
|
|
mode=mode,
|
|
files=dict(files),
|
|
)
|
|
return result
|
|
|
|
|
|
def _find_mount_conflicts(
|
|
profile: ConfigProfile,
|
|
resolved: ResolvedProfile,
|
|
) -> list[dict[str, Any]]:
|
|
"""Find mounts on the profile that override mounts from included profiles.
|
|
|
|
Returns a list of conflict descriptors with the target path, the included
|
|
profile that originally provided the mount, and the current profile name.
|
|
"""
|
|
conflicts = []
|
|
own_targets = {m["target"] for m in (profile.mounts or [])}
|
|
own_files = {f for m in (profile.mounts or []) for f in m.get("files", {}).keys()}
|
|
|
|
for mount in resolved.mounts.values():
|
|
if mount.target in own_targets:
|
|
# The profile itself has a mount at the same target as an included one.
|
|
conflicts.append(
|
|
{
|
|
"type": "mount_target",
|
|
"target": mount.target,
|
|
"overridden_by": profile.name,
|
|
"source": resolved.profile_name,
|
|
}
|
|
)
|
|
continue
|
|
|
|
for rel_path in mount.files:
|
|
if rel_path in own_files:
|
|
conflicts.append(
|
|
{
|
|
"type": "mount_file",
|
|
"target": mount.target,
|
|
"file": rel_path,
|
|
"overridden_by": profile.name,
|
|
"source": resolved.profile_name,
|
|
}
|
|
)
|
|
|
|
return conflicts
|
|
|
|
|
|
def _merge_git_mounts(
|
|
base: list[dict[str, Any]],
|
|
overlay: list[dict[str, Any]],
|
|
source_name: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""Merge git mounts from included profiles.
|
|
|
|
Entries with the same remote_url + branch have their mappings concatenated.
|
|
Different repos are kept as separate entries.
|
|
All entries are normalized to the mappings format.
|
|
"""
|
|
result = list(base)
|
|
# Normalize existing entries to mappings format
|
|
for i, m in enumerate(result):
|
|
result[i] = _normalize_git_mount_entry(dict(m))
|
|
|
|
# Build lookup by (remote_url, branch)
|
|
seen = {}
|
|
for i, m in enumerate(result):
|
|
key = (m["remote_url"], m.get("branch"))
|
|
seen[key] = i
|
|
|
|
for mount in overlay:
|
|
mount = _normalize_git_mount_entry(dict(mount))
|
|
key = (mount["remote_url"], mount.get("branch"))
|
|
if key in seen:
|
|
# Same repo+branch: concatenate mappings, dedup by (source_path, target_path)
|
|
existing = result[seen[key]]
|
|
existing_sources = {
|
|
(m["source_path"], m["target_path"])
|
|
for m in existing.get("mappings", [])
|
|
}
|
|
for mapping in mount.get("mappings", []):
|
|
map_key = (mapping["source_path"], mapping["target_path"])
|
|
if map_key not in existing_sources:
|
|
existing["mappings"].append(dict(mapping))
|
|
existing_sources.add(map_key)
|
|
else:
|
|
seen[key] = len(result)
|
|
result.append(mount)
|
|
return result
|
|
|
|
|
|
def _normalize_git_mount_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
|
"""Normalize a git mount entry to the unified mappings format.
|
|
|
|
Converts legacy source_path + target_path into a single-entry mappings array.
|
|
"""
|
|
entry = dict(entry)
|
|
if "mappings" not in entry or not entry.get("mappings"):
|
|
source = entry.get("source_path", ".")
|
|
target = entry.get("target_path")
|
|
if target is not None:
|
|
entry["mappings"] = [{"source_path": source, "target_path": target}]
|
|
# Remove legacy fields once normalized
|
|
entry.pop("source_path", None)
|
|
entry.pop("target_path", None)
|
|
return entry
|
|
|
|
|
|
async def _resolve_profile_recursive(
|
|
session: AsyncSession,
|
|
profile_id: uuid.UUID,
|
|
visited: set[uuid.UUID],
|
|
path: list[uuid.UUID],
|
|
) -> ResolvedProfile:
|
|
"""Recursively resolve a profile and its includes.
|
|
|
|
Args:
|
|
session: Database session.
|
|
profile_id: Profile ID to resolve.
|
|
visited: Set of already-visited profile IDs in current resolution chain.
|
|
path: Current resolution path for error reporting.
|
|
|
|
Returns:
|
|
ResolvedProfile with all includes merged.
|
|
|
|
Raises:
|
|
ConfigProfileCycleError: If a cycle is detected.
|
|
ConfigProfileNotFoundError: If the profile is not found.
|
|
"""
|
|
if _detect_cycle(profile_id, visited, path):
|
|
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
|
raise ConfigProfileCycleError(
|
|
f"Cycle detected in profile includes: {cycle_path}"
|
|
)
|
|
|
|
profile = await session.get(ConfigProfile, profile_id)
|
|
if profile is None:
|
|
raise ConfigProfileNotFoundError(f"Config profile not found: {profile_id}")
|
|
|
|
new_visited = visited | {profile_id}
|
|
new_path = path + [profile_id]
|
|
|
|
result = ResolvedProfile(
|
|
profile_id=profile.id,
|
|
profile_name=profile.name,
|
|
)
|
|
|
|
# Resolve includes in order
|
|
include_query = (
|
|
select(ConfigProfileInclude)
|
|
.where(ConfigProfileInclude.profile_id == profile_id)
|
|
.order_by(ConfigProfileInclude.order_index)
|
|
)
|
|
include_result = await session.execute(include_query)
|
|
includes = include_result.scalars().all()
|
|
|
|
for include in includes:
|
|
included = await _resolve_profile_recursive(
|
|
session, include.included_profile_id, new_visited, new_path
|
|
)
|
|
result.included_profiles.append(
|
|
{
|
|
"id": str(included.profile_id),
|
|
"name": included.profile_name,
|
|
}
|
|
)
|
|
|
|
result.env_vars = _merge_env_vars(
|
|
result.env_vars,
|
|
included.env_vars,
|
|
result.env_overrides,
|
|
included.profile_name,
|
|
)
|
|
result.runtime_hints = _merge_runtime_hints(
|
|
result.runtime_hints,
|
|
included.runtime_hints,
|
|
result.hint_overrides,
|
|
included.profile_name,
|
|
)
|
|
result.files = _merge_files(
|
|
result.files, included.files, result.file_overrides, included.profile_name
|
|
)
|
|
result.mounts = _merge_mounts(
|
|
result.mounts,
|
|
[
|
|
{"target": m.target, "mode": m.mode, "files": m.files}
|
|
for m in included.mounts.values()
|
|
],
|
|
result.mount_overrides,
|
|
included.profile_name,
|
|
)
|
|
result.git_mounts = _merge_git_mounts(
|
|
result.git_mounts, included.git_mounts, included.profile_name
|
|
)
|
|
|
|
# Apply the profile's own settings (selected profile overrides includes)
|
|
result.env_vars = _merge_env_vars(
|
|
result.env_vars,
|
|
profile.env_vars or {},
|
|
result.env_overrides,
|
|
profile.name,
|
|
)
|
|
result.runtime_hints = _merge_runtime_hints(
|
|
result.runtime_hints,
|
|
profile.runtime_hints or {},
|
|
result.hint_overrides,
|
|
profile.name,
|
|
)
|
|
result.files = _merge_files(
|
|
result.files,
|
|
profile.files or {},
|
|
result.file_overrides,
|
|
profile.name,
|
|
)
|
|
result.mounts = _merge_mounts(
|
|
result.mounts,
|
|
profile.mounts or [],
|
|
result.mount_overrides,
|
|
profile.name,
|
|
)
|
|
result.git_mounts = _merge_git_mounts(
|
|
result.git_mounts,
|
|
profile.git_mounts or [],
|
|
profile.name,
|
|
)
|
|
return result
|
|
|
|
|
|
async def resolve_profile(
|
|
session: AsyncSession,
|
|
profile_id: uuid.UUID,
|
|
) -> ResolvedProfile:
|
|
"""Resolve a config profile with all includes.
|
|
|
|
Args:
|
|
session: Database session.
|
|
profile_id: Profile ID to resolve.
|
|
|
|
Returns:
|
|
ResolvedProfile with merged env vars, runtime hints, mounts, and files.
|
|
|
|
Raises:
|
|
ConfigProfileCycleError: If a cycle is detected in includes.
|
|
ConfigProfileNotFoundError: If the profile is not found.
|
|
"""
|
|
return await _resolve_profile_recursive(session, profile_id, set(), [])
|
|
|
|
|
|
async def check_include_cycle(
|
|
session: AsyncSession,
|
|
profile_id: uuid.UUID,
|
|
new_include_id: uuid.UUID | None = None,
|
|
) -> list[uuid.UUID] | None:
|
|
"""Check if adding an include would create a cycle.
|
|
|
|
Used at save time to validate include relationships before persisting.
|
|
|
|
Args:
|
|
session: Database session.
|
|
profile_id: The profile that would receive the new include.
|
|
new_include_id: Optional new profile to include. If None, checks existing includes.
|
|
|
|
Returns:
|
|
The cycle path as a list of UUIDs if a cycle exists, otherwise None.
|
|
"""
|
|
|
|
async def _check_from(
|
|
current_id: uuid.UUID,
|
|
target_id: uuid.UUID,
|
|
visited: set[uuid.UUID],
|
|
path: list[uuid.UUID],
|
|
) -> list[uuid.UUID] | None:
|
|
if current_id in visited:
|
|
if current_id == target_id:
|
|
return path + [current_id]
|
|
return None
|
|
if current_id == target_id and path:
|
|
return path + [current_id]
|
|
|
|
new_visited = visited | {current_id}
|
|
new_path = path + [current_id]
|
|
|
|
include_query = (
|
|
select(ConfigProfileInclude)
|
|
.where(ConfigProfileInclude.profile_id == current_id)
|
|
.order_by(ConfigProfileInclude.order_index)
|
|
)
|
|
include_result = await session.execute(include_query)
|
|
includes = include_result.scalars().all()
|
|
|
|
for include in includes:
|
|
cycle = await _check_from(
|
|
include.included_profile_id, target_id, new_visited, new_path
|
|
)
|
|
if cycle is not None:
|
|
return cycle
|
|
return None
|
|
|
|
# Check if new_include_id can reach profile_id (would create cycle)
|
|
if new_include_id is not None:
|
|
cycle = await _check_from(new_include_id, profile_id, set(), [])
|
|
if cycle is not None:
|
|
return cycle
|
|
|
|
# Also check existing includes for cycles
|
|
cycle = await _check_from(profile_id, profile_id, set(), [])
|
|
if cycle is not None and len(cycle) > 1:
|
|
return cycle
|
|
|
|
return None
|
|
|
|
|
|
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.
|
|
|
|
Stages files, writes env vars, and prepares mount volumes.
|
|
|
|
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).
|
|
env_vars: Merged environment variables.
|
|
files: Relative file paths to content for the instance.
|
|
volume_mounts: List of Docker volume mount dicts.
|
|
runtime_hints: Extracted runtime hints.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
instance_path = Path(instance_dir)
|
|
env_vars = dict(resolved.env_vars)
|
|
working_dir = working_dir or home_dir
|
|
volume_mounts = []
|
|
|
|
# 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"
|
|
|
|
def write_canonical_file(
|
|
root: Path,
|
|
relative_path: str,
|
|
content: str,
|
|
*,
|
|
preserve_inode: bool = False,
|
|
) -> 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)
|
|
if preserve_inode and path.is_file():
|
|
# A file bind mount follows its inode, not its directory entry.
|
|
# Replacing this path would leave a running container attached to
|
|
# the old inode, so overwrite the existing file in place.
|
|
path.write_text(content, encoding="utf-8")
|
|
return path
|
|
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, preserve_inode=True
|
|
)
|
|
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 = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
|
|
for file_path, content in mount.files.items():
|
|
write_canonical_file(mount_dir, file_path, content, preserve_inode=True)
|
|
|
|
volume_mounts.append(
|
|
{
|
|
"source": str(mount_dir),
|
|
"target": expanded_target,
|
|
"type": "bind",
|
|
"readonly": mount.mode in ("ro", "readonly"),
|
|
}
|
|
)
|
|
|
|
# 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:
|
|
"""Expand ~ and $HOME in a container path to the actual home directory.
|
|
|
|
Only expands at the start of the path (e.g., ~/foo, $HOME/foo, $HOME).
|
|
Leaves mid-string occurrences unchanged.
|
|
|
|
Args:
|
|
path: Container path that may contain ~ or $HOME.
|
|
home_dir: The container's home directory (e.g., /home/user or /root).
|
|
|
|
Returns:
|
|
Path with ~ and $HOME expanded.
|
|
"""
|
|
if path.startswith("~/"):
|
|
return os.path.join(home_dir, path[2:])
|
|
if path == "~":
|
|
return home_dir
|
|
if path.startswith("$HOME/"):
|
|
return home_dir + "/" + path[6:]
|
|
if path == "$HOME":
|
|
return home_dir
|
|
return path
|
|
|
|
|
|
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
|
"""Convert a ResolvedProfile to a plain dict for serialization.
|
|
|
|
Args:
|
|
resolved: The resolved profile.
|
|
|
|
Returns:
|
|
Dict with env_vars, runtime_hints, mounts, files, and metadata.
|
|
"""
|
|
return {
|
|
"profile_id": str(resolved.profile_id),
|
|
"profile_name": resolved.profile_name,
|
|
"env_vars": resolved.env_vars,
|
|
"runtime_hints": resolved.runtime_hints,
|
|
"mounts": [
|
|
{
|
|
"target": m.target,
|
|
"mode": m.mode,
|
|
"files": m.files,
|
|
"overridden_files": m.overridden_files,
|
|
}
|
|
for m in resolved.mounts.values()
|
|
],
|
|
"files": resolved.files,
|
|
"overrides": {
|
|
"env_vars": resolved.env_overrides,
|
|
"runtime_hints": resolved.hint_overrides,
|
|
"files": resolved.file_overrides,
|
|
"mounts": resolved.mount_overrides,
|
|
},
|
|
"git_mounts": resolved.git_mounts,
|
|
"included_profiles": resolved.included_profiles,
|
|
}
|