Files
supervisor/tests/plugins/test_dns.py
Stefan Agner 9a0f530a2f Add Supervisor connectivity check after DNS restart (#6005)
* Add Supervisor connectivity check after DNS restart

When the DNS plug-in got restarted, check Supervisor connectivity
in case the DNS plug-in configuration change influenced Supervisor
connectivity. This is helpful when a DHCP server gets started after
Home Assistant is up. In that case the network provided DNS server
(local DNS server) becomes available after the DNS plug-in restart.

Without this change, the Supervisor connectivity will remain false
until the a Job triggers a connectivity check, for example the
periodic update check (which causes a updater and store reload) by
Core.

* Fix pytest and add coverage for new functionality
2025-07-10 11:08:10 +02:00

484 lines
16 KiB
Python

"""Test DNS plugin."""
import asyncio
import errno
from ipaddress import IPv4Address
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
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
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
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.addons.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"],
),
]
async def test_loop_detection_on_failure(coresys: CoreSys):
"""Test loop detection when coredns fails."""
assert len(coresys.resolution.issues) == 0
assert len(coresys.resolution.suggestions) == 0
with (
patch.object(type(coresys.plugins.dns.instance), "attach"),
patch.object(
type(coresys.plugins.dns.instance),
"is_running",
return_value=True,
),
):
await coresys.plugins.dns.load()
with (
patch.object(type(coresys.plugins.dns), "rebuild") as rebuild,
patch.object(
type(coresys.plugins.dns.instance),
"current_state",
side_effect=[
ContainerState.FAILED,
ContainerState.FAILED,
],
),
patch.object(type(coresys.plugins.dns.instance), "logs") as logs,
):
logs.return_value = b""
coresys.bus.fire_event(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
DockerContainerStateEvent(
name="hassio_dns",
state=ContainerState.FAILED,
id="abc123",
time=1,
),
)
await asyncio.sleep(0)
assert len(coresys.resolution.issues) == 0
assert len(coresys.resolution.suggestions) == 0
rebuild.assert_called_once()
rebuild.reset_mock()
logs.return_value = b"plugin/loop: Loop"
coresys.bus.fire_event(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
DockerContainerStateEvent(
name="hassio_dns",
state=ContainerState.FAILED,
id="abc123",
time=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 called
connectivity_check_event = asyncio.Event()
# Mock connectivity check to set the event when called
async def mock_check_connectivity():
connectivity_check_event.set()
with (
patch.object(
coresys.supervisor,
"check_connectivity",
side_effect=mock_check_connectivity,
),
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
try:
await asyncio.wait_for(connectivity_check_event.wait(), timeout=0.1)
assert False, (
"Connectivity check should not have been called for other containers"
)
except TimeoutError:
# This is expected - connectivity check should not be called
pass
# Verify sleep was not called for other containers
mock_sleep.assert_not_called()