diff --git a/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py b/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py index a951276..50b86da 100644 --- a/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py +++ b/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py @@ -5,6 +5,7 @@ Revises: 2026_05_29_fix_code_server_bind_addr Create Date: 2026-05-29 15:05:00.000000 """ + from collections.abc import Sequence from alembic import op diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 00912b8..6c8e04e 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -52,7 +52,6 @@ from src.services.docker import ( find_free_port, get_container_id, get_container_logs, - get_container_name, get_container_status, recreate_tunnel, render_compose_template, @@ -618,6 +617,41 @@ def _modify_compose_file( compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) +def _ensure_container_name_in_compose(compose_path: str, container_name: str) -> None: + """Ensure compose file has explicit container_name for predictable naming. + + Docker Compose auto-generates container names from the project directory + when container_name is absent. This breaks tunnel connectivity because + get_container_name(instance.name) cannot find the container. We inject + container_name into every service so the container has a predictable name. + """ + import yaml + from pathlib import Path + + compose_file = Path(compose_path) + if not compose_file.exists(): + return + + content = compose_file.read_text() + compose_data = yaml.safe_load(content) + + if not compose_data or "services" not in compose_data: + return + + modified = False + for svc_name, svc_config in compose_data["services"].items(): + if "container_name" not in svc_config: + svc_config["container_name"] = container_name.lower() + modified = True + + if modified: + compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) + logger.info( + "Injected container_name '%s' into compose file", + container_name.lower(), + ) + + def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: """Auto-inject bind address for known web tools that default to 127.0.0.1. @@ -1641,6 +1675,9 @@ async def start_instance( if tool_type and tool_type.interface_type == "web": _ensure_web_bind_address(instance.compose_path, tool_type.name) + # Ensure predictable container name for tunnel connectivity + _ensure_container_name_in_compose(instance.compose_path, instance.name) + # Execute docker compose up with env file logger.debug( "Running docker compose up for instance %s (compose_path=%s)", @@ -1667,24 +1704,23 @@ async def start_instance( detail=f"failed to start instance: {stderr}", ) - # Get container ID and name - container_id = get_container_id(instance.name) + # Get container ID and name (use predictable name from compose) + expected_container_name = instance.name.lower() + container_id = get_container_id(expected_container_name) if container_id: instance.container_id = container_id logger.debug("Container ID for instance %s: %s", instance.id, container_id) - container_name = get_container_name(instance.name) - if container_name: - instance.container_name = container_name - logger.debug("Container name for instance %s: %s", instance.id, container_name) + instance.container_name = expected_container_name + logger.debug("Container name for instance %s: %s", instance.id, expected_container_name) - # Connect container to backend network so API can reach it - logger.debug("Connecting container %s to backend network...", container_name) - connected = connect_container_to_network(container_name, "backend") - if connected: - logger.debug("Successfully connected %s to backend network", container_name) - else: - logger.warning("Failed to connect %s to backend network", container_name) + # Connect container to backend network so API can reach it + logger.debug("Connecting container %s to backend network...", expected_container_name) + connected = connect_container_to_network(expected_container_name, "backend") + if connected: + logger.debug("Successfully connected %s to backend network", expected_container_name) + else: + logger.warning("Failed to connect %s to backend network", expected_container_name) # Verify container reached running state if instance.container_id: @@ -2111,6 +2147,13 @@ async def restart_instance( exc, ) + # Re-apply compose fixes in case they were updated since last start + _sanitize_compose_file(instance.compose_path) + tool_type = await session.get(ToolType, instance.tool_type_id) + if tool_type and tool_type.interface_type == "web": + _ensure_web_bind_address(instance.compose_path, tool_type.name) + _ensure_container_name_in_compose(instance.compose_path, instance.name) + returncode, stdout, stderr = execute_compose_command( instance.compose_path, "restart" ) @@ -2140,7 +2183,7 @@ async def restart_instance( # Create new temporary tunnel try: tunnel_info = start_cloudflared_tunnel( - container_name=instance.container_name or instance.name, + container_name=instance.name.lower(), port=instance_port, ) instance.tunnel_id = tunnel_info["pid"] diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index f583c1e..49b7ba7 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -159,7 +159,7 @@ def execute_compose_command( cmd.extend(["--env-file", env_file]) if action == "up": - cmd.extend(["up", "-d"]) + cmd.extend(["up", "-d", "--force-recreate"]) elif action == "down": cmd.extend(["down", "-v"]) elif action in ("start", "stop", "restart"): diff --git a/apps/api/tests/integration/test_notifications_lifecycle.py b/apps/api/tests/integration/test_notifications_lifecycle.py index 3e76fac..0d35aa3 100644 --- a/apps/api/tests/integration/test_notifications_lifecycle.py +++ b/apps/api/tests/integration/test_notifications_lifecycle.py @@ -149,8 +149,8 @@ async def test_lifecycle_running_creates_notification( assert len(notifications) == 1 n = notifications[0] assert n.category == "instance" - assert n.severity == "info" - assert n.title == "Health Changed" + assert n.severity == "success" + assert n.title == "Container ready" assert n.source_type == "tool_instances" assert n.source_id == test_instance.id @@ -315,9 +315,9 @@ async def test_notification_ownership_matches_instance_owner( event_bus=event_bus, session=db_session, instance=instance, - event_type="instance.created", - status="pending", - message="Instance created", + event_type="instance.health_changed", + status="running", + message="Container running", ) result = await db_session.execute(