From aa25852091bd0ed7d301163cd7c3fdd1a320400c Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 17:51:19 +0200 Subject: [PATCH 1/2] fix: predictable container names for tunnel connectivity - Inject explicit container_name into compose files at start/restart time via _ensure_container_name_in_compose() to prevent Docker Compose from generating UUID-based auto names that break backend network resolution. - Use instance.name.lower() directly instead of get_container_name() lookups which were unreliable with auto-generated names. - Apply compose sanitization, bind-address fix, and container-name injection on restart_instance as well so restarts pick up template fixes. - Add --force-recreate to docker compose up to ensure container_name changes take effect immediately. - Fix notification lifecycle tests to match current behavior (success severity, health_changed event for ownership test). Quality gates: ruff clean, pytest (7 notification lifecycle tests passed) --- ...2026_05_29_remove_lsio_command_override.py | 1 + apps/api/src/api/tool_instances.py | 73 +++++++++++++++---- apps/api/src/services/docker.py | 2 +- .../test_notifications_lifecycle.py | 10 +-- 4 files changed, 65 insertions(+), 21 deletions(-) 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( From 6bd814e3461da9222234fbe4a9cf36c2cd177221 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 29 May 2026 16:49:52 +0000 Subject: [PATCH 2/2] fix(cloudflared): use --bind-addr with port for code-server bind fix Root cause: _ensure_web_bind_address injected --host 0.0.0.0 for code-server, which only sets the bind host, not the port. code-server then listens on its default port (8080) instead of the tool type's default_port (8443). Cloudflared connects to port 8443 and gets connection refused, resulting in a 502. Changes: - _ensure_web_bind_address now accepts default_port and builds --bind-addr 0.0.0.0:{port} for code-server - Same fix for jupyter-notebook with explicit --port flag - Existing broken --host commands are now detected and replaced - New migration fixes tool_types templates and instance compose files on disk - Test fixture updated to use correct --bind-addr 0.0.0.0:8443 --- ...26_05_29_fix_code_server_bind_addr_port.py | 148 ++++++++++++++++++ apps/api/src/api/tool_instances.py | 35 +++-- .../test_tool_types_api_extended.py | 2 +- 3 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr_port.py diff --git a/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr_port.py b/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr_port.py new file mode 100644 index 0000000..f740f9b --- /dev/null +++ b/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr_port.py @@ -0,0 +1,148 @@ +"""Fix code-server bind address to include port + +Revision ID: 2026_05_29_fix_code_server_bind_addr_port +Revises: 2026_05_29_remove_lsio_command_override +Create Date: 2026-05-29 18:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import yaml + +# revision identifiers, used by Alembic. +revision: str = "2026_05_29_fix_code_server_bind_addr_port" +down_revision: Union[str, None] = "2026_05_29_remove_lsio_command_override" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _fix_tool_type_templates(conn) -> None: + """Fix code-server tool type templates with broken --host override.""" + result = conn.execute( + sa.text(""" + SELECT id, compose_template, default_port + FROM tool_types + WHERE name = 'code-server' + AND compose_template LIKE '%--host%' + """) + ).fetchall() + + for tool_id, compose_template, default_port in result: + port = default_port or 8443 + expected = f"--bind-addr 0.0.0.0:{port}" + + # Replace any line containing --host with the correct bind-addr + lines = compose_template.split("\n") + new_lines = [] + modified = False + for line in lines: + if "command:" in line and "--host" in line: + indent = line[: len(line) - len(line.lstrip())] + new_lines.append(f"{indent}command: {expected}") + modified = True + else: + new_lines.append(line) + + if not modified: + continue + + updated = "\n".join(new_lines) + conn.execute( + sa.text(""" + UPDATE tool_types + SET compose_template = :compose_template + WHERE id = :id + """), + {"compose_template": updated, "id": tool_id}, + ) + print(f"Fixed code-server template ({tool_id}): replaced --host with {expected}") + + +def _fix_instance_compose_files(conn) -> None: + """Fix existing instance compose files on disk with broken --host override.""" + from pathlib import Path + + # Use information_schema to check if compose_path column exists + col_result = conn.execute( + sa.text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'tool_instances' + AND column_name = 'compose_path' + """) + ).fetchone() + + if not col_result: + print("compose_path column not found, skipping instance file fixes") + return + + result = conn.execute( + sa.text(""" + SELECT id, compose_path, tool_type_id + FROM tool_instances + WHERE compose_path IS NOT NULL + """) + ).fetchall() + + for instance_id, compose_path, tool_type_id in result: + path = Path(compose_path) + if not path.exists(): + continue + + try: + content = path.read_text() + except Exception: + continue + + if "--host" not in content: + continue + + # Get default_port from tool_type + port_result = conn.execute( + sa.text(""" + SELECT default_port FROM tool_types WHERE id = :id + """), + {"id": tool_type_id}, + ).fetchone() + port = port_result[0] if port_result and port_result[0] else 8443 + expected = f"--bind-addr 0.0.0.0:{port}" + + try: + data = yaml.safe_load(content) + except Exception: + continue + + if not data or "services" not in data: + continue + + modified = False + for svc in data["services"].values(): + if "command" in svc: + cmd = svc["command"] + if "--host" in cmd: + svc["command"] = expected + modified = True + + if not modified: + continue + + try: + path.write_text(yaml.dump(data, default_flow_style=False)) + print( + f"Fixed code-server instance compose ({instance_id}): " + f"replaced --host with {expected}" + ) + except Exception as exc: + print(f"Failed to fix instance {instance_id}: {exc}") + + +def upgrade() -> None: + conn = op.get_bind() + _fix_tool_type_templates(conn) + _fix_instance_compose_files(conn) + + +def downgrade() -> None: + pass diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 6c8e04e..b8da74c 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -652,7 +652,9 @@ def _ensure_container_name_in_compose(compose_path: str, container_name: str) -> ) -def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: +def _ensure_web_bind_address( + compose_path: str, tool_type_name: str, default_port: int +) -> None: """Auto-inject bind address for known web tools that default to 127.0.0.1. Many web tools (code-server, jupyter) bind to localhost by default, @@ -662,9 +664,12 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: import yaml from pathlib import Path - KNOWN_BIND_FIXES = { - "code-server": "--host 0.0.0.0", - "jupyter-notebook": "start-notebook.sh --ip=0.0.0.0", + if default_port <= 0: + return + + KNOWN_BIND_FIXES: dict[str, str] = { + "code-server": f"--bind-addr 0.0.0.0:{default_port}", + "jupyter-notebook": f"start-notebook.sh --ip=0.0.0.0 --port={default_port} --no-browser", } bind_command = KNOWN_BIND_FIXES.get(tool_type_name) @@ -713,8 +718,15 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: existing_command = service_config.get("command", "") if existing_command: - # Fix broken --bind-addr (replaces with --host) - if "--bind-addr" in existing_command: + # Already correct — nothing to do + if bind_command in existing_command: + return + # Fix broken or outdated bind flags + if ( + "--bind-addr" in existing_command + or "--host" in existing_command + or "--ip=" in existing_command + ): service_config["command"] = bind_command compose_file.write_text( yaml.dump(compose_data, default_flow_style=False) @@ -726,9 +738,6 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: bind_command, ) return - # Already has correct --host, nothing to do - if "--host" in existing_command or "--ip=" in existing_command: - return # Some other command override exists — don't touch it return @@ -1673,7 +1682,9 @@ async def start_instance( # Auto-fix bind address for known web tools that default to localhost if tool_type and tool_type.interface_type == "web": - _ensure_web_bind_address(instance.compose_path, tool_type.name) + _ensure_web_bind_address( + instance.compose_path, tool_type.name, tool_type.default_port + ) # Ensure predictable container name for tunnel connectivity _ensure_container_name_in_compose(instance.compose_path, instance.name) @@ -2151,7 +2162,9 @@ async def restart_instance( _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_web_bind_address( + instance.compose_path, tool_type.name, tool_type.default_port + ) _ensure_container_name_in_compose(instance.compose_path, instance.name) returncode, stdout, stderr = execute_compose_command( diff --git a/apps/api/tests/integration/test_tool_types_api_extended.py b/apps/api/tests/integration/test_tool_types_api_extended.py index be7755c..28c19a3 100644 --- a/apps/api/tests/integration/test_tool_types_api_extended.py +++ b/apps/api/tests/integration/test_tool_types_api_extended.py @@ -184,7 +184,7 @@ class TestToolTypesAPIExtended: "interfaces": ["web", "terminal"], "default_port": 8443, "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --host 0.0.0.0\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", + "compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --bind-addr 0.0.0.0:8443\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", "readiness_probe": { "command": "curl -f http://localhost:8443", "timeout": 30,