diff --git a/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py b/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py new file mode 100644 index 0000000..9058a7a --- /dev/null +++ b/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py @@ -0,0 +1,140 @@ +"""fix web tool bind address to 0.0.0.0 + +Revision ID: 2026_05_29_fix_web_tool_bind_address +Revises: 2026_05_29_remove_ssh_keys_mount_from_manifest +Create Date: 2026-05-29 14:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "2026_05_29_fix_web_tool_bind_address" +down_revision: Union[str, None] = "2026_05_29_remove_ssh_keys_mount_from_manifest" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _fix_code_server_compose(conn) -> None: + """Update code-server compose template to bind to 0.0.0.0.""" + result = conn.execute( + sa.text(""" + SELECT id, compose_template, definition_type + FROM tool_types + WHERE name = 'code-server' + """) + ).fetchone() + + if result is None: + return + + tool_id, compose_template, definition_type = result + + if definition_type != "compose" or not compose_template: + return + + # Fix or add command to bind to 0.0.0.0 + lines = compose_template.split("\n") + new_lines = [] + image_line_idx = -1 + command_fixed = False + for i, line in enumerate(lines): + # Replace broken --bind-addr with correct --host + if "command:" in line and "--bind-addr" in line: + indent = line[: len(line) - len(line.lstrip())] + new_lines.append(f"{indent}command: --host 0.0.0.0") + command_fixed = True + continue + new_lines.append(line) + if "image:" in line and image_line_idx == -1: + image_line_idx = i + + # If no command line exists, insert one after image + if not command_fixed and image_line_idx != -1: + image_line = lines[image_line_idx] + indent = image_line[: len(image_line) - len(image_line.lstrip())] + # Insert after the image line in new_lines + insert_idx = new_lines.index(image_line) + 1 + new_lines.insert(insert_idx, f"{indent}command: --host 0.0.0.0") + command_fixed = True + + if not command_fixed: + return + + updated_compose = "\n".join(new_lines) + + conn.execute( + sa.text(""" + UPDATE tool_types + SET compose_template = :compose_template + WHERE id = :id + """), + {"compose_template": updated_compose, "id": tool_id}, + ) + + print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0") + + +def _fix_jupyter_compose(conn) -> None: + """Update jupyter-notebook compose template to bind to 0.0.0.0.""" + result = conn.execute( + sa.text(""" + SELECT id, compose_template, definition_type + FROM tool_types + WHERE name = 'jupyter-notebook' + """) + ).fetchone() + + if result is None: + return + + tool_id, compose_template, definition_type = result + + if definition_type != "compose" or not compose_template: + return + + if "command:" in compose_template: + return + + lines = compose_template.split("\n") + new_lines = [] + image_line_idx = -1 + for i, line in enumerate(lines): + new_lines.append(line) + if "image:" in line and image_line_idx == -1: + image_line_idx = i + indent = line[: len(line) - len(line.lstrip())] + # Jupyter needs --ip=0.0.0.0 to bind to all interfaces + new_lines.append( + f"{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser" + ) + + if image_line_idx == -1: + return + + updated_compose = "\n".join(new_lines) + + conn.execute( + sa.text(""" + UPDATE tool_types + SET compose_template = :compose_template + WHERE id = :id + """), + {"compose_template": updated_compose, "id": tool_id}, + ) + + print(f"Updated jupyter-notebook tool type ({tool_id}) to bind to 0.0.0.0:8888") + + +def upgrade() -> None: + conn = op.get_bind() + _fix_code_server_compose(conn) + _fix_jupyter_compose(conn) + + +def downgrade() -> None: + # Cannot safely downgrade without knowing the original compose_template + pass diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 05e9d54..32ee7c1 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -618,6 +618,60 @@ def _modify_compose_file( compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) +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. + + Many web tools (code-server, jupyter) bind to localhost by default, + making them inaccessible from the Docker network. This function detects + known tool images and injects the correct --bind-addr or --ip flag. + """ + 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", + } + + bind_command = KNOWN_BIND_FIXES.get(tool_type_name) + if not bind_command: + return + + 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 + + for service_config in compose_data["services"].values(): + # Skip if command is already overridden + if "command" in service_config: + return + + image = service_config.get("image", "") + if not image: + return + + # Check if the image matches a known tool + if tool_type_name == "code-server" and ( + "code-server" in image or "coder" in image + ): + service_config["command"] = bind_command + break + if tool_type_name == "jupyter-notebook" and ( + "jupyter" in image or "notebook" in image + ): + service_config["command"] = bind_command + break + + compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) + logger.info("Injected bind address for %s: %s", tool_type_name, bind_command) + + @router.post( "/{project_id}/repositories/{repo_id}/instances", summary="Create tool instance", @@ -854,7 +908,7 @@ services: ) # Determine home directory for path expansion - home_dir = get_manifest_home_dir(manifest) + _home_dir = get_manifest_home_dir(manifest) image_tag = compute_image_tag(tool_type.name, manifest) @@ -1550,6 +1604,10 @@ async def start_instance( # Sanitize compose file to remove invalid port mappings from old instances _sanitize_compose_file(instance.compose_path) + # 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) + # Execute docker compose up with env file logger.debug( "Running docker compose up for instance %s (compose_path=%s)", diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index fd05903..f583c1e 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -384,9 +384,7 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int: raise RuntimeError(f"No free port found in range {start}-{end}") -def _check_app_binding( - container_name: str, port: int -) -> dict[str, str | bool]: +def _check_app_binding(container_name: str, port: int) -> dict[str, str | bool]: """Diagnose whether the app is bound to 127.0.0.1 or 0.0.0.0. Checks from both inside the container (localhost) and outside 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 974f815..be7755c 100644 --- a/apps/api/tests/integration/test_tool_types_api_extended.py +++ b/apps/api/tests/integration/test_tool_types_api_extended.py @@ -6,7 +6,9 @@ from fastapi.testclient import TestClient class TestToolTypesAPIExtended: """Integration tests for tool types API with new fields.""" - def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_dockerfile( + self, authenticated_client: TestClient + ) -> None: """Test creating a tool type with dockerfile definition.""" response = authenticated_client.post( "/tool-types", @@ -27,7 +29,9 @@ class TestToolTypesAPIExtended: assert data["definition_type"] == "dockerfile" assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask" - def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_readiness_probe( + self, authenticated_client: TestClient + ) -> None: """Test creating a tool type with readiness probe.""" response = authenticated_client.post( "/tool-types", @@ -52,7 +56,9 @@ class TestToolTypesAPIExtended: assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080" assert data["readiness_probe"]["timeout"] == 30 - def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_invalid_definition_type( + self, authenticated_client: TestClient + ) -> None: """Test that invalid definition types are rejected.""" response = authenticated_client.post( "/tool-types", @@ -67,7 +73,9 @@ class TestToolTypesAPIExtended: ) assert response.status_code == 422 - def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_dockerfile_without_template( + self, authenticated_client: TestClient + ) -> None: """Test that dockerfile type requires dockerfile_template.""" response = authenticated_client.post( "/tool-types", @@ -81,7 +89,9 @@ class TestToolTypesAPIExtended: ) assert response.status_code == 422 - def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None: + def test_update_tool_type_with_new_fields( + self, authenticated_client: TestClient + ) -> None: """Test updating a tool type with new fields.""" # Create tool type first create_response = authenticated_client.post( @@ -112,7 +122,9 @@ class TestToolTypesAPIExtended: assert response.status_code == 200 data = response.json() assert data["display_name"] == "Updated Name" - assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health" + assert ( + data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health" + ) def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None: """Test validating compose template.""" @@ -127,7 +139,9 @@ class TestToolTypesAPIExtended: data = response.json() assert data["valid"] is True - def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None: + def test_validate_tool_type_invalid_compose( + self, authenticated_client: TestClient + ) -> None: """Test validating invalid compose template.""" response = authenticated_client.post( "/tool-types/validate", @@ -141,7 +155,9 @@ class TestToolTypesAPIExtended: assert data["valid"] is False assert "errors" in data - def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None: + def test_validate_tool_type_dockerfile( + self, authenticated_client: TestClient + ) -> None: """Test validating dockerfile template.""" response = authenticated_client.post( "/tool-types/validate", @@ -154,7 +170,9 @@ class TestToolTypesAPIExtended: data = response.json() assert data["valid"] is True - def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None: + def test_get_tool_type_returns_new_fields( + self, authenticated_client: TestClient + ) -> None: """Test that GET returns new fields.""" # Create tool type with all fields create_response = authenticated_client.post( @@ -166,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 ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", + "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\"", "readiness_probe": { "command": "curl -f http://localhost:8443", "timeout": 30, @@ -186,7 +204,9 @@ class TestToolTypesAPIExtended: assert data["interfaces"] == ["web", "terminal"] assert "readiness_probe" in data - def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_without_port_fails( + self, authenticated_client: TestClient + ) -> None: """Test that creating a tool type without default_port fails validation.""" response = authenticated_client.post( "/tool-types", @@ -204,7 +224,9 @@ class TestToolTypesAPIExtended: data = response.json() assert "default_port" in str(data) - def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_port_mismatch_fails( + self, authenticated_client: TestClient + ) -> None: """Test that port mismatch between default_port and compose template fails.""" response = authenticated_client.post( "/tool-types", @@ -222,7 +244,9 @@ class TestToolTypesAPIExtended: assert response.status_code == 422 _ = response.json() - def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_startup_command( + self, authenticated_client: TestClient + ) -> None: """Test creating a tool type with startup_command.""" response = authenticated_client.post( "/tool-types", @@ -244,7 +268,9 @@ class TestToolTypesAPIExtended: assert data["startup_command"] == "cd /workspace && ls" assert data["interface_type"] == "terminal" - def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None: + def test_update_tool_type_startup_command( + self, authenticated_client: TestClient + ) -> None: """Test updating a tool type's startup_command.""" # Create tool type first create_response = authenticated_client.post( @@ -273,7 +299,9 @@ class TestToolTypesAPIExtended: data = response.json() assert data["startup_command"] == "source /etc/profile" - def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None: + def test_get_tool_type_returns_startup_command( + self, authenticated_client: TestClient + ) -> None: """Test that GET returns startup_command.""" create_response = authenticated_client.post( "/tool-types",