fix(cloudflared): use --host 0.0.0.0 instead of --bind-addr for code-server

The --bind-addr flag caused code-server to fail entirely (app not
responding on any interface). The correct override for the
coder/code-server image is --host 0.0.0.0, which overrides the
entrypoint's --host 127.0.0.1.

Changes:
- Migration: Replace --bind-addr with --host 0.0.0.0, also handle
  existing broken templates by detecting --bind-addr and replacing it
- Runtime safety net: _ensure_web_bind_address uses --host 0.0.0.0
- Test fixture: Updated compose template to match

Quality gates: pytest 42 passed
This commit is contained in:
2026-05-29 16:36:09 +02:00
parent 3c57c8b78b
commit 1efbc289ba
3 changed files with 68 additions and 32 deletions
@@ -5,6 +5,7 @@ Revises: 2026_05_29_remove_ssh_keys_mount_from_manifest
Create Date: 2026-05-29 14:00:00.000000 Create Date: 2026-05-29 14:00:00.000000
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -35,25 +36,32 @@ def _fix_code_server_compose(conn) -> None:
if definition_type != "compose" or not compose_template: if definition_type != "compose" or not compose_template:
return return
# Add command to bind to 0.0.0.0 if not already present # Fix or add command to bind to 0.0.0.0
if "command:" in compose_template:
# Already has a command override, skip
return
# Insert command line after the image line
lines = compose_template.split("\n") lines = compose_template.split("\n")
new_lines = [] new_lines = []
image_line_idx = -1 image_line_idx = -1
command_fixed = False
for i, line in enumerate(lines): 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) new_lines.append(line)
if "image:" in line and image_line_idx == -1: if "image:" in line and image_line_idx == -1:
image_line_idx = i image_line_idx = i
# Insert command with proper indentation (same as image line)
indent = line[: len(line) - len(line.lstrip())]
new_lines.append(f"{indent}command: --bind-addr 0.0.0.0:8443")
if image_line_idx == -1: # If no command line exists, insert one after image
# No image line found, can't safely modify 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 return
updated_compose = "\n".join(new_lines) updated_compose = "\n".join(new_lines)
@@ -67,7 +75,7 @@ def _fix_code_server_compose(conn) -> None:
{"compose_template": updated_compose, "id": tool_id}, {"compose_template": updated_compose, "id": tool_id},
) )
print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0:8443") print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0")
def _fix_jupyter_compose(conn) -> None: def _fix_jupyter_compose(conn) -> None:
@@ -100,7 +108,9 @@ def _fix_jupyter_compose(conn) -> None:
image_line_idx = i image_line_idx = i
indent = line[: len(line) - len(line.lstrip())] indent = line[: len(line) - len(line.lstrip())]
# Jupyter needs --ip=0.0.0.0 to bind to all interfaces # 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') new_lines.append(
f"{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser"
)
if image_line_idx == -1: if image_line_idx == -1:
return return
+2 -4
View File
@@ -629,7 +629,7 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
from pathlib import Path from pathlib import Path
KNOWN_BIND_FIXES = { KNOWN_BIND_FIXES = {
"code-server": "--bind-addr 0.0.0.0:8443", "code-server": "--host 0.0.0.0",
"jupyter-notebook": "start-notebook.sh --ip=0.0.0.0", "jupyter-notebook": "start-notebook.sh --ip=0.0.0.0",
} }
@@ -669,9 +669,7 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
break break
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
logger.info( logger.info("Injected bind address for %s: %s", tool_type_name, bind_command)
"Injected bind address for %s: %s", tool_type_name, bind_command
)
@router.post( @router.post(
@@ -6,7 +6,9 @@ from fastapi.testclient import TestClient
class TestToolTypesAPIExtended: class TestToolTypesAPIExtended:
"""Integration tests for tool types API with new fields.""" """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.""" """Test creating a tool type with dockerfile definition."""
response = authenticated_client.post( response = authenticated_client.post(
"/tool-types", "/tool-types",
@@ -27,7 +29,9 @@ class TestToolTypesAPIExtended:
assert data["definition_type"] == "dockerfile" assert data["definition_type"] == "dockerfile"
assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask" 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.""" """Test creating a tool type with readiness probe."""
response = authenticated_client.post( response = authenticated_client.post(
"/tool-types", "/tool-types",
@@ -52,7 +56,9 @@ class TestToolTypesAPIExtended:
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080" assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
assert data["readiness_probe"]["timeout"] == 30 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.""" """Test that invalid definition types are rejected."""
response = authenticated_client.post( response = authenticated_client.post(
"/tool-types", "/tool-types",
@@ -67,7 +73,9 @@ class TestToolTypesAPIExtended:
) )
assert response.status_code == 422 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.""" """Test that dockerfile type requires dockerfile_template."""
response = authenticated_client.post( response = authenticated_client.post(
"/tool-types", "/tool-types",
@@ -81,7 +89,9 @@ class TestToolTypesAPIExtended:
) )
assert response.status_code == 422 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.""" """Test updating a tool type with new fields."""
# Create tool type first # Create tool type first
create_response = authenticated_client.post( create_response = authenticated_client.post(
@@ -112,7 +122,9 @@ class TestToolTypesAPIExtended:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["display_name"] == "Updated Name" 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: def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
"""Test validating compose template.""" """Test validating compose template."""
@@ -127,7 +139,9 @@ class TestToolTypesAPIExtended:
data = response.json() data = response.json()
assert data["valid"] is True 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.""" """Test validating invalid compose template."""
response = authenticated_client.post( response = authenticated_client.post(
"/tool-types/validate", "/tool-types/validate",
@@ -141,7 +155,9 @@ class TestToolTypesAPIExtended:
assert data["valid"] is False assert data["valid"] is False
assert "errors" in data 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.""" """Test validating dockerfile template."""
response = authenticated_client.post( response = authenticated_client.post(
"/tool-types/validate", "/tool-types/validate",
@@ -154,7 +170,9 @@ class TestToolTypesAPIExtended:
data = response.json() data = response.json()
assert data["valid"] is True 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.""" """Test that GET returns new fields."""
# Create tool type with all fields # Create tool type with all fields
create_response = authenticated_client.post( create_response = authenticated_client.post(
@@ -166,7 +184,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web", "terminal"], "interfaces": ["web", "terminal"],
"default_port": 8443, "default_port": 8443,
"definition_type": "compose", "definition_type": "compose",
"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\"", "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": { "readiness_probe": {
"command": "curl -f http://localhost:8443", "command": "curl -f http://localhost:8443",
"timeout": 30, "timeout": 30,
@@ -186,7 +204,9 @@ class TestToolTypesAPIExtended:
assert data["interfaces"] == ["web", "terminal"] assert data["interfaces"] == ["web", "terminal"]
assert "readiness_probe" in data 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.""" """Test that creating a tool type without default_port fails validation."""
response = authenticated_client.post( response = authenticated_client.post(
"/tool-types", "/tool-types",
@@ -204,7 +224,9 @@ class TestToolTypesAPIExtended:
data = response.json() data = response.json()
assert "default_port" in str(data) 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.""" """Test that port mismatch between default_port and compose template fails."""
response = authenticated_client.post( response = authenticated_client.post(
"/tool-types", "/tool-types",
@@ -222,7 +244,9 @@ class TestToolTypesAPIExtended:
assert response.status_code == 422 assert response.status_code == 422
_ = response.json() _ = 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.""" """Test creating a tool type with startup_command."""
response = authenticated_client.post( response = authenticated_client.post(
"/tool-types", "/tool-types",
@@ -244,7 +268,9 @@ class TestToolTypesAPIExtended:
assert data["startup_command"] == "cd /workspace && ls" assert data["startup_command"] == "cd /workspace && ls"
assert data["interface_type"] == "terminal" 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.""" """Test updating a tool type's startup_command."""
# Create tool type first # Create tool type first
create_response = authenticated_client.post( create_response = authenticated_client.post(
@@ -273,7 +299,9 @@ class TestToolTypesAPIExtended:
data = response.json() data = response.json()
assert data["startup_command"] == "source /etc/profile" 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.""" """Test that GET returns startup_command."""
create_response = authenticated_client.post( create_response = authenticated_client.post(
"/tool-types", "/tool-types",