Compare commits
4 Commits
dev
...
9cc98455ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cc98455ef | |||
| 13aceeb08d | |||
| f0e19615ce | |||
| 0bea26c784 |
@@ -0,0 +1,104 @@
|
|||||||
|
"""add config profiles, includes, mounts, and tool instance profile selection
|
||||||
|
|
||||||
|
Revision ID: 0013_add_config_profiles
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-24 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0013_add_config_profiles"
|
||||||
|
down_revision: Union[str, None] = "0012_default_port_req"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create config_profiles table
|
||||||
|
op.create_table(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||||
|
|
||||||
|
# Create config_includes table
|
||||||
|
op.create_table(
|
||||||
|
"config_includes",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
|
||||||
|
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
|
||||||
|
|
||||||
|
# Create config_mounts table
|
||||||
|
op.create_table(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("mount_path", sa.String(length=1024), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
|
||||||
|
|
||||||
|
# Add selected_profile_id to tool_instances
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_tool_instances_selected_profile",
|
||||||
|
"tool_instances",
|
||||||
|
"config_profiles",
|
||||||
|
["selected_profile_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove selected_profile_id from tool_instances
|
||||||
|
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
|
||||||
|
op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey")
|
||||||
|
op.drop_column("tool_instances", "selected_profile_id")
|
||||||
|
|
||||||
|
# Drop config_mounts
|
||||||
|
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
|
||||||
|
op.drop_table("config_mounts")
|
||||||
|
|
||||||
|
# Drop config_includes
|
||||||
|
op.drop_index("idx_config_includes_included", table_name="config_includes")
|
||||||
|
op.drop_index("idx_config_includes_profile", table_name="config_includes")
|
||||||
|
op.drop_table("config_includes")
|
||||||
|
|
||||||
|
# Drop config_profiles
|
||||||
|
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
||||||
|
op.drop_table("config_profiles")
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""add profile resolver fields to config profiles and mounts
|
||||||
|
|
||||||
|
Revision ID: 0014_add_profile_resolver_fields
|
||||||
|
Revises: 0013_add_config_profiles
|
||||||
|
Create Date: 2026-05-24 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0014_add_profile_resolver_fields"
|
||||||
|
down_revision: Union[str, None] = "0013_add_config_profiles"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add fields to config_profiles
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("environment_variables", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("start_command", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("working_directory", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("port", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add foreign keys for project and tool_type
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_config_profiles_project",
|
||||||
|
"config_profiles",
|
||||||
|
"projects",
|
||||||
|
["project_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_config_profiles_tool_type",
|
||||||
|
"config_profiles",
|
||||||
|
"tool_types",
|
||||||
|
["tool_type_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indices
|
||||||
|
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||||
|
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
|
||||||
|
|
||||||
|
# Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON
|
||||||
|
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
|
||||||
|
op.add_column(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("files", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
# Drop the source_profile foreign key if it exists
|
||||||
|
op.drop_constraint(
|
||||||
|
"config_mounts_source_profile_id_fkey",
|
||||||
|
"config_mounts",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("config_mounts", "content")
|
||||||
|
op.drop_column("config_mounts", "source_profile_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Restore config_mounts
|
||||||
|
op.add_column(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"config_mounts",
|
||||||
|
sa.Column("content", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.drop_column("config_mounts", "files")
|
||||||
|
op.drop_column("config_mounts", "mode")
|
||||||
|
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
|
||||||
|
|
||||||
|
# Restore config_profiles
|
||||||
|
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
|
||||||
|
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
|
||||||
|
op.drop_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey")
|
||||||
|
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
|
||||||
|
op.drop_column("config_profiles", "is_default")
|
||||||
|
op.drop_column("config_profiles", "port")
|
||||||
|
op.drop_column("config_profiles", "working_directory")
|
||||||
|
op.drop_column("config_profiles", "start_command")
|
||||||
|
op.drop_column("config_profiles", "environment_variables")
|
||||||
|
op.drop_column("config_profiles", "tool_type_id")
|
||||||
|
op.drop_column("config_profiles", "project_id")
|
||||||
@@ -18,6 +18,7 @@ from src.auth.dependencies import get_current_user_id
|
|||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
from src.models.tool_config import ToolConfig
|
from src.models.tool_config import ToolConfig
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
@@ -41,6 +42,7 @@ from src.services.docker import (
|
|||||||
write_config_folder_files,
|
write_config_folder_files,
|
||||||
)
|
)
|
||||||
from src.services.docker_build import build_image
|
from src.services.docker_build import build_image
|
||||||
|
from src.services.profile_resolver import resolve_profile
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||||
@@ -53,6 +55,7 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
|
|
||||||
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
||||||
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
||||||
|
config_profile_id: str | None = Field(default=None, description="Optional config profile ID to apply to the instance")
|
||||||
|
|
||||||
|
|
||||||
def _modify_compose_file(
|
def _modify_compose_file(
|
||||||
@@ -107,6 +110,68 @@ def _modify_compose_file(
|
|||||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_resolved_profile(
|
||||||
|
profile: ConfigProfile,
|
||||||
|
instance_dir: str,
|
||||||
|
env_vars: dict[str, str],
|
||||||
|
port_override: int | None,
|
||||||
|
start_command: str | None,
|
||||||
|
working_directory: str | None,
|
||||||
|
extra_volumes: list[dict],
|
||||||
|
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
|
||||||
|
"""Resolve a profile and apply its output to instance configuration.
|
||||||
|
|
||||||
|
Merges resolved profile env vars (profile wins), applies runtime hints,
|
||||||
|
stages mount files to the instance directory, and adds Docker bind mounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: The config profile to resolve and apply.
|
||||||
|
instance_dir: Path to the instance directory.
|
||||||
|
env_vars: Current environment variables dict (will be updated).
|
||||||
|
port_override: Current port override (may be updated).
|
||||||
|
start_command: Current start command (may be updated).
|
||||||
|
working_directory: Current working directory (may be updated).
|
||||||
|
extra_volumes: Current extra volumes list (will be extended).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated (env_vars, port_override, start_command, working_directory, extra_volumes).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
resolved = resolve_profile(profile)
|
||||||
|
|
||||||
|
# Merge env vars from resolved profile (profile wins over tool configs)
|
||||||
|
if resolved.environment_variables:
|
||||||
|
env_vars.update(resolved.environment_variables)
|
||||||
|
|
||||||
|
# Apply runtime hints
|
||||||
|
if resolved.runtime_hints.start_command is not None:
|
||||||
|
start_command = resolved.runtime_hints.start_command
|
||||||
|
if resolved.runtime_hints.working_directory is not None:
|
||||||
|
working_directory = resolved.runtime_hints.working_directory
|
||||||
|
if resolved.runtime_hints.port is not None:
|
||||||
|
port_override = resolved.runtime_hints.port
|
||||||
|
|
||||||
|
# Stage mount files and add volume mounts
|
||||||
|
for target_path, mount in resolved.mounts.items():
|
||||||
|
safe_name = target_path.strip("/").replace("/", "_")
|
||||||
|
mount_dir = Path(instance_dir) / "mounts" / safe_name
|
||||||
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for rel_path, content in mount.files.items():
|
||||||
|
file_path = mount_dir / rel_path
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
extra_volumes.append({
|
||||||
|
"source": str(mount_dir),
|
||||||
|
"target": target_path,
|
||||||
|
"type": mount.mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return env_vars, port_override, start_command, working_directory, extra_volumes
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
"""Fetch a user by ID or raise 404 if not found."""
|
"""Fetch a user by ID or raise 404 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
@@ -189,6 +254,29 @@ async def create_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate config_profile_id if provided
|
||||||
|
selected_profile_id: uuid.UUID | None = None
|
||||||
|
if data.config_profile_id:
|
||||||
|
try:
|
||||||
|
selected_profile_id = uuid.UUID(data.config_profile_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="invalid config_profile_id format",
|
||||||
|
)
|
||||||
|
|
||||||
|
config_profile = await session.get(ConfigProfile, selected_profile_id)
|
||||||
|
if config_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
if config_profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="config profile does not belong to user",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate unique name
|
# Generate unique name
|
||||||
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
|
||||||
@@ -262,6 +350,7 @@ services:
|
|||||||
status="pending",
|
status="pending",
|
||||||
compose_path=compose_path,
|
compose_path=compose_path,
|
||||||
port=tool_port,
|
port=tool_port,
|
||||||
|
selected_profile_id=selected_profile_id,
|
||||||
)
|
)
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -273,6 +362,7 @@ services:
|
|||||||
"display_name": instance.display_name,
|
"display_name": instance.display_name,
|
||||||
"tool_type_id": str(instance.tool_type_id),
|
"tool_type_id": str(instance.tool_type_id),
|
||||||
"status": instance.status,
|
"status": instance.status,
|
||||||
|
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -335,6 +425,7 @@ async def list_instances(
|
|||||||
"status": i.status,
|
"status": i.status,
|
||||||
"url": i.url,
|
"url": i.url,
|
||||||
"port": i.port,
|
"port": i.port,
|
||||||
|
"config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None,
|
||||||
"created_at": i.created_at.isoformat(),
|
"created_at": i.created_at.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -395,6 +486,7 @@ async def get_instance(
|
|||||||
"compose_path": instance.compose_path,
|
"compose_path": instance.compose_path,
|
||||||
"url": instance.url,
|
"url": instance.url,
|
||||||
"port": instance.port,
|
"port": instance.port,
|
||||||
|
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
|
||||||
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
|
||||||
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
|
||||||
"created_at": instance.created_at.isoformat(),
|
"created_at": instance.created_at.isoformat(),
|
||||||
@@ -452,6 +544,7 @@ async def start_instance(
|
|||||||
extra_env_vars = {}
|
extra_env_vars = {}
|
||||||
extra_volumes = []
|
extra_volumes = []
|
||||||
|
|
||||||
|
# Fetch all matching configs for this tool type
|
||||||
config_query = select(ToolConfig).where(
|
config_query = select(ToolConfig).where(
|
||||||
ToolConfig.user_id == user_id,
|
ToolConfig.user_id == user_id,
|
||||||
ToolConfig.tool_type_id == instance.tool_type_id,
|
ToolConfig.tool_type_id == instance.tool_type_id,
|
||||||
@@ -484,6 +577,31 @@ async def start_instance(
|
|||||||
# Merge extra env vars
|
# Merge extra env vars
|
||||||
env_vars.update(extra_env_vars)
|
env_vars.update(extra_env_vars)
|
||||||
|
|
||||||
|
# Apply resolved profile output if a profile is selected
|
||||||
|
if instance.selected_profile_id:
|
||||||
|
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||||
|
if selected_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
if selected_profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="config profile does not belong to user",
|
||||||
|
)
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_vars, port_override, start_command, working_directory, extra_volumes = await _apply_resolved_profile(
|
||||||
|
selected_profile,
|
||||||
|
instance_dir,
|
||||||
|
env_vars,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
|
)
|
||||||
|
logger.info("Applied resolved profile %s for instance %s", selected_profile.name, instance.id)
|
||||||
|
|
||||||
# Fetch active config folders for this user
|
# Fetch active config folders for this user
|
||||||
folder_query = select(ConfigFolder).where(
|
folder_query = select(ConfigFolder).where(
|
||||||
ConfigFolder.user_id == user_id,
|
ConfigFolder.user_id == user_id,
|
||||||
@@ -755,8 +873,105 @@ async def restart_instance(
|
|||||||
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
|
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
# Re-apply configuration using stored profile instead of current defaults
|
||||||
|
env_vars = {}
|
||||||
|
config_files = {}
|
||||||
|
port_override = None
|
||||||
|
start_command = None
|
||||||
|
working_directory = None
|
||||||
|
extra_env_vars = {}
|
||||||
|
extra_volumes = []
|
||||||
|
|
||||||
|
# Fetch all matching configs for this tool type
|
||||||
|
config_query = select(ToolConfig).where(
|
||||||
|
ToolConfig.user_id == user_id,
|
||||||
|
ToolConfig.tool_type_id == instance.tool_type_id,
|
||||||
|
).where(
|
||||||
|
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
config_result = await session.execute(config_query)
|
||||||
|
configs = config_result.scalars().all()
|
||||||
|
logger.info("Found %d tool configs for restart of instance %s", len(configs), instance.id)
|
||||||
|
|
||||||
|
for config in configs:
|
||||||
|
if config.config_type == "env":
|
||||||
|
env_vars[config.key] = config.value
|
||||||
|
elif config.config_type == "file" and config.file_path:
|
||||||
|
config_files[config.file_path] = config.value
|
||||||
|
|
||||||
|
if config.port_override:
|
||||||
|
port_override = config.port_override
|
||||||
|
if config.start_command:
|
||||||
|
start_command = config.start_command
|
||||||
|
if config.working_directory:
|
||||||
|
working_directory = config.working_directory
|
||||||
|
if config.environment_variables:
|
||||||
|
extra_env_vars.update(config.environment_variables)
|
||||||
|
if config.volumes:
|
||||||
|
extra_volumes.extend(config.volumes)
|
||||||
|
|
||||||
|
# Merge extra env vars
|
||||||
|
env_vars.update(extra_env_vars)
|
||||||
|
|
||||||
|
# Apply stored profile on restart instead of current defaults
|
||||||
|
if instance.selected_profile_id:
|
||||||
|
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
||||||
|
if stored_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="config profile not found",
|
||||||
|
)
|
||||||
|
if stored_profile.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="config profile does not belong to user",
|
||||||
|
)
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_vars, port_override, start_command, working_directory, extra_volumes = await _apply_resolved_profile(
|
||||||
|
stored_profile,
|
||||||
|
instance_dir,
|
||||||
|
env_vars,
|
||||||
|
port_override,
|
||||||
|
start_command,
|
||||||
|
working_directory,
|
||||||
|
extra_volumes,
|
||||||
|
)
|
||||||
|
logger.info("Re-applied stored profile %s for restart of instance %s", stored_profile.name, instance.id)
|
||||||
|
|
||||||
|
# Fetch active config folders for this user
|
||||||
|
folder_query = select(ConfigFolder).where(
|
||||||
|
ConfigFolder.user_id == user_id,
|
||||||
|
ConfigFolder.is_active == True,
|
||||||
|
)
|
||||||
|
folder_result = await session.execute(folder_query)
|
||||||
|
config_folders = folder_result.scalars().all()
|
||||||
|
|
||||||
|
# Write env file and config files
|
||||||
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
|
env_file_path = None
|
||||||
|
|
||||||
|
if env_vars:
|
||||||
|
env_file_path = write_env_file(instance_dir, env_vars)
|
||||||
|
logger.info("Wrote env file for restart of instance %s: %s", instance.id, env_file_path)
|
||||||
|
|
||||||
|
if config_files:
|
||||||
|
write_config_files(instance_dir, config_files)
|
||||||
|
logger.info("Wrote %d config files for restart of instance %s", len(config_files), instance.id)
|
||||||
|
|
||||||
|
# Write config folder files
|
||||||
|
if config_folders:
|
||||||
|
folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id))
|
||||||
|
extra_volumes.extend(folder_volumes)
|
||||||
|
logger.info("Wrote config folders with %d volume mounts for restart of instance %s", len(folder_volumes), instance.id)
|
||||||
|
|
||||||
|
# Modify compose file if needed
|
||||||
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
|
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes)
|
||||||
|
logger.info("Modified compose file for restart of instance %s", instance.id)
|
||||||
|
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
instance.compose_path, "restart"
|
instance.compose_path, "restart", env_file=env_file_path
|
||||||
)
|
)
|
||||||
|
|
||||||
if returncode == 0:
|
if returncode == 0:
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_folder import ConfigFolder
|
from src.models.config_folder import ConfigFolder
|
||||||
|
from src.models.config_include import ConfigInclude
|
||||||
|
from src.models.config_mount import ConfigMount
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -8,4 +11,17 @@ from src.models.tool_type import ToolType
|
|||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"ConfigFolder",
|
||||||
|
"ConfigInclude",
|
||||||
|
"ConfigMount",
|
||||||
|
"ConfigProfile",
|
||||||
|
"GitRepository",
|
||||||
|
"Project",
|
||||||
|
"SSHKey",
|
||||||
|
"ToolInstance",
|
||||||
|
"ToolType",
|
||||||
|
"User",
|
||||||
|
"UserConfig",
|
||||||
|
]
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
project_overrides: Mapped[dict | None] = mapped_column(
|
project_overrides: Mapped[dict | None] = mapped_column(
|
||||||
JSON, default=dict, nullable=True
|
JSON, default=dict, nullable=True
|
||||||
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
||||||
|
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
|
||||||
|
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_includes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="includes",
|
||||||
|
)
|
||||||
|
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[included_profile_id],
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, JSON, String
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_mounts"
|
||||||
|
|
||||||
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
|
||||||
|
files: Mapped[dict[str, str] | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="mounts",
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_include import ConfigInclude
|
||||||
|
from src.models.config_mount import ConfigMount
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
)
|
||||||
|
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
|
project: Mapped["Project | None"] = relationship()
|
||||||
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
|
includes: Mapped[list["ConfigInclude"]] = relationship(
|
||||||
|
"ConfigInclude",
|
||||||
|
foreign_keys="ConfigInclude.profile_id",
|
||||||
|
back_populates="profile",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ConfigInclude.order_index",
|
||||||
|
)
|
||||||
|
mounts: Mapped[list["ConfigMount"]] = relationship(
|
||||||
|
"ConfigMount",
|
||||||
|
back_populates="profile",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ConfigMount.order_index",
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from src.models.config_profile import ConfigProfile
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
@@ -62,8 +63,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
project: Mapped["Project"] = relationship()
|
project: Mapped["Project"] = relationship()
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
|
selected_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||||
|
|||||||
@@ -18,3 +18,23 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_profile_id(self) -> uuid.UUID | None:
|
||||||
|
profile_id = self.config.get("default_profile_id")
|
||||||
|
return uuid.UUID(profile_id) if profile_id else None
|
||||||
|
|
||||||
|
@default_profile_id.setter
|
||||||
|
def default_profile_id(self, value: uuid.UUID | None) -> None:
|
||||||
|
if value is not None:
|
||||||
|
self.config["default_profile_id"] = str(value)
|
||||||
|
elif "default_profile_id" in self.config:
|
||||||
|
del self.config["default_profile_id"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_profiles(self) -> dict[str, str]:
|
||||||
|
return self.config.get("default_profiles", {})
|
||||||
|
|
||||||
|
@default_profiles.setter
|
||||||
|
def default_profiles(self, value: dict[str, str]) -> None:
|
||||||
|
self.config["default_profiles"] = value
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
@@ -39,3 +39,18 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
|
|||||||
|
|
||||||
assert module.revision == "0002_refresh_tokens"
|
assert module.revision == "0002_refresh_tokens"
|
||||||
assert module.down_revision == "0001_initial_schema"
|
assert module.down_revision == "0001_initial_schema"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_config_profiles_migration_has_expected_revision_chain() -> None:
|
||||||
|
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
|
||||||
|
spec = spec_from_file_location("add_config_profiles", migration_path)
|
||||||
|
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.loader is not None
|
||||||
|
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
assert module.revision == "0013_add_config_profiles"
|
||||||
|
assert module.down_revision == "0012_default_port_req"
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
"""Unit tests for the profile resolver service."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.profile_resolver import (
|
||||||
|
ProfileCycleError,
|
||||||
|
ResolvedProfileOutput,
|
||||||
|
resolve_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_profile(
|
||||||
|
name: str,
|
||||||
|
env_vars: dict[str, str] | None = None,
|
||||||
|
start_command: str | None = None,
|
||||||
|
working_directory: str | None = None,
|
||||||
|
port: int | None = None,
|
||||||
|
mounts: list[MagicMock] | None = None,
|
||||||
|
includes: list[MagicMock] | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Create a mock ConfigProfile for testing."""
|
||||||
|
profile = MagicMock()
|
||||||
|
profile.id = uuid.uuid4()
|
||||||
|
profile.name = name
|
||||||
|
profile.environment_variables = env_vars or {}
|
||||||
|
profile.start_command = start_command
|
||||||
|
profile.working_directory = working_directory
|
||||||
|
profile.port = port
|
||||||
|
profile.mounts = mounts or []
|
||||||
|
profile.includes = includes or []
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _make_include(included_profile: MagicMock, order_index: int = 0) -> MagicMock:
|
||||||
|
"""Create a mock ConfigInclude for testing."""
|
||||||
|
include = MagicMock()
|
||||||
|
include.included_profile = included_profile
|
||||||
|
include.order_index = order_index
|
||||||
|
return include
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mount(
|
||||||
|
target_path: str,
|
||||||
|
mode: str = "rw",
|
||||||
|
files: dict[str, str] | None = None,
|
||||||
|
order_index: int = 0,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Create a mock ConfigMount for testing."""
|
||||||
|
mount = MagicMock()
|
||||||
|
mount.target_path = target_path
|
||||||
|
mount.mode = mode
|
||||||
|
mount.files = files or {}
|
||||||
|
mount.order_index = order_index
|
||||||
|
return mount
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileBasic:
|
||||||
|
"""Tests for basic profile resolution without includes."""
|
||||||
|
|
||||||
|
def test_empty_profile(self) -> None:
|
||||||
|
"""Resolving an empty profile returns empty output."""
|
||||||
|
profile = _make_profile("empty")
|
||||||
|
result = resolve_profile(profile)
|
||||||
|
|
||||||
|
assert isinstance(result, ResolvedProfileOutput)
|
||||||
|
assert result.profile_name == "empty"
|
||||||
|
assert result.environment_variables == {}
|
||||||
|
assert result.runtime_hints.start_command is None
|
||||||
|
assert result.runtime_hints.working_directory is None
|
||||||
|
assert result.runtime_hints.port is None
|
||||||
|
assert result.mounts == {}
|
||||||
|
assert result.resolution_order == ["empty"]
|
||||||
|
|
||||||
|
def test_env_vars_only(self) -> None:
|
||||||
|
"""Profile with env vars resolves correctly."""
|
||||||
|
profile = _make_profile(
|
||||||
|
"env-only",
|
||||||
|
env_vars={"FOO": "bar", "BAZ": "qux"},
|
||||||
|
)
|
||||||
|
result = resolve_profile(profile)
|
||||||
|
|
||||||
|
assert result.environment_variables == {"FOO": "bar", "BAZ": "qux"}
|
||||||
|
assert result.env_var_sources == {
|
||||||
|
"FOO": ["env-only"],
|
||||||
|
"BAZ": ["env-only"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_runtime_hints_only(self) -> None:
|
||||||
|
"""Profile with runtime hints resolves correctly."""
|
||||||
|
profile = _make_profile(
|
||||||
|
"hints-only",
|
||||||
|
start_command="python app.py",
|
||||||
|
working_directory="/app",
|
||||||
|
port=8080,
|
||||||
|
)
|
||||||
|
result = resolve_profile(profile)
|
||||||
|
|
||||||
|
assert result.runtime_hints.start_command == "python app.py"
|
||||||
|
assert result.runtime_hints.working_directory == "/app"
|
||||||
|
assert result.runtime_hints.port == 8080
|
||||||
|
assert result.runtime_hints.overridden_hints == {
|
||||||
|
"start_command": "hints-only",
|
||||||
|
"working_directory": "hints-only",
|
||||||
|
"port": "hints-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_mounts_only(self) -> None:
|
||||||
|
"""Profile with mounts resolves correctly."""
|
||||||
|
profile = _make_profile(
|
||||||
|
"mounts-only",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
mode="ro",
|
||||||
|
files={"settings.json": '{"key": "value"}'},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(profile)
|
||||||
|
|
||||||
|
assert "/config" in result.mounts
|
||||||
|
mount = result.mounts["/config"]
|
||||||
|
assert mount.target_path == "/config"
|
||||||
|
assert mount.mode == "ro"
|
||||||
|
assert mount.files == {"settings.json": '{"key": "value"}'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileIncludes:
|
||||||
|
"""Tests for profile resolution with includes."""
|
||||||
|
|
||||||
|
def test_single_include(self) -> None:
|
||||||
|
"""Profile with one include resolves in correct order."""
|
||||||
|
base = _make_profile("base", env_vars={"FOO": "base"})
|
||||||
|
derived = _make_profile(
|
||||||
|
"derived",
|
||||||
|
env_vars={"BAR": "derived"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
result = resolve_profile(derived)
|
||||||
|
|
||||||
|
assert result.resolution_order == ["derived", "base"]
|
||||||
|
assert result.environment_variables == {
|
||||||
|
"FOO": "base",
|
||||||
|
"BAR": "derived",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_multiple_includes_ordered(self) -> None:
|
||||||
|
"""Multiple includes are resolved in order_index order."""
|
||||||
|
first = _make_profile("first", env_vars={"KEY": "first"})
|
||||||
|
second = _make_profile("second", env_vars={"KEY": "second"})
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(first, order_index=0),
|
||||||
|
_make_include(second, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.resolution_order == ["main", "first", "second"]
|
||||||
|
# second overrides first
|
||||||
|
assert result.environment_variables == {"KEY": "second"}
|
||||||
|
assert result.env_var_sources["KEY"] == ["first", "second"]
|
||||||
|
|
||||||
|
def test_include_order_matters(self) -> None:
|
||||||
|
"""Changing include order changes resolution."""
|
||||||
|
a = _make_profile("a", env_vars={"KEY": "a"})
|
||||||
|
b = _make_profile("b", env_vars={"KEY": "b"})
|
||||||
|
main1 = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(a, order_index=0),
|
||||||
|
_make_include(b, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
main2 = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(b, order_index=0),
|
||||||
|
_make_include(a, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
result1 = resolve_profile(main1)
|
||||||
|
result2 = resolve_profile(main2)
|
||||||
|
|
||||||
|
assert result1.environment_variables["KEY"] == "b"
|
||||||
|
assert result2.environment_variables["KEY"] == "a"
|
||||||
|
|
||||||
|
def test_nested_includes(self) -> None:
|
||||||
|
"""Deeply nested includes resolve recursively."""
|
||||||
|
deep = _make_profile("deep", env_vars={"DEEP": "value"})
|
||||||
|
mid = _make_profile(
|
||||||
|
"mid",
|
||||||
|
env_vars={"MID": "value"},
|
||||||
|
includes=[_make_include(deep, order_index=0)],
|
||||||
|
)
|
||||||
|
top = _make_profile(
|
||||||
|
"top",
|
||||||
|
env_vars={"TOP": "value"},
|
||||||
|
includes=[_make_include(mid, order_index=0)],
|
||||||
|
)
|
||||||
|
result = resolve_profile(top)
|
||||||
|
|
||||||
|
assert result.resolution_order == ["top", "mid", "deep"]
|
||||||
|
assert result.environment_variables == {
|
||||||
|
"TOP": "value",
|
||||||
|
"MID": "value",
|
||||||
|
"DEEP": "value",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileOverrides:
|
||||||
|
"""Tests for deterministic override rules."""
|
||||||
|
|
||||||
|
def test_env_var_override(self) -> None:
|
||||||
|
"""Later layers override earlier env vars."""
|
||||||
|
base = _make_profile("base", env_vars={"KEY": "base"})
|
||||||
|
override = _make_profile("override", env_vars={"KEY": "override"})
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.environment_variables["KEY"] == "override"
|
||||||
|
assert result.env_var_sources["KEY"] == ["base", "override"]
|
||||||
|
|
||||||
|
def test_main_profile_wins_over_includes(self) -> None:
|
||||||
|
"""The main profile itself wins over all includes."""
|
||||||
|
base = _make_profile("base", env_vars={"KEY": "base"})
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
env_vars={"KEY": "main"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.environment_variables["KEY"] == "main"
|
||||||
|
assert result.env_var_sources["KEY"] == ["base", "main"]
|
||||||
|
|
||||||
|
def test_runtime_hint_override(self) -> None:
|
||||||
|
"""Later layers override earlier runtime hints."""
|
||||||
|
base = _make_profile("base", start_command="python old.py")
|
||||||
|
override = _make_profile("override", start_command="python new.py")
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.runtime_hints.start_command == "python new.py"
|
||||||
|
assert result.runtime_hints.overridden_hints["start_command"] == "override"
|
||||||
|
|
||||||
|
def test_mount_file_override(self) -> None:
|
||||||
|
"""Later layers override earlier files in the same mount."""
|
||||||
|
base = _make_profile(
|
||||||
|
"base",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
files={"app.json": '{"v": 1}'},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
override = _make_profile(
|
||||||
|
"override",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
files={"app.json": '{"v": 2}'},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
mount = result.mounts["/config"]
|
||||||
|
assert mount.files["app.json"] == '{"v": 2}'
|
||||||
|
assert mount.overridden_files["app.json"] == ["override"]
|
||||||
|
|
||||||
|
def test_mount_mode_override(self) -> None:
|
||||||
|
"""Later layers override mount mode."""
|
||||||
|
base = _make_profile(
|
||||||
|
"base",
|
||||||
|
mounts=[_make_mount("/data", mode="ro")],
|
||||||
|
)
|
||||||
|
override = _make_profile(
|
||||||
|
"override",
|
||||||
|
mounts=[_make_mount("/data", mode="rw")],
|
||||||
|
)
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
assert result.mounts["/data"].mode == "rw"
|
||||||
|
assert result.mounts["/data"].mode_overridden_by == "override"
|
||||||
|
|
||||||
|
def test_mount_file_merge(self) -> None:
|
||||||
|
"""Different files in the same mount are merged."""
|
||||||
|
base = _make_profile(
|
||||||
|
"base",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
files={"a.json": "1"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
override = _make_profile(
|
||||||
|
"override",
|
||||||
|
mounts=[
|
||||||
|
_make_mount(
|
||||||
|
"/config",
|
||||||
|
files={"b.json": "2"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
main = _make_profile(
|
||||||
|
"main",
|
||||||
|
includes=[
|
||||||
|
_make_include(base, order_index=0),
|
||||||
|
_make_include(override, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(main)
|
||||||
|
|
||||||
|
mount = result.mounts["/config"]
|
||||||
|
assert mount.files == {"a.json": "1", "b.json": "2"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileCycles:
|
||||||
|
"""Tests for cycle detection during resolution."""
|
||||||
|
|
||||||
|
def test_direct_cycle(self) -> None:
|
||||||
|
"""A -> B -> A is detected."""
|
||||||
|
a = _make_profile("a")
|
||||||
|
b = _make_profile("b", includes=[_make_include(a, order_index=0)])
|
||||||
|
a.includes = [_make_include(b, order_index=0)]
|
||||||
|
|
||||||
|
with pytest.raises(ProfileCycleError) as exc_info:
|
||||||
|
resolve_profile(a)
|
||||||
|
|
||||||
|
assert "a" in exc_info.value.cycle_path
|
||||||
|
assert "b" in exc_info.value.cycle_path
|
||||||
|
|
||||||
|
def test_indirect_cycle(self) -> None:
|
||||||
|
"""A -> B -> C -> A is detected."""
|
||||||
|
a = _make_profile("a")
|
||||||
|
c = _make_profile("c")
|
||||||
|
b = _make_profile("b", includes=[_make_include(c, order_index=0)])
|
||||||
|
a.includes = [_make_include(b, order_index=0)]
|
||||||
|
c.includes = [_make_include(a, order_index=0)]
|
||||||
|
|
||||||
|
with pytest.raises(ProfileCycleError) as exc_info:
|
||||||
|
resolve_profile(a)
|
||||||
|
|
||||||
|
assert "a" in exc_info.value.cycle_path
|
||||||
|
assert "b" in exc_info.value.cycle_path
|
||||||
|
assert "c" in exc_info.value.cycle_path
|
||||||
|
|
||||||
|
def test_self_cycle(self) -> None:
|
||||||
|
"""A -> A is detected."""
|
||||||
|
a = _make_profile("a")
|
||||||
|
a.includes = [_make_include(a, order_index=0)]
|
||||||
|
|
||||||
|
with pytest.raises(ProfileCycleError) as exc_info:
|
||||||
|
resolve_profile(a)
|
||||||
|
|
||||||
|
assert exc_info.value.cycle_path == ["a", "a"]
|
||||||
|
|
||||||
|
def test_cycle_does_not_partially_resolve(self) -> None:
|
||||||
|
"""Cycle detection prevents any partial resolution."""
|
||||||
|
a = _make_profile("a", env_vars={"A": "a"})
|
||||||
|
b = _make_profile("b", env_vars={"B": "b"})
|
||||||
|
a.includes = [_make_include(b, order_index=0)]
|
||||||
|
b.includes = [_make_include(a, order_index=0)]
|
||||||
|
|
||||||
|
with pytest.raises(ProfileCycleError):
|
||||||
|
resolve_profile(a)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfileDiamond:
|
||||||
|
"""Tests for diamond-shaped include graphs."""
|
||||||
|
|
||||||
|
def test_diamond_resolution(self) -> None:
|
||||||
|
"""Diamond graph resolves correctly without duplication issues."""
|
||||||
|
base = _make_profile("base", env_vars={"BASE": "base"})
|
||||||
|
left = _make_profile(
|
||||||
|
"left",
|
||||||
|
env_vars={"LEFT": "left"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
right = _make_profile(
|
||||||
|
"right",
|
||||||
|
env_vars={"RIGHT": "right"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
top = _make_profile(
|
||||||
|
"top",
|
||||||
|
env_vars={"TOP": "top"},
|
||||||
|
includes=[
|
||||||
|
_make_include(left, order_index=0),
|
||||||
|
_make_include(right, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(top)
|
||||||
|
|
||||||
|
# base should appear once (via left, then right skips because visited)
|
||||||
|
assert result.resolution_order == ["top", "left", "base", "right"]
|
||||||
|
assert result.environment_variables == {
|
||||||
|
"TOP": "top",
|
||||||
|
"LEFT": "left",
|
||||||
|
"RIGHT": "right",
|
||||||
|
"BASE": "base",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_diamond_override(self) -> None:
|
||||||
|
"""Diamond graph with conflicting overrides resolves correctly."""
|
||||||
|
base = _make_profile("base", env_vars={"KEY": "base"})
|
||||||
|
left = _make_profile(
|
||||||
|
"left",
|
||||||
|
env_vars={"KEY": "left"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
right = _make_profile(
|
||||||
|
"right",
|
||||||
|
env_vars={"KEY": "right"},
|
||||||
|
includes=[_make_include(base, order_index=0)],
|
||||||
|
)
|
||||||
|
top = _make_profile(
|
||||||
|
"top",
|
||||||
|
includes=[
|
||||||
|
_make_include(left, order_index=0),
|
||||||
|
_make_include(right, order_index=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = resolve_profile(top)
|
||||||
|
|
||||||
|
# right wins because it's later
|
||||||
|
assert result.environment_variables["KEY"] == "right"
|
||||||
|
assert result.env_var_sources["KEY"] == ["base", "left", "right"]
|
||||||
|
# Note: base appears once because visited set skips duplicate resolution in diamond graphs
|
||||||
Reference in New Issue
Block a user