252 lines
8.1 KiB
Python
252 lines
8.1 KiB
Python
"""Profile resolver service for recursive ordered include resolution.
|
|
|
|
Provides deterministic merge rules, save-independent cycle protection,
|
|
and resolved output structures for env vars, runtime hints, mounts,
|
|
file trees, and override metadata.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
|
|
from src.models.config_include import ConfigInclude
|
|
from src.models.config_mount import ConfigMount
|
|
from src.models.config_profile import ConfigProfile
|
|
|
|
|
|
@dataclass
|
|
class ResolvedMount:
|
|
"""A resolved mount with merged file tree and final mode."""
|
|
|
|
target_path: str
|
|
mode: str # "ro" or "rw"
|
|
files: dict[str, str] = field(default_factory=dict)
|
|
"""Relative file paths to UTF-8 text content."""
|
|
overridden_files: dict[str, list[str]] = field(default_factory=dict)
|
|
"""Map of relative file path to list of profile names that contributed
|
|
(latest is the winner)."""
|
|
mode_overridden_by: str | None = None
|
|
"""Name of the profile that set the final mode, if different from first."""
|
|
|
|
|
|
@dataclass
|
|
class ResolvedRuntimeHints:
|
|
"""Resolved runtime hints from profile layers."""
|
|
|
|
start_command: str | None = None
|
|
working_directory: str | None = None
|
|
port: int | None = None
|
|
overridden_hints: dict[str, str] = field(default_factory=dict)
|
|
"""Map of hint key to profile name that provided the winning value."""
|
|
|
|
|
|
@dataclass
|
|
class ResolvedProfileOutput:
|
|
"""Complete resolved output for a config profile."""
|
|
|
|
profile_id: uuid.UUID
|
|
profile_name: str
|
|
environment_variables: dict[str, str] = field(default_factory=dict)
|
|
"""Final merged env vars (later layers win)."""
|
|
env_var_sources: dict[str, list[str]] = field(default_factory=dict)
|
|
"""Map of env var key to ordered list of contributing profile names
|
|
(latest is the winner)."""
|
|
runtime_hints: ResolvedRuntimeHints = field(
|
|
default_factory=lambda: ResolvedRuntimeHints()
|
|
)
|
|
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
|
"""Map of target_path to ResolvedMount."""
|
|
resolution_order: list[str] = field(default_factory=list)
|
|
"""Ordered list of profile names as they were resolved."""
|
|
cycle_detected: bool = False
|
|
cycle_path: list[str] | None = None
|
|
|
|
|
|
class ProfileResolutionError(Exception):
|
|
"""Raised when profile resolution fails."""
|
|
|
|
pass
|
|
|
|
|
|
class ProfileCycleError(ProfileResolutionError):
|
|
"""Raised when a cycle is detected during profile resolution."""
|
|
|
|
def __init__(self, cycle_path: list[str]) -> None:
|
|
self.cycle_path = cycle_path
|
|
path_str = " -> ".join(cycle_path)
|
|
super().__init__(f"Profile include cycle detected: {path_str}")
|
|
|
|
|
|
def _merge_env_vars(
|
|
current: dict[str, str],
|
|
sources: dict[str, list[str]],
|
|
profile: ConfigProfile,
|
|
) -> None:
|
|
"""Merge a profile's env vars into the current dict, tracking sources."""
|
|
if not profile.environment_variables:
|
|
return
|
|
for key, value in profile.environment_variables.items():
|
|
current[key] = value
|
|
if key not in sources:
|
|
sources[key] = []
|
|
sources[key].append(profile.name)
|
|
|
|
|
|
def _merge_runtime_hints(
|
|
hints: ResolvedRuntimeHints,
|
|
profile: ConfigProfile,
|
|
) -> None:
|
|
"""Merge a profile's runtime hints, tracking overrides."""
|
|
if profile.start_command is not None:
|
|
hints.start_command = profile.start_command
|
|
hints.overridden_hints["start_command"] = profile.name
|
|
if profile.working_directory is not None:
|
|
hints.working_directory = profile.working_directory
|
|
hints.overridden_hints["working_directory"] = profile.name
|
|
if profile.port is not None:
|
|
hints.port = profile.port
|
|
hints.overridden_hints["port"] = profile.name
|
|
|
|
|
|
def _merge_mounts(
|
|
mounts: dict[str, ResolvedMount],
|
|
profile_mounts: list[ConfigMount],
|
|
profile: ConfigProfile,
|
|
) -> None:
|
|
"""Merge a profile's mounts into the current mounts dict."""
|
|
for mount in profile_mounts:
|
|
target = mount.target_path
|
|
if target not in mounts:
|
|
mounts[target] = ResolvedMount(
|
|
target_path=target,
|
|
mode=mount.mode,
|
|
files={},
|
|
overridden_files={},
|
|
)
|
|
resolved = mounts[target]
|
|
|
|
# Mode override: later wins
|
|
if resolved.mode != mount.mode:
|
|
resolved.mode = mount.mode
|
|
resolved.mode_overridden_by = profile.name
|
|
|
|
# File tree merge: later wins for same relative path
|
|
if mount.files:
|
|
for rel_path, content in mount.files.items():
|
|
if rel_path not in resolved.files:
|
|
resolved.overridden_files[rel_path] = []
|
|
else:
|
|
if rel_path not in resolved.overridden_files:
|
|
resolved.overridden_files[rel_path] = []
|
|
resolved.overridden_files[rel_path].append(profile.name)
|
|
resolved.files[rel_path] = content
|
|
|
|
|
|
def _resolve_profile_recursive(
|
|
profile: ConfigProfile,
|
|
visited: set[uuid.UUID],
|
|
path: list[str],
|
|
resolution_order: list[str],
|
|
env_vars: dict[str, str],
|
|
env_var_sources: dict[str, list[str]],
|
|
runtime_hints: ResolvedRuntimeHints,
|
|
mounts: dict[str, ResolvedMount],
|
|
) -> None:
|
|
"""Recursively resolve a profile and its includes.
|
|
|
|
Args:
|
|
profile: The profile to resolve
|
|
visited: Set of already-resolved profile IDs to avoid duplicates
|
|
path: Current recursion path for cycle detection
|
|
resolution_order: Ordered list of profile names being resolved
|
|
env_vars: Accumulated environment variables
|
|
env_var_sources: Tracking of which profiles contributed each env var
|
|
runtime_hints: Accumulated runtime hints
|
|
mounts: Accumulated mounts
|
|
|
|
Raises:
|
|
ProfileCycleError: If a cycle is detected
|
|
"""
|
|
if profile.name in path:
|
|
# Cycle detected
|
|
cycle_start = path.index(profile.name)
|
|
cycle_path = path[cycle_start:] + [profile.name]
|
|
raise ProfileCycleError(cycle_path)
|
|
|
|
if profile.id in visited:
|
|
# Already resolved in another branch (diamond graph)
|
|
return
|
|
|
|
visited.add(profile.id)
|
|
path.append(profile.name)
|
|
resolution_order.append(profile.name)
|
|
|
|
# Resolve includes first (in order)
|
|
includes: list[ConfigInclude] = list(profile.includes)
|
|
includes.sort(key=lambda inc: inc.order_index)
|
|
for include in includes:
|
|
included_profile = include.included_profile
|
|
if included_profile is not None:
|
|
_resolve_profile_recursive(
|
|
included_profile,
|
|
visited,
|
|
path,
|
|
resolution_order,
|
|
env_vars,
|
|
env_var_sources,
|
|
runtime_hints,
|
|
mounts,
|
|
)
|
|
|
|
# Apply this profile's values (later layers win)
|
|
_merge_env_vars(env_vars, env_var_sources, profile)
|
|
_merge_runtime_hints(runtime_hints, profile)
|
|
_merge_mounts(mounts, list(profile.mounts), profile)
|
|
|
|
path.pop()
|
|
|
|
|
|
def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput:
|
|
"""Resolve a config profile with all its includes.
|
|
|
|
Processes included profiles in configured order, then applies the
|
|
selected profile itself. Later layers override earlier layers.
|
|
|
|
Args:
|
|
profile: The root profile to resolve
|
|
|
|
Returns:
|
|
ResolvedProfileOutput with merged env vars, runtime hints, mounts,
|
|
and override metadata
|
|
|
|
Raises:
|
|
ProfileCycleError: If a cycle is detected in the include graph
|
|
"""
|
|
env_vars: dict[str, str] = {}
|
|
env_var_sources: dict[str, list[str]] = {}
|
|
runtime_hints = ResolvedRuntimeHints()
|
|
mounts: dict[str, ResolvedMount] = {}
|
|
resolution_order: list[str] = []
|
|
|
|
_resolve_profile_recursive(
|
|
profile,
|
|
set(),
|
|
[],
|
|
resolution_order,
|
|
env_vars,
|
|
env_var_sources,
|
|
runtime_hints,
|
|
mounts,
|
|
)
|
|
|
|
return ResolvedProfileOutput(
|
|
profile_id=profile.id,
|
|
profile_name=profile.name,
|
|
environment_variables=env_vars,
|
|
env_var_sources=env_var_sources,
|
|
runtime_hints=runtime_hints,
|
|
mounts=mounts,
|
|
resolution_order=resolution_order,
|
|
)
|