Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
Alex Blank
2026-05-29 16:48:24 +02:00
4 changed files with 243 additions and 19 deletions
@@ -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
+59 -1
View File
@@ -618,6 +618,60 @@ def _modify_compose_file(
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) 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( @router.post(
"/{project_id}/repositories/{repo_id}/instances", "/{project_id}/repositories/{repo_id}/instances",
summary="Create tool instance", summary="Create tool instance",
@@ -854,7 +908,7 @@ services:
) )
# Determine home directory for path expansion # 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) 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 to remove invalid port mappings from old instances
_sanitize_compose_file(instance.compose_path) _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 # Execute docker compose up with env file
logger.debug( logger.debug(
"Running docker compose up for instance %s (compose_path=%s)", "Running docker compose up for instance %s (compose_path=%s)",
+1 -3
View File
@@ -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}") raise RuntimeError(f"No free port found in range {start}-{end}")
def _check_app_binding( def _check_app_binding(container_name: str, port: int) -> dict[str, str | bool]:
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. """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 Checks from both inside the container (localhost) and outside
@@ -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 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",