diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 0dbf789..ee83ed1 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -45,10 +45,10 @@ from src.services.config_profile_resolver import ( resolve_profile, ) from src.services.docker import ( - connect_container_to_network, ensure_instance_directory, execute_compose_command, find_free_port, + get_backend_network_name, get_container_id, get_container_logs, get_container_status, @@ -750,6 +750,49 @@ def _ensure_web_bind_address( return +def _ensure_backend_network_in_compose(compose_path: str) -> None: + """Inject the backend network into the compose file so compose up attaches it. + + Instead of running 'docker network connect' after container creation (which + is prone to race conditions and silent failures), we declare the network in + the compose file itself. Docker Compose then connects the container to the + network atomically during 'docker compose up'. + """ + 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 + + network_name = get_backend_network_name() + modified = False + + for svc_config in compose_data["services"].values(): + existing = svc_config.get("networks", []) + if network_name not in existing: + svc_config["networks"] = existing + [network_name] + modified = True + break # Only modify first service + + # Declare the network as external at the top level + if "networks" not in compose_data: + compose_data["networks"] = {} + if network_name not in compose_data["networks"]: + compose_data["networks"][network_name] = {"external": True} + modified = True + + if modified: + compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) + logger.info("Injected backend network '%s' into compose file", network_name) + + @router.post( "/{project_id}/repositories/{repo_id}/instances", summary="Create tool instance", @@ -1690,6 +1733,7 @@ async def start_instance( # Ensure predictable container name for tunnel connectivity _ensure_container_name_in_compose(instance.compose_path, instance.name) + _ensure_backend_network_in_compose(instance.compose_path) # Execute docker compose up with env file logger.debug( @@ -1729,20 +1773,6 @@ async def start_instance( "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...", expected_container_name - ) - connected = connect_container_to_network(expected_container_name) - 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: instance.status = "starting" @@ -2174,6 +2204,7 @@ async def restart_instance( instance.compose_path, tool_type.name, tool_type.default_port ) _ensure_container_name_in_compose(instance.compose_path, instance.name) + _ensure_backend_network_in_compose(instance.compose_path) returncode, stdout, stderr = execute_compose_command( instance.compose_path, "restart"