Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -0,0 +1,148 @@
|
|||||||
|
"""Fix code-server bind address to include port
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_fix_code_server_bind_addr_port
|
||||||
|
Revises: 2026_05_29_remove_lsio_command_override
|
||||||
|
Create Date: 2026-05-29 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_fix_code_server_bind_addr_port"
|
||||||
|
down_revision: Union[str, None] = "2026_05_29_remove_lsio_command_override"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_tool_type_templates(conn) -> None:
|
||||||
|
"""Fix code-server tool type templates with broken --host override."""
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template, default_port
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
AND compose_template LIKE '%--host%'
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for tool_id, compose_template, default_port in result:
|
||||||
|
port = default_port or 8443
|
||||||
|
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||||
|
|
||||||
|
# Replace any line containing --host with the correct bind-addr
|
||||||
|
lines = compose_template.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
modified = False
|
||||||
|
for line in lines:
|
||||||
|
if "command:" in line and "--host" in line:
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
new_lines.append(f"{indent}command: {expected}")
|
||||||
|
modified = True
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
continue
|
||||||
|
|
||||||
|
updated = "\n".join(new_lines)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated, "id": tool_id},
|
||||||
|
)
|
||||||
|
print(f"Fixed code-server template ({tool_id}): replaced --host with {expected}")
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_instance_compose_files(conn) -> None:
|
||||||
|
"""Fix existing instance compose files on disk with broken --host override."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Use information_schema to check if compose_path column exists
|
||||||
|
col_result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances'
|
||||||
|
AND column_name = 'compose_path'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not col_result:
|
||||||
|
print("compose_path column not found, skipping instance file fixes")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_path, tool_type_id
|
||||||
|
FROM tool_instances
|
||||||
|
WHERE compose_path IS NOT NULL
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for instance_id, compose_path, tool_type_id in result:
|
||||||
|
path = Path(compose_path)
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = path.read_text()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "--host" not in content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get default_port from tool_type
|
||||||
|
port_result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT default_port FROM tool_types WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"id": tool_type_id},
|
||||||
|
).fetchone()
|
||||||
|
port = port_result[0] if port_result and port_result[0] else 8443
|
||||||
|
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(content)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not data or "services" not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
for svc in data["services"].values():
|
||||||
|
if "command" in svc:
|
||||||
|
cmd = svc["command"]
|
||||||
|
if "--host" in cmd:
|
||||||
|
svc["command"] = expected
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.write_text(yaml.dump(data, default_flow_style=False))
|
||||||
|
print(
|
||||||
|
f"Fixed code-server instance compose ({instance_id}): "
|
||||||
|
f"replaced --host with {expected}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to fix instance {instance_id}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
_fix_tool_type_templates(conn)
|
||||||
|
_fix_instance_compose_files(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -5,6 +5,7 @@ Revises: 2026_05_29_fix_code_server_bind_addr
|
|||||||
Create Date: 2026-05-29 15:05:00.000000
|
Create Date: 2026-05-29 15:05:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ from src.services.docker import (
|
|||||||
find_free_port,
|
find_free_port,
|
||||||
get_container_id,
|
get_container_id,
|
||||||
get_container_logs,
|
get_container_logs,
|
||||||
get_container_name,
|
|
||||||
get_container_status,
|
get_container_status,
|
||||||
recreate_tunnel,
|
recreate_tunnel,
|
||||||
render_compose_template,
|
render_compose_template,
|
||||||
@@ -618,7 +617,44 @@ 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:
|
def _ensure_container_name_in_compose(compose_path: str, container_name: str) -> None:
|
||||||
|
"""Ensure compose file has explicit container_name for predictable naming.
|
||||||
|
|
||||||
|
Docker Compose auto-generates container names from the project directory
|
||||||
|
when container_name is absent. This breaks tunnel connectivity because
|
||||||
|
get_container_name(instance.name) cannot find the container. We inject
|
||||||
|
container_name into every service so the container has a predictable name.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
for svc_name, svc_config in compose_data["services"].items():
|
||||||
|
if "container_name" not in svc_config:
|
||||||
|
svc_config["container_name"] = container_name.lower()
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||||
|
logger.info(
|
||||||
|
"Injected container_name '%s' into compose file",
|
||||||
|
container_name.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_web_bind_address(
|
||||||
|
compose_path: str, tool_type_name: str, default_port: int
|
||||||
|
) -> None:
|
||||||
"""Auto-inject bind address for known web tools that default to 127.0.0.1.
|
"""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,
|
Many web tools (code-server, jupyter) bind to localhost by default,
|
||||||
@@ -628,9 +664,12 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
|
|||||||
import yaml
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
KNOWN_BIND_FIXES = {
|
if default_port <= 0:
|
||||||
"code-server": "--host 0.0.0.0",
|
return
|
||||||
"jupyter-notebook": "start-notebook.sh --ip=0.0.0.0",
|
|
||||||
|
KNOWN_BIND_FIXES: dict[str, str] = {
|
||||||
|
"code-server": f"--bind-addr 0.0.0.0:{default_port}",
|
||||||
|
"jupyter-notebook": f"start-notebook.sh --ip=0.0.0.0 --port={default_port} --no-browser",
|
||||||
}
|
}
|
||||||
|
|
||||||
bind_command = KNOWN_BIND_FIXES.get(tool_type_name)
|
bind_command = KNOWN_BIND_FIXES.get(tool_type_name)
|
||||||
@@ -679,8 +718,15 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
|
|||||||
|
|
||||||
existing_command = service_config.get("command", "")
|
existing_command = service_config.get("command", "")
|
||||||
if existing_command:
|
if existing_command:
|
||||||
# Fix broken --bind-addr (replaces with --host)
|
# Already correct — nothing to do
|
||||||
if "--bind-addr" in existing_command:
|
if bind_command in existing_command:
|
||||||
|
return
|
||||||
|
# Fix broken or outdated bind flags
|
||||||
|
if (
|
||||||
|
"--bind-addr" in existing_command
|
||||||
|
or "--host" in existing_command
|
||||||
|
or "--ip=" in existing_command
|
||||||
|
):
|
||||||
service_config["command"] = bind_command
|
service_config["command"] = bind_command
|
||||||
compose_file.write_text(
|
compose_file.write_text(
|
||||||
yaml.dump(compose_data, default_flow_style=False)
|
yaml.dump(compose_data, default_flow_style=False)
|
||||||
@@ -692,9 +738,6 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
|
|||||||
bind_command,
|
bind_command,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
# Already has correct --host, nothing to do
|
|
||||||
if "--host" in existing_command or "--ip=" in existing_command:
|
|
||||||
return
|
|
||||||
# Some other command override exists — don't touch it
|
# Some other command override exists — don't touch it
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1639,7 +1682,12 @@ async def start_instance(
|
|||||||
|
|
||||||
# Auto-fix bind address for known web tools that default to localhost
|
# Auto-fix bind address for known web tools that default to localhost
|
||||||
if tool_type and tool_type.interface_type == "web":
|
if tool_type and tool_type.interface_type == "web":
|
||||||
_ensure_web_bind_address(instance.compose_path, tool_type.name)
|
_ensure_web_bind_address(
|
||||||
|
instance.compose_path, tool_type.name, tool_type.default_port
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure predictable container name for tunnel connectivity
|
||||||
|
_ensure_container_name_in_compose(instance.compose_path, instance.name)
|
||||||
|
|
||||||
# Execute docker compose up with env file
|
# Execute docker compose up with env file
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -1667,24 +1715,23 @@ async def start_instance(
|
|||||||
detail=f"failed to start instance: {stderr}",
|
detail=f"failed to start instance: {stderr}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get container ID and name
|
# Get container ID and name (use predictable name from compose)
|
||||||
container_id = get_container_id(instance.name)
|
expected_container_name = instance.name.lower()
|
||||||
|
container_id = get_container_id(expected_container_name)
|
||||||
if container_id:
|
if container_id:
|
||||||
instance.container_id = container_id
|
instance.container_id = container_id
|
||||||
logger.debug("Container ID for instance %s: %s", instance.id, container_id)
|
logger.debug("Container ID for instance %s: %s", instance.id, container_id)
|
||||||
|
|
||||||
container_name = get_container_name(instance.name)
|
instance.container_name = expected_container_name
|
||||||
if container_name:
|
logger.debug("Container name for instance %s: %s", instance.id, expected_container_name)
|
||||||
instance.container_name = container_name
|
|
||||||
logger.debug("Container name for instance %s: %s", instance.id, container_name)
|
|
||||||
|
|
||||||
# Connect container to backend network so API can reach it
|
# Connect container to backend network so API can reach it
|
||||||
logger.debug("Connecting container %s to backend network...", container_name)
|
logger.debug("Connecting container %s to backend network...", expected_container_name)
|
||||||
connected = connect_container_to_network(container_name, "backend")
|
connected = connect_container_to_network(expected_container_name, "backend")
|
||||||
if connected:
|
if connected:
|
||||||
logger.debug("Successfully connected %s to backend network", container_name)
|
logger.debug("Successfully connected %s to backend network", expected_container_name)
|
||||||
else:
|
else:
|
||||||
logger.warning("Failed to connect %s to backend network", container_name)
|
logger.warning("Failed to connect %s to backend network", expected_container_name)
|
||||||
|
|
||||||
# Verify container reached running state
|
# Verify container reached running state
|
||||||
if instance.container_id:
|
if instance.container_id:
|
||||||
@@ -2111,6 +2158,15 @@ async def restart_instance(
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Re-apply compose fixes in case they were updated since last start
|
||||||
|
_sanitize_compose_file(instance.compose_path)
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
if tool_type and tool_type.interface_type == "web":
|
||||||
|
_ensure_web_bind_address(
|
||||||
|
instance.compose_path, tool_type.name, tool_type.default_port
|
||||||
|
)
|
||||||
|
_ensure_container_name_in_compose(instance.compose_path, instance.name)
|
||||||
|
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
instance.compose_path, "restart"
|
instance.compose_path, "restart"
|
||||||
)
|
)
|
||||||
@@ -2140,7 +2196,7 @@ async def restart_instance(
|
|||||||
# Create new temporary tunnel
|
# Create new temporary tunnel
|
||||||
try:
|
try:
|
||||||
tunnel_info = start_cloudflared_tunnel(
|
tunnel_info = start_cloudflared_tunnel(
|
||||||
container_name=instance.container_name or instance.name,
|
container_name=instance.name.lower(),
|
||||||
port=instance_port,
|
port=instance_port,
|
||||||
)
|
)
|
||||||
instance.tunnel_id = tunnel_info["pid"]
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ def execute_compose_command(
|
|||||||
cmd.extend(["--env-file", env_file])
|
cmd.extend(["--env-file", env_file])
|
||||||
|
|
||||||
if action == "up":
|
if action == "up":
|
||||||
cmd.extend(["up", "-d"])
|
cmd.extend(["up", "-d", "--force-recreate"])
|
||||||
elif action == "down":
|
elif action == "down":
|
||||||
cmd.extend(["down", "-v"])
|
cmd.extend(["down", "-v"])
|
||||||
elif action in ("start", "stop", "restart"):
|
elif action in ("start", "stop", "restart"):
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ async def test_lifecycle_running_creates_notification(
|
|||||||
assert len(notifications) == 1
|
assert len(notifications) == 1
|
||||||
n = notifications[0]
|
n = notifications[0]
|
||||||
assert n.category == "instance"
|
assert n.category == "instance"
|
||||||
assert n.severity == "info"
|
assert n.severity == "success"
|
||||||
assert n.title == "Health Changed"
|
assert n.title == "Container ready"
|
||||||
assert n.source_type == "tool_instances"
|
assert n.source_type == "tool_instances"
|
||||||
assert n.source_id == test_instance.id
|
assert n.source_id == test_instance.id
|
||||||
|
|
||||||
@@ -315,9 +315,9 @@ async def test_notification_ownership_matches_instance_owner(
|
|||||||
event_bus=event_bus,
|
event_bus=event_bus,
|
||||||
session=db_session,
|
session=db_session,
|
||||||
instance=instance,
|
instance=instance,
|
||||||
event_type="instance.created",
|
event_type="instance.health_changed",
|
||||||
status="pending",
|
status="running",
|
||||||
message="Instance created",
|
message="Container running",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
|
|||||||
@@ -184,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: --host 0.0.0.0\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: --bind-addr 0.0.0.0:8443\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,
|
||||||
|
|||||||
Reference in New Issue
Block a user