Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c051929f8c | |||
| 4814ec2363 | |||
| 98b9d612fa | |||
| 874873541d | |||
| ef9ac76f06 | |||
| ca9db195de | |||
| c1e16f2163 | |||
| 61d32fa00f | |||
| cddb3f8ccf | |||
| 87a938fe58 | |||
| 9157694412 | |||
| aa34314175 | |||
| c7fc386d0f | |||
| 6bd814e346 |
@@ -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
|
||||||
@@ -652,7 +652,9 @@ def _ensure_container_name_in_compose(compose_path: str, container_name: str) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
|
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,
|
||||||
@@ -662,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)
|
||||||
@@ -713,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)
|
||||||
@@ -726,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
|
||||||
|
|
||||||
@@ -1673,7 +1682,9 @@ 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 predictable container name for tunnel connectivity
|
||||||
_ensure_container_name_in_compose(instance.compose_path, instance.name)
|
_ensure_container_name_in_compose(instance.compose_path, instance.name)
|
||||||
@@ -2151,7 +2162,9 @@ async def restart_instance(
|
|||||||
_sanitize_compose_file(instance.compose_path)
|
_sanitize_compose_file(instance.compose_path)
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
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_container_name_in_compose(instance.compose_path, instance.name)
|
_ensure_container_name_in_compose(instance.compose_path, instance.name)
|
||||||
|
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -411,8 +411,9 @@ class TestStartInstanceLegacyFallback:
|
|||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
@patch("src.api.tool_instances._get_user")
|
@patch("src.api.tool_instances._get_user")
|
||||||
@@ -423,8 +424,9 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_user,
|
mock_get_user,
|
||||||
mock_prepare_manifest,
|
mock_prepare_manifest,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
@@ -440,7 +442,6 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {
|
mock_wait_container.return_value = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -509,8 +510,9 @@ class TestStartInstanceLegacyFallback:
|
|||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
@patch("src.api.tool_instances._get_user")
|
@patch("src.api.tool_instances._get_user")
|
||||||
@@ -521,8 +523,9 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_user,
|
mock_get_user,
|
||||||
mock_prepare_manifest,
|
mock_prepare_manifest,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
@@ -538,7 +541,6 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {
|
mock_wait_container.return_value = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -606,8 +608,9 @@ class TestStartInstanceLegacyFallback:
|
|||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
@patch("src.api.tool_instances._get_user")
|
@patch("src.api.tool_instances._get_user")
|
||||||
@@ -618,8 +621,9 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_user,
|
mock_get_user,
|
||||||
mock_prepare_manifest,
|
mock_prepare_manifest,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
@@ -635,7 +639,6 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {
|
mock_wait_container.return_value = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -705,12 +708,14 @@ class TestStartInstanceSshPermissions:
|
|||||||
"""SSH key mounts trigger permission fixes after container starts."""
|
"""SSH key mounts trigger permission fixes after container starts."""
|
||||||
|
|
||||||
@patch("src.api.tool_instances.write_compose_file")
|
@patch("src.api.tool_instances.write_compose_file")
|
||||||
|
@patch("src.api.tool_instances.prepare_ssh_key_files")
|
||||||
@patch("src.api.tool_instances.apply_ssh_permissions")
|
@patch("src.api.tool_instances.apply_ssh_permissions")
|
||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._get_user")
|
@patch("src.api.tool_instances._get_user")
|
||||||
@patch("src.api.tool_instances._get_owned_project")
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
@@ -719,12 +724,14 @@ class TestStartInstanceSshPermissions:
|
|||||||
mock_get_project,
|
mock_get_project,
|
||||||
mock_get_user,
|
mock_get_user,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
mock_apply_ssh,
|
mock_apply_ssh,
|
||||||
|
mock_prepare_ssh,
|
||||||
mock_write_compose,
|
mock_write_compose,
|
||||||
mock_session,
|
mock_session,
|
||||||
fake_user_id,
|
fake_user_id,
|
||||||
@@ -743,7 +750,6 @@ class TestStartInstanceSshPermissions:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {
|
mock_wait_container.return_value = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -836,12 +842,14 @@ class TestStartInstanceSshPermissions:
|
|||||||
assert result["status"] == "running"
|
assert result["status"] == "running"
|
||||||
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
|
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.prepare_ssh_key_files")
|
||||||
@patch("src.api.tool_instances.apply_ssh_permissions")
|
@patch("src.api.tool_instances.apply_ssh_permissions")
|
||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._get_user")
|
@patch("src.api.tool_instances._get_user")
|
||||||
@patch("src.api.tool_instances._get_owned_project")
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
@@ -850,12 +858,14 @@ class TestStartInstanceSshPermissions:
|
|||||||
mock_get_project,
|
mock_get_project,
|
||||||
mock_get_user,
|
mock_get_user,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
mock_apply_ssh,
|
mock_apply_ssh,
|
||||||
|
mock_prepare_ssh,
|
||||||
mock_session,
|
mock_session,
|
||||||
fake_user_id,
|
fake_user_id,
|
||||||
fake_project_id,
|
fake_project_id,
|
||||||
@@ -870,7 +880,6 @@ class TestStartInstanceSshPermissions:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {
|
mock_wait_container.return_value = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -933,14 +942,15 @@ class TestStartInstanceSshPermissions:
|
|||||||
mock_session.get.side_effect = _get
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
with patch("os.path.exists", return_value=True):
|
with patch("os.path.exists", return_value=True):
|
||||||
result = await start_instance(
|
with patch("src.api.tool_instances._modify_compose_file"):
|
||||||
project_id=fake_project_id,
|
result = await start_instance(
|
||||||
repo_id=fake_repo_id,
|
project_id=fake_project_id,
|
||||||
instance_id=fake_instance_id,
|
repo_id=fake_repo_id,
|
||||||
data=None,
|
instance_id=fake_instance_id,
|
||||||
user_id=fake_user_id,
|
data=None,
|
||||||
session=mock_session,
|
user_id=fake_user_id,
|
||||||
)
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
assert result["status"] == "running"
|
assert result["status"] == "running"
|
||||||
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
|
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
|
||||||
@@ -952,8 +962,9 @@ class TestStartInstanceManifestBranch:
|
|||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
@patch("src.api.tool_instances.write_compose_file")
|
@patch("src.api.tool_instances.write_compose_file")
|
||||||
@@ -966,8 +977,9 @@ class TestStartInstanceManifestBranch:
|
|||||||
mock_write_compose,
|
mock_write_compose,
|
||||||
mock_prepare_manifest,
|
mock_prepare_manifest,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
@@ -987,7 +999,6 @@ class TestStartInstanceManifestBranch:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {
|
mock_wait_container.return_value = {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -313,6 +313,96 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
term.focus();
|
term.focus();
|
||||||
const ws = connectWebSocket();
|
const ws = connectWebSocket();
|
||||||
|
|
||||||
|
// Mobile touch scroll.
|
||||||
|
// In normal mode xterm.js has a scrollable viewport; in alternate
|
||||||
|
// screen (tmux/vim) there is no scrollback and the only way to
|
||||||
|
// scroll is to send mouse-wheel protocol sequences to the
|
||||||
|
// application. We detect which situation we're in by checking
|
||||||
|
// whether the viewport has scrollable height.
|
||||||
|
let touchCleanup: (() => void) | undefined;
|
||||||
|
if (isMobile) {
|
||||||
|
let startY = 0;
|
||||||
|
let startX = 0;
|
||||||
|
let isScrolling = false;
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
startY = e.touches[0].clientY;
|
||||||
|
startX = e.touches[0].clientX;
|
||||||
|
isScrolling = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const deltaY = startY - touch.clientY;
|
||||||
|
const deltaX = Math.abs(startX - touch.clientX);
|
||||||
|
if (!isScrolling) {
|
||||||
|
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 4) {
|
||||||
|
isScrolling = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isScrolling) {
|
||||||
|
e.preventDefault();
|
||||||
|
const viewport = container.querySelector(
|
||||||
|
".xterm-viewport",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!viewport) return;
|
||||||
|
|
||||||
|
// If the viewport is scrollable, scroll it directly.
|
||||||
|
// Otherwise we are in alternate screen (tmux/vim) and must
|
||||||
|
// send SGR 1006 mouse-wheel protocol data.
|
||||||
|
const hasScrollback =
|
||||||
|
viewport.scrollHeight > viewport.clientHeight;
|
||||||
|
if (hasScrollback) {
|
||||||
|
viewport.scrollTop += deltaY;
|
||||||
|
} else {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (
|
||||||
|
ws?.readyState === WebSocket.OPEN &&
|
||||||
|
termRef.current
|
||||||
|
) {
|
||||||
|
// Use the cursor position as the wheel location so
|
||||||
|
// tmux knows which pane to scroll.
|
||||||
|
const buf = termRef.current.buffer.active;
|
||||||
|
const col = buf.cursorX + 1;
|
||||||
|
const row = buf.cursorY + 1;
|
||||||
|
// SGR 1006: 64 = wheel-up, 65 = wheel-down
|
||||||
|
const btn = deltaY > 0 ? 64 : 65;
|
||||||
|
ws.send(`\x1b[<${btn};${col};${row}M`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startY = touch.clientY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
isScrolling = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("touchstart", onTouchStart, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
container.addEventListener("touchmove", onTouchMove, {
|
||||||
|
passive: false,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
container.addEventListener("touchend", onTouchEnd, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
touchCleanup = () => {
|
||||||
|
container.removeEventListener("touchstart", onTouchStart, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
container.removeEventListener("touchmove", onTouchMove, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
container.removeEventListener("touchend", onTouchEnd, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Initial fit after layout settles (terminal must be opened first)
|
// Initial fit after layout settles (terminal must be opened first)
|
||||||
let fitAttempts = 0;
|
let fitAttempts = 0;
|
||||||
const doInitialFit = () => {
|
const doInitialFit = () => {
|
||||||
@@ -440,6 +530,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
"visibilitychange",
|
"visibilitychange",
|
||||||
handleVisibilityChange,
|
handleVisibilityChange,
|
||||||
);
|
);
|
||||||
|
if (touchCleanup) touchCleanup();
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close(1000, "Component unmounting");
|
ws.close(1000, "Component unmounting");
|
||||||
}
|
}
|
||||||
@@ -568,7 +659,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}>
|
<div
|
||||||
|
className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}
|
||||||
|
>
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<div className="terminal-header">
|
<div className="terminal-header">
|
||||||
<div className="terminal-header-left">
|
<div className="terminal-header-left">
|
||||||
|
|||||||
@@ -204,6 +204,18 @@ export const TerminalPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Lock page scroll on mobile terminal so swipes scroll the terminal buffer,
|
||||||
|
// not the page.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
document.documentElement.classList.add("terminal-page-open");
|
||||||
|
document.body.classList.add("terminal-page-open");
|
||||||
|
return () => {
|
||||||
|
document.documentElement.classList.remove("terminal-page-open");
|
||||||
|
document.body.classList.remove("terminal-page-open");
|
||||||
|
};
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
// Click outside terminal content/header to exit fullscreen
|
// Click outside terminal content/header to exit fullscreen
|
||||||
const handleFullscreenClick = useCallback(
|
const handleFullscreenClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLElement>) => {
|
(e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ body {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.terminal-page-open,
|
||||||
|
body.terminal-page-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="dark"] body {
|
[data-theme="dark"] body {
|
||||||
background: radial-gradient(circle at top right, #2a2520, var(--bg));
|
background: radial-gradient(circle at top right, #2a2520, var(--bg));
|
||||||
}
|
}
|
||||||
@@ -3703,6 +3708,7 @@ a.nav-item,
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* xterm.js manages its own sizing */
|
/* xterm.js manages its own sizing */
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}",
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -92,7 +96,7 @@ services:
|
|||||||
AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-}
|
AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-}
|
||||||
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
|
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- repo_data:/data/repos
|
- /data/repos:/data/repos
|
||||||
- /data/instances:/data/instances
|
- /data/instances:/data/instances
|
||||||
- avatar_uploads:/app/uploads
|
- avatar_uploads:/app/uploads
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
@@ -116,7 +120,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
repo_data:
|
|
||||||
avatar_uploads:
|
avatar_uploads:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+7
-4
@@ -1,4 +1,4 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# PostgreSQL Database
|
# PostgreSQL Database
|
||||||
@@ -14,7 +14,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}",
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -57,7 +61,7 @@ services:
|
|||||||
REPO_BASE_PATH: /data/repos
|
REPO_BASE_PATH: /data/repos
|
||||||
INSTANCE_BASE_PATH: /data/instances
|
INSTANCE_BASE_PATH: /data/instances
|
||||||
volumes:
|
volumes:
|
||||||
- repo_data:/data/repos
|
- /data/repos:/data/repos
|
||||||
- /data/instances:/data/instances
|
- /data/instances:/data/instances
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -91,7 +95,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
repo_data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
Reference in New Issue
Block a user