diff --git a/src/devx/molecule/start_docker.py b/src/devx/molecule/start_docker.py index c785452..bae250a 100644 --- a/src/devx/molecule/start_docker.py +++ b/src/devx/molecule/start_docker.py @@ -41,6 +41,54 @@ def is_docker_ready() -> bool: return result.returncode == 0 +def _diagnose_socket() -> None: + """Print diagnostic info about the Docker socket.""" + click.echo(f"DOCKER_HOST = {os.environ.get('DOCKER_HOST', '(not set)')}") + click.echo(f"Socket path: {DOCKER_SOCK}") + click.echo(f"Socket exists: {os.path.exists(DOCKER_SOCK)}") + if os.path.exists(DOCKER_SOCK): + stat = os.stat(DOCKER_SOCK) + click.echo(f"Socket mode: {oct(stat.st_mode)}") + click.echo(f"Socket uid: {stat.st_uid}, gid: {stat.st_gid}") + # Check if it's a mount point + result = subprocess.run( # nosec B603 B607 + ["mount"], + capture_output=True, + check=False, + text=True, + ) + docker_mounts = [line for line in result.stdout.splitlines() if "docker" in line.lower()] + if docker_mounts: + click.echo("Docker-related mounts:") + for line in docker_mounts: + click.echo(f" {line}") + else: + click.echo("No Docker-related mounts found") + # Check docker context + result = subprocess.run( # nosec B603 B607 + ["docker", "context", "ls"], + capture_output=True, + check=False, + text=True, + ) + click.echo(f"Docker contexts:\n{result.stdout}") + # Try docker info without DOCKER_HOST + result = subprocess.run( # nosec B603 B607 + ["docker", "info"], + capture_output=True, + check=False, + text=True, + ) + click.echo(f"docker info (no DOCKER_HOST): rc={result.returncode}") + if result.returncode != 0: + click.echo(f" stderr: {result.stderr[:500]}") + else: + # Print server version and storage driver + for line in result.stdout.splitlines(): + if "Server Version" in line or "Storage Driver" in line or "Docker Root Dir" in line: + click.echo(f" {line.strip()}") + + def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool: """Ensure Docker is ready for molecule tests. @@ -54,16 +102,23 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool: # Point Docker CLI and Python library to the socket explicitly os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}" + # Diagnose socket state + click.echo("--- Docker socket diagnostics ---") + _diagnose_socket() + click.echo("--- End diagnostics ---") + # Check if host Docker is already available if is_docker_ready(): click.echo(_("Docker daemon already running")) return True + click.echo(_("Host Docker not available, starting local dockerd...")) + # Start local dockerd (requires privileged container) - click.echo(_("Starting Docker daemon...")) log_file = tempfile.NamedTemporaryFile( # noqa: SIM115 mode="w", suffix="dockerd.log", delete=False ) + click.echo(f"dockerd log: {log_file.name}") subprocess.Popen( # nosec B603 B607 [ "dockerd", @@ -83,7 +138,17 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool: return True time.sleep(1) + # Print dockerd log on failure click.echo(_("Docker daemon failed to start")) + click.echo("--- dockerd log ---") + try: + with open(log_file.name) as f: + log_content = f.read() + click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content) + except OSError as e: + click.echo(f"Could not read log: {e}") + click.echo("--- End dockerd log ---") + return False diff --git a/src/devx/translations.json b/src/devx/translations.json index 63916d9..0081307 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -559,6 +559,13 @@ "ru": "Head branch is behind master. Pulling and rebasing...", "zh": "Head branch is behind master. Pulling and rebasing..." }, + "Host Docker not available, starting local dockerd...": { + "bg": "Хост Docker не е наличен, стартиране на локален dockerd...", + "de": "Host-Docker nicht verfügbar, lokaler dockerd wird gestartet...", + "en": "Host Docker not available, starting local dockerd...", + "ru": "Хост Docker недоступен, запускается локальный dockerd...", + "zh": "主机 Docker 不可用,正在启动本地 dockerd..." + }, "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": { "bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}", "de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}", @@ -937,13 +944,6 @@ "ru": "Skipping commit push — no staged changes.", "zh": "Skipping commit push — no staged changes." }, - "Starting Docker daemon...": { - "bg": "Starting Docker daemon...", - "de": "Docker-Daemon wird gestartet...", - "en": "Starting Docker daemon...", - "ru": "Запуск Docker-демона...", - "zh": "正在启动 Docker 守护进程..." - }, "Syncing {count} documentation pages to wiki...": { "bg": "Syncing {count} documentation pages to wiki...", "de": "Syncing {count} documentation pages to wiki...", diff --git a/tests/unit/test_start_docker.py b/tests/unit/test_start_docker.py index 5f71b67..6329f64 100644 --- a/tests/unit/test_start_docker.py +++ b/tests/unit/test_start_docker.py @@ -1,10 +1,16 @@ """Unit tests for devx.molecule.start_docker.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, mock_open, patch from click.testing import CliRunner -from devx.molecule.start_docker import DOCKER_SOCK, is_docker_ready, main, start_docker_daemon +from devx.molecule.start_docker import ( + DOCKER_SOCK, + _diagnose_socket, + is_docker_ready, + main, + start_docker_daemon, +) class TestIsDockerReady: @@ -22,13 +28,46 @@ class TestIsDockerReady: assert is_docker_ready() is False +class TestDiagnoseSocket: + @patch("devx.molecule.start_docker.os.stat") + @patch("devx.molecule.start_docker.os.path.exists", return_value=True) + @patch("devx.molecule.start_docker.subprocess.run") + def test_socket_exists(self, mock_run: MagicMock, mock_exists: MagicMock, mock_stat: MagicMock) -> None: + mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0) + mock_run.side_effect = [ + MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""), + MagicMock(stdout="default\n", returncode=0, text=""), + MagicMock( + stdout="Server Version: 29.5.2\nStorage Driver: overlay2\nDocker Root Dir: /var/lib/docker\n", + returncode=0, + text="", + ), + ] + _diagnose_socket() + mock_exists.assert_called_with(DOCKER_SOCK) + + @patch("devx.molecule.start_docker.os.path.exists", return_value=False) + @patch("devx.molecule.start_docker.subprocess.run") + def test_socket_missing(self, mock_run: MagicMock, mock_exists: MagicMock) -> None: + mock_run.side_effect = [ + MagicMock(stdout="proc on /proc type proc\n", returncode=0, text=""), + MagicMock(stdout="default\n", returncode=0, text=""), + MagicMock(stdout="", stderr="Cannot connect", returncode=1, text=""), + ] + _diagnose_socket() + mock_exists.assert_called_with(DOCKER_SOCK) + + class TestStartDockerDaemon: + @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.is_docker_ready", return_value=True) - def test_host_socket_available(self, mock_ready: MagicMock) -> None: + def test_host_socket_available(self, mock_ready: MagicMock, mock_diag: MagicMock) -> None: """Should return immediately if host Docker is available.""" assert start_docker_daemon(timeout=5) is True mock_ready.assert_called_once() + mock_diag.assert_called_once() + @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.time.sleep") @patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.subprocess.Popen") @@ -39,9 +78,9 @@ class TestStartDockerDaemon: mock_popen: MagicMock, mock_ready: MagicMock, mock_sleep: MagicMock, + mock_diag: MagicMock, ) -> None: - mock_ntf.return_value = MagicMock() - # Host socket not available, then local daemon starts on third check + mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ready.side_effect = [False, False, False, True] assert start_docker_daemon(timeout=5) is True mock_popen.assert_called_once() @@ -53,6 +92,7 @@ class TestStartDockerDaemon: assert f"unix://{DOCKER_SOCK}" in popen_args assert mock_sleep.call_count == 2 + @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.time.sleep") @patch("devx.molecule.start_docker.is_docker_ready", return_value=False) @patch("devx.molecule.start_docker.subprocess.Popen") @@ -63,12 +103,33 @@ class TestStartDockerDaemon: mock_popen: MagicMock, mock_ready: MagicMock, mock_sleep: MagicMock, + mock_diag: MagicMock, ) -> None: - mock_ntf.return_value = MagicMock() - assert start_docker_daemon(timeout=3) is False + mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") + with patch("builtins.open", mock_open(read_data="dockerd error log")): + assert start_docker_daemon(timeout=3) is False mock_popen.assert_called_once() assert mock_sleep.call_count == 3 + @patch("devx.molecule.start_docker._diagnose_socket") + @patch("devx.molecule.start_docker.time.sleep") + @patch("devx.molecule.start_docker.is_docker_ready", return_value=False) + @patch("devx.molecule.start_docker.subprocess.Popen") + @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") + def test_fails_log_read_error( + self, + mock_ntf: MagicMock, + mock_popen: MagicMock, + mock_ready: MagicMock, + mock_sleep: MagicMock, + mock_diag: MagicMock, + ) -> None: + """Should handle log read errors gracefully.""" + mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") + with patch("builtins.open", side_effect=OSError("permission denied")): + assert start_docker_daemon(timeout=2) is False + + @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.time.sleep") @patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.subprocess.Popen") @@ -79,20 +140,22 @@ class TestStartDockerDaemon: mock_popen: MagicMock, mock_ready: MagicMock, mock_sleep: MagicMock, + mock_diag: MagicMock, ) -> None: - mock_ntf.return_value = MagicMock() - # Host not available, local daemon ready on first loop check + mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ready.side_effect = [False, False, True] assert start_docker_daemon(timeout=5) is True mock_popen.assert_called_once() mock_sleep.assert_called_once_with(1) + @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.os.environ") @patch("devx.molecule.start_docker.is_docker_ready", return_value=True) def test_sets_docker_host( self, mock_ready: MagicMock, mock_environ: MagicMock, + mock_diag: MagicMock, ) -> None: """DOCKER_HOST must be set so molecule connects to correct socket.""" start_docker_daemon(timeout=5)