diff --git a/supervisor/docker/manager.py b/supervisor/docker/manager.py index 65688c03c..148fe92e4 100644 --- a/supervisor/docker/manager.py +++ b/supervisor/docker/manager.py @@ -327,7 +327,7 @@ class DockerAPI: # cleanup container if container: with suppress(docker_errors.DockerException, requests.RequestException): - container.remove(force=True) + container.remove(force=True, v=True) return CommandReturn(result.get("StatusCode"), output) @@ -442,7 +442,7 @@ class DockerAPI: if remove_container: with suppress(DockerException, requests.RequestException): _LOGGER.info("Cleaning %s application", name) - docker_container.remove(force=True) + docker_container.remove(force=True, v=True) def start_container(self, name: str) -> None: """Start Docker container.""" diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index 8bf89fcc2..258eccf05 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -843,7 +843,7 @@ async def test_addon_loads_wrong_image( with patch("pathlib.Path.is_file", return_value=True): await install_addon_ssh.load() - container.remove.assert_called_once_with(force=True) + container.remove.assert_called_once_with(force=True, v=True) assert coresys.docker.images.remove.call_args_list[0].kwargs == { "image": "local/aarch64-addon-ssh:latest", "force": True, diff --git a/tests/docker/test_manager.py b/tests/docker/test_manager.py new file mode 100644 index 000000000..bc2f85cce --- /dev/null +++ b/tests/docker/test_manager.py @@ -0,0 +1,134 @@ +"""Test Docker manager.""" + +from unittest.mock import MagicMock + +from docker.errors import DockerException +import pytest +from requests import RequestException + +from supervisor.docker.manager import CommandReturn, DockerAPI +from supervisor.exceptions import DockerError + + +async def test_run_command_success(docker: DockerAPI): + """Test successful command execution.""" + # Mock container and its methods + mock_container = MagicMock() + mock_container.wait.return_value = {"StatusCode": 0} + mock_container.logs.return_value = b"command output" + + # Mock docker containers.run to return our mock container + docker.docker.containers.run.return_value = mock_container + + # Execute the command + result = docker.run_command( + image="alpine", tag="3.18", command="echo hello", stdout=True, stderr=True + ) + + # Verify the result + assert isinstance(result, CommandReturn) + assert result.exit_code == 0 + assert result.output == b"command output" + + # Verify docker.containers.run was called correctly + docker.docker.containers.run.assert_called_once_with( + "alpine:3.18", + command="echo hello", + network=docker.network.name, + use_config_proxy=False, + stdout=True, + stderr=True, + ) + + # Verify container cleanup + mock_container.remove.assert_called_once_with(force=True, v=True) + + +async def test_run_command_with_defaults(docker: DockerAPI): + """Test command execution with default parameters.""" + # Mock container and its methods + mock_container = MagicMock() + mock_container.wait.return_value = {"StatusCode": 1} + mock_container.logs.return_value = b"error output" + + # Mock docker containers.run to return our mock container + docker.docker.containers.run.return_value = mock_container + + # Execute the command with minimal parameters + result = docker.run_command(image="ubuntu") + + # Verify the result + assert isinstance(result, CommandReturn) + assert result.exit_code == 1 + assert result.output == b"error output" + + # Verify docker.containers.run was called with defaults + docker.docker.containers.run.assert_called_once_with( + "ubuntu:latest", # default tag + command=None, # default command + network=docker.network.name, + use_config_proxy=False, + ) + + # Verify container.logs was called with default stdout/stderr + mock_container.logs.assert_called_once_with(stdout=True, stderr=True) + + +async def test_run_command_docker_exception(docker: DockerAPI): + """Test command execution when Docker raises an exception.""" + # Mock docker containers.run to raise DockerException + docker.docker.containers.run.side_effect = DockerException("Docker error") + + # Execute the command and expect DockerError + with pytest.raises(DockerError, match="Can't execute command: Docker error"): + docker.run_command(image="alpine", command="test") + + +async def test_run_command_request_exception(docker: DockerAPI): + """Test command execution when requests raises an exception.""" + # Mock docker containers.run to raise RequestException + docker.docker.containers.run.side_effect = RequestException("Connection error") + + # Execute the command and expect DockerError + with pytest.raises(DockerError, match="Can't execute command: Connection error"): + docker.run_command(image="alpine", command="test") + + +async def test_run_command_cleanup_on_exception(docker: DockerAPI): + """Test that container cleanup happens even when an exception occurs.""" + # Mock container + mock_container = MagicMock() + + # Mock docker.containers.run to return container, but container.wait to raise exception + docker.docker.containers.run.return_value = mock_container + mock_container.wait.side_effect = DockerException("Wait failed") + + # Execute the command and expect DockerError + with pytest.raises(DockerError): + docker.run_command(image="alpine", command="test") + + # Verify container cleanup still happened + mock_container.remove.assert_called_once_with(force=True, v=True) + + +async def test_run_command_custom_stdout_stderr(docker: DockerAPI): + """Test command execution with custom stdout/stderr settings.""" + # Mock container and its methods + mock_container = MagicMock() + mock_container.wait.return_value = {"StatusCode": 0} + mock_container.logs.return_value = b"output" + + # Mock docker containers.run to return our mock container + docker.docker.containers.run.return_value = mock_container + + # Execute the command with custom stdout/stderr + result = docker.run_command( + image="alpine", command="test", stdout=False, stderr=True + ) + + # Verify container.logs was called with the correct parameters + mock_container.logs.assert_called_once_with(stdout=False, stderr=True) + + # Verify the result + assert result.exit_code == 0 + assert result.output == b"output" diff --git a/tests/homeassistant/test_core.py b/tests/homeassistant/test_core.py index 151a19f70..c53d0cb99 100644 --- a/tests/homeassistant/test_core.py +++ b/tests/homeassistant/test_core.py @@ -200,7 +200,8 @@ async def test_start( coresys.docker.containers.get.return_value.stop.assert_not_called() if container_exists: coresys.docker.containers.get.return_value.remove.assert_called_once_with( - force=True + force=True, + v=True, ) else: coresys.docker.containers.get.return_value.remove.assert_not_called() @@ -397,7 +398,7 @@ async def test_core_loads_wrong_image_for_machine( await coresys.homeassistant.core.load() - container.remove.assert_called_once_with(force=True) + container.remove.assert_called_once_with(force=True, v=True) assert coresys.docker.images.remove.call_args_list[0].kwargs == { "image": "ghcr.io/home-assistant/odroid-n2-homeassistant:latest", "force": True, @@ -444,7 +445,7 @@ async def test_core_loads_wrong_image_for_architecture( await coresys.homeassistant.core.load() - container.remove.assert_called_once_with(force=True) + container.remove.assert_called_once_with(force=True, v=True) assert coresys.docker.images.remove.call_args_list[0].kwargs == { "image": "ghcr.io/home-assistant/qemux86-64-homeassistant:latest", "force": True, diff --git a/tests/plugins/test_plugin_base.py b/tests/plugins/test_plugin_base.py index 8e08755be..45a939914 100644 --- a/tests/plugins/test_plugin_base.py +++ b/tests/plugins/test_plugin_base.py @@ -363,7 +363,7 @@ async def test_load_with_incorrect_image( await plugin.load() - container.remove.assert_called_once_with(force=True) + container.remove.assert_called_once_with(force=True, v=True) assert coresys.docker.images.remove.call_args_list[0].kwargs == { "image": f"{old_image}:latest", "force": True, diff --git a/tests/resolution/fixup/test_addon_execute_rebuild.py b/tests/resolution/fixup/test_addon_execute_rebuild.py index c3104248b..bc3955fbf 100644 --- a/tests/resolution/fixup/test_addon_execute_rebuild.py +++ b/tests/resolution/fixup/test_addon_execute_rebuild.py @@ -76,7 +76,9 @@ async def test_fixup_stopped_core( assert not coresys.resolution.issues assert not coresys.resolution.suggestions - docker.containers.get("addon_local_ssh").remove.assert_called_once_with(force=True) + docker.containers.get("addon_local_ssh").remove.assert_called_once_with( + force=True, v=True + ) assert "Addon local_ssh is stopped" in caplog.text diff --git a/tests/resolution/fixup/test_core_execute_rebuild.py b/tests/resolution/fixup/test_core_execute_rebuild.py index 2c4c232e7..57e6e8f94 100644 --- a/tests/resolution/fixup/test_core_execute_rebuild.py +++ b/tests/resolution/fixup/test_core_execute_rebuild.py @@ -65,7 +65,9 @@ async def test_fixup_stopped_core( assert not coresys.resolution.issues assert not coresys.resolution.suggestions - docker.containers.get("homeassistant").remove.assert_called_once_with(force=True) + docker.containers.get("homeassistant").remove.assert_called_once_with( + force=True, v=True + ) assert "Home Assistant is stopped" in caplog.text