fix: lowercase container name filter for case-sensitive docker ps

- get_container_id() and get_container_name() now lowercase the
  instance name before passing to docker ps --filter, because
  Docker container names are lowercase internally and the filter
  is case-sensitive. This caused container_id to never be captured
  when instance.name contained uppercase chars (e.g. 'Headquarter'),
  breaking terminal WebSocket connections.

- Also guard proc.stdout being None in start_cloudflared_tunnel().

- Add unit tests for get_container_id and get_container_name.

Quality gates: pytest (14 passed), python clean
This commit is contained in:
Alex Blank
2026-05-28 10:56:33 +02:00
parent c63cf7db50
commit 29943ac239
3 changed files with 125 additions and 23 deletions
+2
View File
@@ -49,3 +49,5 @@ apps/web/dist/
.DS_Store
Thumbs.db
/.stoneforge/.worktrees/
# Local Pi runtime state
.atl/
+71 -23
View File
@@ -35,6 +35,7 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
@@ -87,7 +88,7 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
@@ -109,7 +110,7 @@ def execute_compose_command(
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
@@ -142,8 +143,9 @@ def get_container_id(instance_name: str) -> str | None:
Returns:
Container ID or None if not found
"""
# Docker container names are lowercase internally; normalize to ensure match
result = subprocess.run(
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
["docker", "ps", "-q", "--filter", f"name={instance_name.lower()}"],
capture_output=True,
text=True,
)
@@ -162,8 +164,16 @@ def get_container_name(instance_name: str) -> str | None:
Returns:
Container name or None if not found
"""
# Docker container names are lowercase internally; normalize to ensure match
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
[
"docker",
"ps",
"--format",
"{{.Names}}",
"--filter",
f"name={instance_name.lower()}",
],
capture_output=True,
text=True,
)
@@ -173,7 +183,9 @@ def get_container_name(instance_name: str) -> str | None:
return None
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
def connect_container_to_network(
container_name: str, network_name: str = "backend"
) -> bool:
"""Connect a Docker container to an existing network.
Args:
@@ -198,12 +210,14 @@ def get_container_status(container_id: str) -> dict[str, Any]:
container_id: Docker container ID
Returns:
Dict with 'status' (running, exited, restarting, not_found),
Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
"""
result = subprocess.run(
[
"docker", "inspect", "-f",
"docker",
"inspect",
"-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
@@ -213,12 +227,12 @@ def get_container_status(container_id: str) -> dict[str, Any]:
if result.returncode != 0:
return {"status": "not_found", "exit_code": None, "health": None}
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
@@ -241,10 +255,10 @@ def wait_for_container_running(
import time
start_time = time.time()
while time.time() - start_time < timeout:
info = get_container_status(container_id)
if info["status"] == "running":
return {
"success": True,
@@ -252,7 +266,7 @@ def wait_for_container_running(
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
if info["status"] == "exited":
return {
"success": False,
@@ -260,7 +274,7 @@ def wait_for_container_running(
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if info["status"] == "not_found":
return {
"success": False,
@@ -268,9 +282,9 @@ def wait_for_container_running(
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
time.sleep(interval)
# Timeout reached
info = get_container_status(container_id)
return {
@@ -354,18 +368,29 @@ def start_cloudflared_tunnel(
logger.info("Checking connectivity to %s:%d...", container_name, port)
for attempt in range(10):
check = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
f"http://{container_name}:{port}"],
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
f"http://{container_name}:{port}",
],
capture_output=True,
text=True,
timeout=5,
)
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
logger.info(
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
)
if check.returncode == 0:
break
time.sleep(1)
else:
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
logger.warning(
"Container %s:%d not responding to curl checks", container_name, port
)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
@@ -381,9 +406,15 @@ def start_cloudflared_tunnel(
start_time = time.time()
url = None
if proc.stdout is None:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError("Failed to capture cloudflared output")
while time.time() - start_time < timeout:
# Read available output
import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
@@ -455,14 +486,23 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", str(timeout), url],
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
str(timeout),
url,
],
capture_output=True,
text=True,
timeout=timeout + 5,
)
status_code = int(result.stdout.strip())
if 200 <= status_code < 400:
return {
"tunnel_status": "healthy",
@@ -495,7 +535,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
except (ValueError, Exception) as e:
error_str = str(e).lower()
# Classify connection errors
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
if any(
err in error_str
for err in [
"connection refused",
"econnrefused",
"could not resolve",
"nodename",
]
):
return {
"tunnel_status": "unreachable",
"status_code": None,
@@ -0,0 +1,52 @@
"""Unit tests for docker service utilities."""
from unittest.mock import MagicMock, patch
from src.services.docker import get_container_id, get_container_name
class TestGetContainerId:
"""Tests for get_container_id."""
@patch("subprocess.run")
def test_lowercases_name_for_filter(self, mock_run) -> None:
"""Docker ps name filter is case-sensitive; we must lowercase."""
mock_run.return_value = MagicMock(returncode=0, stdout="abc123\n")
result = get_container_id("MyContainer-ABC")
assert result == "abc123"
call_args = mock_run.call_args[0][0]
# The filter must use lowercase
assert "name=mycontainer-abc" in call_args
@patch("subprocess.run")
def test_returns_none_when_not_found(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="")
result = get_container_id("missing")
assert result is None
class TestGetContainerName:
"""Tests for get_container_name."""
@patch("subprocess.run")
def test_lowercases_name_for_filter(self, mock_run) -> None:
"""Docker ps name filter is case-sensitive; we must lowercase."""
mock_run.return_value = MagicMock(returncode=0, stdout="mycontainer-abc\n")
result = get_container_name("MyContainer-ABC")
assert result == "mycontainer-abc"
call_args = mock_run.call_args[0][0]
assert "name=mycontainer-abc" in call_args
@patch("subprocess.run")
def test_returns_none_when_not_found(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="")
result = get_container_name("missing")
assert result is None