mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-08-13 00:12:57 +01:00
Follow-up to review feedback on #7003 and #7004: when removing the hosts file in CoreDNS.reset() or cleaning up a backup tarfile in BackupManager.import_backup(), pass any OSError to sys_resolution.check_oserror() so known filesystem issues (e.g. EBADMSG) mark the system unhealthy early. For the backup paths the check only applies to local locations (default and cloud backup), matching the existing handling in remove() and _copy_to_location(). This also covers the second unlink in import_backup() (cleanup after a failed load) and stops OSError from bubbling out of both cleanup paths unhandled to the job: the error is now logged, and the method still returns None or raises the original BackupInvalidError. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
502 lines
17 KiB
Python
502 lines
17 KiB
Python
"""Test DNS plugin."""
|
|
|
|
import asyncio
|
|
import errno
|
|
from ipaddress import IPv4Address
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
from aiodocker.containers import DockerContainer
|
|
import pytest
|
|
|
|
from supervisor.const import BusEvent, LogLevel
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.docker.const import ContainerState
|
|
from supervisor.docker.dns import DockerDNS
|
|
from supervisor.docker.monitor import DockerContainerStateEvent
|
|
from supervisor.plugins.dns import HostEntry, PluginDns
|
|
from supervisor.resolution.const import (
|
|
ContextType,
|
|
IssueType,
|
|
SuggestionType,
|
|
UnhealthyReason,
|
|
)
|
|
from supervisor.resolution.data import Issue, Suggestion
|
|
|
|
|
|
@pytest.fixture(name="docker_interface")
|
|
async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]:
|
|
"""Mock docker interface methods."""
|
|
with (
|
|
patch.object(DockerDNS, "run") as run,
|
|
patch.object(DockerDNS, "restart") as restart,
|
|
):
|
|
yield (run, restart)
|
|
|
|
|
|
@pytest.fixture(name="write_json")
|
|
async def fixture_write_json() -> Mock:
|
|
"""Mock json file writer."""
|
|
with patch("supervisor.plugins.dns.write_json_file") as write_json_file:
|
|
yield write_json_file
|
|
|
|
|
|
@pytest.fixture(name="mock_call_later")
|
|
def fixture_mock_call_later(coresys: CoreSys):
|
|
"""Mock sys_call_later with zero delay for testing."""
|
|
|
|
def mock_call_later(_delay, *args, **kwargs) -> asyncio.TimerHandle:
|
|
"""Mock to remove delay."""
|
|
return coresys.call_later(0, *args, **kwargs)
|
|
|
|
return mock_call_later
|
|
|
|
|
|
async def test_config_write(
|
|
coresys: CoreSys,
|
|
docker_interface: tuple[AsyncMock, AsyncMock],
|
|
write_json: Mock,
|
|
):
|
|
"""Test config write on DNS start and restart."""
|
|
assert coresys.plugins.dns.locals == ["dns://192.168.30.1"]
|
|
coresys.plugins.dns.servers = ["dns://1.1.1.1", "dns://8.8.8.8"]
|
|
|
|
await coresys.plugins.dns.start()
|
|
docker_interface[0].assert_called_once()
|
|
docker_interface[1].assert_not_called()
|
|
|
|
write_json.assert_called_once_with(
|
|
Path("/data/dns/coredns.json"),
|
|
{
|
|
"servers": ["dns://1.1.1.1", "dns://8.8.8.8"],
|
|
"locals": ["dns://192.168.30.1"],
|
|
"fallback": True,
|
|
"debug": False,
|
|
},
|
|
)
|
|
|
|
docker_interface[0].reset_mock()
|
|
write_json.reset_mock()
|
|
coresys.plugins.dns.servers = ["dns://8.8.8.8"]
|
|
coresys.plugins.dns.fallback = False
|
|
coresys.config.logging = LogLevel.DEBUG
|
|
|
|
await coresys.plugins.dns.restart()
|
|
docker_interface[0].assert_not_called()
|
|
docker_interface[1].assert_called_once()
|
|
|
|
write_json.assert_called_once_with(
|
|
Path("/data/dns/coredns.json"),
|
|
{
|
|
"servers": ["dns://8.8.8.8"],
|
|
"locals": ["dns://192.168.30.1"],
|
|
"fallback": False,
|
|
"debug": True,
|
|
},
|
|
)
|
|
|
|
|
|
async def test_reset(coresys: CoreSys):
|
|
"""Test reset returns dns plugin to defaults."""
|
|
coresys.plugins.dns.servers = ["dns://1.1.1.1", "dns://8.8.8.8"]
|
|
coresys.plugins.dns.fallback = False
|
|
coresys.plugins.dns._loop = True # pylint: disable=protected-access
|
|
assert len(coresys.apps.installed) == 0
|
|
|
|
with (
|
|
patch.object(type(coresys.plugins.dns.hosts), "unlink") as unlink,
|
|
patch.object(type(coresys.plugins.dns), "write_hosts") as write_hosts,
|
|
):
|
|
await coresys.plugins.dns.reset()
|
|
|
|
assert coresys.plugins.dns.servers == []
|
|
assert coresys.plugins.dns.fallback is True
|
|
assert coresys.plugins.dns._loop is False # pylint: disable=protected-access
|
|
unlink.assert_called_once()
|
|
write_hosts.assert_called_once()
|
|
|
|
# Verify the hosts data structure is properly initialized
|
|
# pylint: disable=protected-access
|
|
assert coresys.plugins.dns._hosts == [
|
|
HostEntry(
|
|
ip_address=IPv4Address("127.0.0.1"),
|
|
names=["localhost", "localhost.local.hass.io"],
|
|
),
|
|
HostEntry(
|
|
ip_address=IPv4Address("172.30.32.2"),
|
|
names=[
|
|
"hassio",
|
|
"hassio.local.hass.io",
|
|
"supervisor",
|
|
"supervisor.local.hass.io",
|
|
],
|
|
),
|
|
HostEntry(
|
|
ip_address=IPv4Address("172.30.32.1"),
|
|
names=[
|
|
"homeassistant",
|
|
"homeassistant.local.hass.io",
|
|
"home-assistant",
|
|
"home-assistant.local.hass.io",
|
|
],
|
|
),
|
|
HostEntry(
|
|
ip_address=IPv4Address("172.30.32.3"),
|
|
names=["dns", "dns.local.hass.io"],
|
|
),
|
|
HostEntry(
|
|
ip_address=IPv4Address("172.30.32.6"),
|
|
names=["observer", "observer.local.hass.io"],
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error_num", "unhealthy"),
|
|
[(errno.EBUSY, False), (errno.EBADMSG, True)],
|
|
)
|
|
async def test_reset_hosts_unlink_oserror(
|
|
coresys: CoreSys, error_num: int, unhealthy: bool
|
|
):
|
|
"""Test reset does not fail if hosts cannot be removed but checks OSError."""
|
|
err = OSError()
|
|
err.errno = error_num
|
|
|
|
with (
|
|
patch.object(type(coresys.plugins.dns.hosts), "unlink", side_effect=err),
|
|
patch.object(type(coresys.plugins.dns), "write_hosts"),
|
|
):
|
|
await coresys.plugins.dns.reset()
|
|
|
|
assert (
|
|
UnhealthyReason.OSERROR_BAD_MESSAGE in coresys.resolution.unhealthy
|
|
) is unhealthy
|
|
|
|
|
|
async def test_loop_detection_on_failure(coresys: CoreSys, container: DockerContainer):
|
|
"""Test loop detection when coredns fails."""
|
|
assert len(coresys.resolution.issues) == 0
|
|
assert len(coresys.resolution.suggestions) == 0
|
|
|
|
with (
|
|
patch.object(DockerDNS, "attach"),
|
|
patch.object(DockerDNS, "is_running", return_value=True),
|
|
):
|
|
await coresys.plugins.dns.load()
|
|
|
|
with (
|
|
patch.object(PluginDns, "rebuild") as rebuild,
|
|
patch.object(
|
|
DockerDNS,
|
|
"current_state",
|
|
side_effect=[
|
|
ContainerState.FAILED,
|
|
ContainerState.FAILED,
|
|
],
|
|
),
|
|
):
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name="hassio_dns",
|
|
state=ContainerState.FAILED,
|
|
id="abc123",
|
|
time=1,
|
|
exit_code=1,
|
|
),
|
|
)
|
|
await asyncio.sleep(0)
|
|
assert len(coresys.resolution.issues) == 0
|
|
assert len(coresys.resolution.suggestions) == 0
|
|
rebuild.assert_called_once()
|
|
|
|
rebuild.reset_mock()
|
|
container.log.return_value = ["plugin/loop: Loop"]
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name="hassio_dns",
|
|
state=ContainerState.FAILED,
|
|
id="abc123",
|
|
time=1,
|
|
exit_code=1,
|
|
),
|
|
)
|
|
await asyncio.sleep(0)
|
|
assert coresys.resolution.issues == [
|
|
Issue(IssueType.DNS_LOOP, ContextType.PLUGIN, "dns")
|
|
]
|
|
assert coresys.resolution.suggestions == [
|
|
Suggestion(SuggestionType.EXECUTE_RESET, ContextType.PLUGIN, "dns")
|
|
]
|
|
rebuild.assert_called_once()
|
|
|
|
|
|
async def test_load_error(
|
|
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
|
|
):
|
|
"""Test error reading config files during load."""
|
|
with (
|
|
patch("supervisor.plugins.dns.Path.read_text", side_effect=(err := OSError())),
|
|
patch("supervisor.plugins.dns.Path.write_text", side_effect=err),
|
|
):
|
|
err.errno = errno.EBUSY
|
|
await coresys.plugins.dns.load()
|
|
|
|
assert "Can't read resolve.tmpl" in caplog.text
|
|
assert "Can't read hosts.tmpl" in caplog.text
|
|
assert coresys.core.healthy is True
|
|
|
|
caplog.clear()
|
|
err.errno = errno.EBADMSG
|
|
await coresys.plugins.dns.load()
|
|
|
|
assert "Can't read resolve.tmpl" in caplog.text
|
|
assert "Can't read hosts.tmpl" in caplog.text
|
|
assert coresys.core.healthy is False
|
|
|
|
|
|
async def test_load_error_writing_resolv(
|
|
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
|
|
):
|
|
"""Test error writing resolv during load."""
|
|
with patch(
|
|
"supervisor.plugins.dns.Path.write_text", side_effect=(err := OSError())
|
|
):
|
|
err.errno = errno.EBUSY
|
|
await coresys.plugins.dns.load()
|
|
|
|
assert "Can't write/update /etc/resolv.conf" in caplog.text
|
|
assert coresys.core.healthy is True
|
|
|
|
caplog.clear()
|
|
err.errno = errno.EBADMSG
|
|
await coresys.plugins.dns.load()
|
|
|
|
assert "Can't write/update /etc/resolv.conf" in caplog.text
|
|
assert coresys.core.healthy is False
|
|
|
|
|
|
async def test_notify_locals_changed_end_to_end_with_changes_and_running(
|
|
coresys: CoreSys, mock_call_later
|
|
):
|
|
"""Test notify_locals_changed end-to-end: local DNS changes detected and plugin restarted."""
|
|
dns_plugin = coresys.plugins.dns
|
|
|
|
# Set cached locals to something different from current network state
|
|
current_locals = dns_plugin._compute_locals()
|
|
dns_plugin._cached_locals = (
|
|
["dns://192.168.1.1"]
|
|
if current_locals != ["dns://192.168.1.1"]
|
|
else ["dns://192.168.1.2"]
|
|
)
|
|
|
|
with (
|
|
patch.object(dns_plugin, "restart") as mock_restart,
|
|
patch.object(dns_plugin.instance, "is_running", return_value=True),
|
|
patch.object(dns_plugin, "sys_call_later", new=mock_call_later),
|
|
):
|
|
# Call notify_locals_changed
|
|
dns_plugin.notify_locals_changed()
|
|
|
|
# Wait for the async task to complete
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Verify restart was called and cached locals were updated
|
|
mock_restart.assert_called_once()
|
|
assert dns_plugin._cached_locals == current_locals
|
|
|
|
|
|
async def test_notify_locals_changed_end_to_end_with_changes_but_not_running(
|
|
coresys: CoreSys, mock_call_later
|
|
):
|
|
"""Test notify_locals_changed end-to-end: local DNS changes detected but plugin not running."""
|
|
dns_plugin = coresys.plugins.dns
|
|
|
|
# Set cached locals to something different from current network state
|
|
current_locals = dns_plugin._compute_locals()
|
|
dns_plugin._cached_locals = (
|
|
["dns://192.168.1.1"]
|
|
if current_locals != ["dns://192.168.1.1"]
|
|
else ["dns://192.168.1.2"]
|
|
)
|
|
|
|
with (
|
|
patch.object(dns_plugin, "restart") as mock_restart,
|
|
patch.object(dns_plugin.instance, "is_running", return_value=False),
|
|
patch.object(dns_plugin, "sys_call_later", new=mock_call_later),
|
|
):
|
|
# Call notify_locals_changed
|
|
dns_plugin.notify_locals_changed()
|
|
|
|
# Wait for the async task to complete
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Verify restart was NOT called but cached locals were still updated
|
|
mock_restart.assert_not_called()
|
|
assert dns_plugin._cached_locals == current_locals
|
|
|
|
|
|
async def test_notify_locals_changed_end_to_end_no_changes(
|
|
coresys: CoreSys, mock_call_later
|
|
):
|
|
"""Test notify_locals_changed end-to-end: no local DNS changes detected."""
|
|
dns_plugin = coresys.plugins.dns
|
|
|
|
# Set cached locals to match current network state
|
|
current_locals = dns_plugin._compute_locals()
|
|
dns_plugin._cached_locals = current_locals
|
|
|
|
with (
|
|
patch.object(dns_plugin, "restart") as mock_restart,
|
|
patch.object(dns_plugin, "sys_call_later", new=mock_call_later),
|
|
):
|
|
# Call notify_locals_changed
|
|
dns_plugin.notify_locals_changed()
|
|
|
|
# Wait for the async task to complete
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Verify restart was NOT called since no changes
|
|
mock_restart.assert_not_called()
|
|
assert dns_plugin._cached_locals == current_locals
|
|
|
|
|
|
async def test_notify_locals_changed_debouncing_cancels_previous_timer(
|
|
coresys: CoreSys,
|
|
):
|
|
"""Test notify_locals_changed debouncing cancels previous timer before creating new one."""
|
|
dns_plugin = coresys.plugins.dns
|
|
|
|
# Set cached locals to trigger change detection
|
|
current_locals = dns_plugin._compute_locals()
|
|
dns_plugin._cached_locals = (
|
|
["dns://192.168.1.1"]
|
|
if current_locals != ["dns://192.168.1.1"]
|
|
else ["dns://192.168.1.2"]
|
|
)
|
|
|
|
call_count = 0
|
|
handles = []
|
|
|
|
def mock_call_later_with_tracking(_delay, *args, **kwargs) -> asyncio.TimerHandle:
|
|
"""Mock to remove delay and track calls."""
|
|
nonlocal call_count
|
|
call_count += 1
|
|
handle = coresys.call_later(0, *args, **kwargs)
|
|
handles.append(handle)
|
|
return handle
|
|
|
|
with (
|
|
patch.object(dns_plugin, "restart") as mock_restart,
|
|
patch.object(dns_plugin.instance, "is_running", return_value=True),
|
|
patch.object(dns_plugin, "sys_call_later", new=mock_call_later_with_tracking),
|
|
):
|
|
# First call sets up timer
|
|
dns_plugin.notify_locals_changed()
|
|
assert call_count == 1
|
|
first_handle = dns_plugin._locals_changed_handle
|
|
assert first_handle is not None
|
|
|
|
# Second call should cancel first timer and create new one
|
|
dns_plugin.notify_locals_changed()
|
|
assert call_count == 2
|
|
second_handle = dns_plugin._locals_changed_handle
|
|
assert second_handle is not None
|
|
assert first_handle != second_handle
|
|
|
|
# Wait for the async task to complete
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Verify restart was called once for the final timer
|
|
mock_restart.assert_called_once()
|
|
assert dns_plugin._cached_locals == current_locals
|
|
|
|
|
|
async def test_stop_cancels_pending_timers_and_tasks(coresys: CoreSys):
|
|
"""Test stop cancels pending locals change timers and restart tasks to prevent resource leaks."""
|
|
dns_plugin = coresys.plugins.dns
|
|
|
|
mock_timer_handle = Mock()
|
|
mock_task_handle = Mock()
|
|
dns_plugin._locals_changed_handle = mock_timer_handle
|
|
dns_plugin._restart_after_locals_change_handle = mock_task_handle
|
|
|
|
with patch.object(dns_plugin.instance, "stop"):
|
|
await dns_plugin.stop()
|
|
|
|
# Should cancel pending timer and task, then clean up
|
|
mock_timer_handle.cancel.assert_called_once()
|
|
mock_task_handle.cancel.assert_called_once()
|
|
assert dns_plugin._locals_changed_handle is None
|
|
assert dns_plugin._restart_after_locals_change_handle is None
|
|
|
|
|
|
async def test_dns_restart_triggers_connectivity_check(coresys: CoreSys):
|
|
"""Test end-to-end that DNS container restart triggers connectivity check."""
|
|
dns_plugin = coresys.plugins.dns
|
|
|
|
# Load the plugin to register the event listener
|
|
with (
|
|
patch.object(type(dns_plugin.instance), "attach"),
|
|
patch.object(type(dns_plugin.instance), "is_running", return_value=True),
|
|
):
|
|
await dns_plugin.load()
|
|
|
|
# Verify listener was registered (connectivity check listener should be stored)
|
|
assert dns_plugin._connectivity_check_listener is not None
|
|
|
|
# Create event to signal when connectivity check is requested
|
|
connectivity_check_event = asyncio.Event()
|
|
|
|
# Mock the fire-and-forget request to set the event when called
|
|
def mock_request_connectivity_check(*, force: bool = False):
|
|
connectivity_check_event.set()
|
|
|
|
with (
|
|
patch.object(
|
|
coresys.supervisor,
|
|
"request_connectivity_check",
|
|
side_effect=mock_request_connectivity_check,
|
|
),
|
|
patch("supervisor.plugins.dns.asyncio.sleep") as mock_sleep,
|
|
):
|
|
# Fire the DNS container state change event through bus system
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name="hassio_dns",
|
|
state=ContainerState.RUNNING,
|
|
id="test_id",
|
|
time=1234567890,
|
|
),
|
|
)
|
|
|
|
# Wait for connectivity check to be called
|
|
await asyncio.wait_for(connectivity_check_event.wait(), timeout=1.0)
|
|
|
|
# Verify sleep was called with correct delay
|
|
mock_sleep.assert_called_once_with(5)
|
|
|
|
# Reset and test that other containers don't trigger check
|
|
connectivity_check_event.clear()
|
|
mock_sleep.reset_mock()
|
|
|
|
# Fire event for different container
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name="hassio_homeassistant",
|
|
state=ContainerState.RUNNING,
|
|
id="test_id",
|
|
time=1234567890,
|
|
),
|
|
)
|
|
|
|
# Wait a bit and verify connectivity check was NOT triggered
|
|
with pytest.raises(TimeoutError):
|
|
await asyncio.wait_for(connectivity_check_event.wait(), timeout=0.1)
|
|
|
|
# Verify sleep was not called for other containers
|
|
mock_sleep.assert_not_called()
|