feat: add config profiles

- Add ConfigProfile and ConfigProfileInclude data models with migrations
- Implement profile resolver service with ordered includes and merge rules
- Add profile CRUD API with validation, compatibility, and cycle detection
- Add instance API plumbing for profile selection on create/start/restart
- Add resolved profile preview and default resolution APIs
- Add frontend config profile API client and management UI
- Add launch/restart profile selection UI
- Add backend integration and unit tests (31 passing)

OpenSpec: add-config-profiles
Quality gates: ruff, TypeScript compile, 31 tests passing
This commit is contained in:
2026-05-24 17:58:39 +00:00
parent 4de312c170
commit 9ad11a021c
23 changed files with 3136 additions and 125 deletions
@@ -0,0 +1,453 @@
"""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)
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
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,
)
# 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,
)
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,
},
"included_profiles": resolved.included_profiles,
}