Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -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
|
||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user