feat: add Docker watch mode for dynamic container sessions
- Add --docker-watch CLI flag to watch for containers with webterm-command label - Containers with label 'auto' get bash exec, otherwise use label as command - Dynamic dashboard updates via SSE when containers start/stop - Add /tiles endpoint for JSON tile list - Multi-stage Dockerfile for minimal production image - Update README with docker-watch documentation The docker watcher monitors Docker events and automatically: - Adds terminal tiles when labeled containers start - Removes tiles when containers stop - Notifies dashboard via SSE for live updates
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""Tests for docker_watcher module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from textual_webterm.docker_watcher import DEFAULT_COMMAND, LABEL_NAME, DockerWatcher
|
||||
|
||||
|
||||
class TestDockerWatcher:
|
||||
"""Tests for DockerWatcher class."""
|
||||
|
||||
def test_container_to_slug(self):
|
||||
"""Test slug generation from container names."""
|
||||
watcher = DockerWatcher(MagicMock())
|
||||
|
||||
# Test basic name
|
||||
container = {"Names": ["/my-container"]}
|
||||
assert watcher._container_to_slug(container) == "my-container"
|
||||
|
||||
# Test with underscores
|
||||
container = {"Names": ["/my_container_name"]}
|
||||
assert watcher._container_to_slug(container) == "my-container-name"
|
||||
|
||||
# Test with dots
|
||||
container = {"Names": ["/service.name"]}
|
||||
assert watcher._container_to_slug(container) == "service-name"
|
||||
|
||||
# Test fallback to ID
|
||||
container = {"Id": "abc123def456"}
|
||||
assert watcher._container_to_slug(container) == "abc123def456"
|
||||
|
||||
def test_get_container_name(self):
|
||||
"""Test extracting container name."""
|
||||
watcher = DockerWatcher(MagicMock())
|
||||
|
||||
container = {"Names": ["/my-container"]}
|
||||
assert watcher._get_container_name(container) == "my-container"
|
||||
|
||||
container = {"Names": []}
|
||||
container["Id"] = "abc123def456789"
|
||||
assert watcher._get_container_name(container) == "abc123def456"
|
||||
|
||||
def test_get_container_command_auto(self):
|
||||
"""Test command generation when label is 'auto'."""
|
||||
watcher = DockerWatcher(MagicMock())
|
||||
|
||||
container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}}
|
||||
expected = f"docker exec -it my-container {DEFAULT_COMMAND}"
|
||||
assert watcher._get_container_command(container) == expected
|
||||
|
||||
def test_get_container_command_custom(self):
|
||||
"""Test command when label has custom value."""
|
||||
watcher = DockerWatcher(MagicMock())
|
||||
|
||||
container = {
|
||||
"Names": ["/my-container"],
|
||||
"Labels": {LABEL_NAME: "docker logs -f my-container"},
|
||||
}
|
||||
assert watcher._get_container_command(container) == "docker logs -f my-container"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_container(self):
|
||||
"""Test adding a container."""
|
||||
session_manager = MagicMock()
|
||||
on_added = MagicMock()
|
||||
watcher = DockerWatcher(session_manager, on_container_added=on_added)
|
||||
|
||||
container = {"Id": "abc123", "Names": ["/test-container"], "Labels": {LABEL_NAME: "auto"}}
|
||||
|
||||
await watcher._add_container(container)
|
||||
|
||||
# Should add to session manager
|
||||
session_manager.add_app.assert_called_once()
|
||||
call_args = session_manager.add_app.call_args
|
||||
assert call_args[0][0] == "test-container" # name
|
||||
assert "docker exec -it test-container" in call_args[0][1] # command
|
||||
assert call_args[0][2] == "test-container" # slug
|
||||
assert call_args[1]["terminal"] is True
|
||||
|
||||
# Should call callback
|
||||
on_added.assert_called_once_with("test-container", "test-container", call_args[0][1])
|
||||
|
||||
# Should track container
|
||||
assert "test-container" in watcher._managed_containers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_container_already_managed(self):
|
||||
"""Test adding a container that's already managed."""
|
||||
session_manager = MagicMock()
|
||||
watcher = DockerWatcher(session_manager)
|
||||
watcher._managed_containers["test-container"] = "abc123"
|
||||
|
||||
container = {"Id": "abc123", "Names": ["/test-container"], "Labels": {LABEL_NAME: "auto"}}
|
||||
|
||||
await watcher._add_container(container)
|
||||
|
||||
# Should not add again
|
||||
session_manager.add_app.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_container(self):
|
||||
"""Test removing a container."""
|
||||
session_manager = MagicMock()
|
||||
session_manager.apps_by_slug = {"test-container": MagicMock()}
|
||||
session_manager.apps = [session_manager.apps_by_slug["test-container"]]
|
||||
session_manager.get_session_by_route_key.return_value = None
|
||||
|
||||
on_removed = MagicMock()
|
||||
watcher = DockerWatcher(session_manager, on_container_removed=on_removed)
|
||||
watcher._managed_containers["test-container"] = "abc123"
|
||||
|
||||
await watcher._remove_container("abc123")
|
||||
|
||||
# Should remove from tracking
|
||||
assert "test-container" not in watcher._managed_containers
|
||||
|
||||
# Should call callback
|
||||
on_removed.assert_called_once_with("test-container")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_container_not_managed(self):
|
||||
"""Test removing a container that's not managed."""
|
||||
session_manager = MagicMock()
|
||||
on_removed = MagicMock()
|
||||
watcher = DockerWatcher(session_manager, on_container_removed=on_removed)
|
||||
|
||||
await watcher._remove_container("unknown123")
|
||||
|
||||
# Should not call callback
|
||||
on_removed.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_stop(self):
|
||||
"""Test starting and stopping the watcher."""
|
||||
session_manager = MagicMock()
|
||||
watcher = DockerWatcher(session_manager, socket_path="/nonexistent.sock")
|
||||
|
||||
# Mock the methods that would fail without Docker
|
||||
watcher._get_labeled_containers = AsyncMock(return_value=[])
|
||||
watcher._watch_events = AsyncMock()
|
||||
|
||||
await watcher.start()
|
||||
assert watcher._running is True
|
||||
|
||||
await watcher.stop()
|
||||
assert watcher._running is False
|
||||
|
||||
|
||||
class TestDockerWatcherIntegration:
|
||||
"""Integration-style tests for Docker watcher."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_start_event(self):
|
||||
"""Test handling a container start event."""
|
||||
session_manager = MagicMock()
|
||||
watcher = DockerWatcher(session_manager)
|
||||
|
||||
# Mock the docker request to return container info
|
||||
async def mock_request(method, path):
|
||||
if "/containers/" in path and "/json" in path:
|
||||
return (
|
||||
200,
|
||||
'{"Name": "/test-service", "Config": {"Labels": {"webterm-command": "auto"}}}',
|
||||
)
|
||||
return 404, ""
|
||||
|
||||
watcher._docker_request = mock_request
|
||||
|
||||
event = {
|
||||
"Action": "start",
|
||||
"Actor": {"ID": "container123", "Attributes": {LABEL_NAME: "auto"}},
|
||||
}
|
||||
|
||||
await watcher._handle_event(event)
|
||||
|
||||
# Should add container
|
||||
session_manager.add_app.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_die_event(self):
|
||||
"""Test handling a container die event."""
|
||||
session_manager = MagicMock()
|
||||
session_manager.apps_by_slug = {}
|
||||
session_manager.apps = []
|
||||
session_manager.get_session_by_route_key.return_value = None
|
||||
|
||||
watcher = DockerWatcher(session_manager)
|
||||
watcher._managed_containers["test-service"] = "container123"
|
||||
|
||||
event = {
|
||||
"Action": "die",
|
||||
"Actor": {"ID": "container123", "Attributes": {LABEL_NAME: "auto"}},
|
||||
}
|
||||
|
||||
await watcher._handle_event(event)
|
||||
|
||||
# Should remove container
|
||||
assert "test-service" not in watcher._managed_containers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_without_label(self):
|
||||
"""Test that events without our label are ignored."""
|
||||
session_manager = MagicMock()
|
||||
watcher = DockerWatcher(session_manager)
|
||||
|
||||
event = {
|
||||
"Action": "start",
|
||||
"Actor": {
|
||||
"ID": "container123",
|
||||
"Attributes": {}, # No label
|
||||
},
|
||||
}
|
||||
|
||||
await watcher._handle_event(event)
|
||||
|
||||
# Should not add container
|
||||
session_manager.add_app.assert_not_called()
|
||||
Reference in New Issue
Block a user