mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-10-18 16:19:38 +00:00

* Fix CID file handling to prevent directory creation It seems that under certain conditions Docker creates a directory instead of a file for the CID file. This change ensures that the CID file is always created as a file, and any existing directory is removed before creating the file. * Fix tests * Fix pytest
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""Test Docker manager."""
|
|
|
|
import asyncio
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from docker.errors import DockerException
|
|
import pytest
|
|
from requests import RequestException
|
|
|
|
from supervisor.coresys import CoreSys
|
|
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", version="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",
|
|
detach=True,
|
|
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
|
|
detach=True,
|
|
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"
|
|
|
|
|
|
async def test_run_container_with_cidfile(
|
|
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
|
|
):
|
|
"""Test container creation with cidfile and bind mount."""
|
|
# Mock container
|
|
mock_container = MagicMock()
|
|
mock_container.id = "test_container_id_12345"
|
|
|
|
container_name = "test_container"
|
|
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
|
extern_cidfile_path = coresys.config.path_extern_cid_files / f"{container_name}.cid"
|
|
|
|
docker.docker.containers.run.return_value = mock_container
|
|
|
|
# Mock container creation
|
|
with patch.object(
|
|
docker.containers, "create", return_value=mock_container
|
|
) as create_mock:
|
|
# Execute run with a container name
|
|
loop = asyncio.get_event_loop()
|
|
result = await loop.run_in_executor(
|
|
None,
|
|
lambda kwrgs: docker.run(**kwrgs),
|
|
{"image": "test_image", "tag": "latest", "name": container_name},
|
|
)
|
|
|
|
# Check the container creation parameters
|
|
create_mock.assert_called_once()
|
|
kwargs = create_mock.call_args[1]
|
|
|
|
assert "volumes" in kwargs
|
|
assert str(extern_cidfile_path) in kwargs["volumes"]
|
|
assert kwargs["volumes"][str(extern_cidfile_path)]["bind"] == "/run/cid"
|
|
assert kwargs["volumes"][str(extern_cidfile_path)]["mode"] == "ro"
|
|
|
|
# Verify container start was called
|
|
mock_container.start.assert_called_once()
|
|
|
|
# Verify cidfile was written with container ID
|
|
assert cidfile_path.exists()
|
|
assert cidfile_path.read_text() == mock_container.id
|
|
|
|
assert result == mock_container
|
|
|
|
|
|
async def test_run_container_with_leftover_cidfile(
|
|
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
|
|
):
|
|
"""Test container creation removes leftover cidfile before creating new one."""
|
|
# Mock container
|
|
mock_container = MagicMock()
|
|
mock_container.id = "test_container_id_new"
|
|
|
|
container_name = "test_container"
|
|
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
|
|
|
# Create a leftover cidfile
|
|
cidfile_path.touch()
|
|
|
|
# Mock container creation
|
|
with patch.object(
|
|
docker.containers, "create", return_value=mock_container
|
|
) as create_mock:
|
|
# Execute run with a container name
|
|
loop = asyncio.get_event_loop()
|
|
result = await loop.run_in_executor(
|
|
None,
|
|
lambda kwrgs: docker.run(**kwrgs),
|
|
{"image": "test_image", "tag": "latest", "name": container_name},
|
|
)
|
|
|
|
# Verify container was created
|
|
create_mock.assert_called_once()
|
|
|
|
# Verify new cidfile was written with container ID
|
|
assert cidfile_path.exists()
|
|
assert cidfile_path.read_text() == mock_container.id
|
|
|
|
assert result == mock_container
|
|
|
|
|
|
async def test_stop_container_with_cidfile_cleanup(
|
|
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
|
|
):
|
|
"""Test container stop with cidfile cleanup."""
|
|
# Mock container
|
|
mock_container = MagicMock()
|
|
mock_container.status = "running"
|
|
|
|
container_name = "test_container"
|
|
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
|
|
|
# Create a cidfile
|
|
cidfile_path.touch()
|
|
|
|
# Mock the containers.get method and cidfile cleanup
|
|
with (
|
|
patch.object(docker.containers, "get", return_value=mock_container),
|
|
):
|
|
# Call stop_container with remove_container=True
|
|
loop = asyncio.get_event_loop()
|
|
await loop.run_in_executor(
|
|
None,
|
|
lambda kwrgs: docker.stop_container(**kwrgs),
|
|
{"timeout": 10, "remove_container": True, "name": container_name},
|
|
)
|
|
|
|
# Verify container operations
|
|
mock_container.stop.assert_called_once_with(timeout=10)
|
|
mock_container.remove.assert_called_once_with(force=True, v=True)
|
|
|
|
assert not cidfile_path.exists()
|
|
|
|
|
|
async def test_stop_container_without_removal_no_cidfile_cleanup(docker: DockerAPI):
|
|
"""Test container stop without removal doesn't clean up cidfile."""
|
|
# Mock container
|
|
mock_container = MagicMock()
|
|
mock_container.status = "running"
|
|
|
|
container_name = "test_container"
|
|
|
|
# Mock the containers.get method and cidfile cleanup
|
|
with (
|
|
patch.object(docker.containers, "get", return_value=mock_container),
|
|
patch("pathlib.Path.unlink") as mock_unlink,
|
|
):
|
|
# Call stop_container with remove_container=False
|
|
docker.stop_container(container_name, timeout=10, remove_container=False)
|
|
|
|
# Verify container operations
|
|
mock_container.stop.assert_called_once_with(timeout=10)
|
|
mock_container.remove.assert_not_called()
|
|
|
|
# Verify cidfile cleanup was NOT called
|
|
mock_unlink.assert_not_called()
|
|
|
|
|
|
async def test_cidfile_cleanup_handles_oserror(
|
|
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
|
|
):
|
|
"""Test that cidfile cleanup handles OSError gracefully."""
|
|
# Mock container
|
|
mock_container = MagicMock()
|
|
mock_container.status = "running"
|
|
|
|
container_name = "test_container"
|
|
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
|
|
|
# Create a cidfile
|
|
cidfile_path.touch()
|
|
|
|
# Mock the containers.get method and cidfile cleanup to raise OSError
|
|
with (
|
|
patch.object(docker.containers, "get", return_value=mock_container),
|
|
patch("pathlib.Path.is_dir", return_value=False),
|
|
patch("pathlib.Path.is_file", return_value=True),
|
|
patch(
|
|
"pathlib.Path.unlink", side_effect=OSError("File not found")
|
|
) as mock_unlink,
|
|
):
|
|
# Call stop_container - should not raise exception
|
|
docker.stop_container(container_name, timeout=10, remove_container=True)
|
|
|
|
# Verify container operations completed
|
|
mock_container.stop.assert_called_once_with(timeout=10)
|
|
mock_container.remove.assert_called_once_with(force=True, v=True)
|
|
|
|
# Verify cidfile cleanup was attempted
|
|
mock_unlink.assert_called_once_with(missing_ok=True)
|
|
|
|
|
|
async def test_run_container_with_leftover_cidfile_directory(
|
|
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
|
|
):
|
|
"""Test container creation removes leftover cidfile directory before creating new one.
|
|
|
|
This can happen when Docker auto-starts a container with restart policy
|
|
before Supervisor could write the CID file, causing Docker to create
|
|
the bind mount source as a directory.
|
|
"""
|
|
# Mock container
|
|
mock_container = MagicMock()
|
|
mock_container.id = "test_container_id_new"
|
|
|
|
container_name = "test_container"
|
|
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
|
|
|
# Create a leftover directory (simulating Docker's behavior)
|
|
cidfile_path.mkdir()
|
|
assert cidfile_path.is_dir()
|
|
|
|
# Mock container creation
|
|
with patch.object(
|
|
docker.containers, "create", return_value=mock_container
|
|
) as create_mock:
|
|
# Execute run with a container name
|
|
loop = asyncio.get_event_loop()
|
|
result = await loop.run_in_executor(
|
|
None,
|
|
lambda kwrgs: docker.run(**kwrgs),
|
|
{"image": "test_image", "tag": "latest", "name": container_name},
|
|
)
|
|
|
|
# Verify container was created
|
|
create_mock.assert_called_once()
|
|
|
|
# Verify new cidfile was written as a file (not directory)
|
|
assert cidfile_path.exists()
|
|
assert cidfile_path.is_file()
|
|
assert cidfile_path.read_text() == mock_container.id
|
|
|
|
assert result == mock_container
|