"""Config profile resolver service. Provides recursive ordered include resolution with deterministic merge rules and cycle protection. """ import logging import uuid from dataclasses import dataclass, field from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.models.config_profile 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 _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. Later mounts override earlier ones with the same repo_id + target_path combo. """ result = list(base) # Build lookup by (repo_id, target_path) seen = {(m["repo_id"], m["target_path"]): i for i, m in enumerate(result)} for mount in overlay: key = (mount["repo_id"], mount["target_path"]) if key in seen: result[seen[key]] = dict(mount) else: seen[key] = len(result) result.append(dict(mount)) return result 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, ) -> 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. 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) files = dict(resolved.files) 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) # Stage mount files and prepare volume mounts for mount in resolved.mounts.values(): mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_") mount_dir.mkdir(parents=True, exist_ok=True) 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) volume_mounts.append({ "source": str(mount_dir), "target": mount.target, "type": "bind", }) return env_vars, files, volume_mounts, resolved.runtime_hints 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, }