Files
supervisor/tests/docker/test_manager.py
Stefan Agner f3e1e0f423 Fix CID file handling to prevent directory creation (#6225)
* 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
2025-10-02 09:24:19 +02:00

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