From 9cc98455ef9b1523c06e8b96be0fcfd336048052 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 24 May 2026 14:41:17 +0000 Subject: [PATCH] 3.2 Launch and restart profile application (el-5z8) --- apps/api/src/api/tool_instances.py | 202 ++++++++++++++++++++++++++--- 1 file changed, 186 insertions(+), 16 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 0e641fb..3b09f45 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -42,6 +42,7 @@ from src.services.docker import ( write_config_folder_files, ) from src.services.docker_build import build_image +from src.services.profile_resolver import resolve_profile from src.services.readiness_probe import execute_probe router = APIRouter(prefix="/projects", tags=["tool-instances"]) @@ -109,6 +110,68 @@ def _modify_compose_file( 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: """Fetch a user by ID or raise 404 if not found.""" user = await session.get(User, user_id) @@ -481,21 +544,6 @@ async def start_instance( extra_env_vars = {} extra_volumes = [] - if instance.selected_profile_id: - # Validate the selected config profile - 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", - ) - logger.info("Using selected config profile %s for instance %s", instance.selected_profile_id, instance.id) - # Fetch all matching configs for this tool type config_query = select(ToolConfig).where( ToolConfig.user_id == user_id, @@ -529,6 +577,31 @@ async def start_instance( # Merge 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 folder_query = select(ConfigFolder).where( ConfigFolder.user_id == user_id, @@ -800,8 +873,105 @@ async def restart_instance( 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): + # 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( - instance.compose_path, "restart" + instance.compose_path, "restart", env_file=env_file_path ) if returncode == 0: