Files
supervisor/tests/plugins/test_dns.py
Stefan Agner 953f7d01d7 Improve DNS plug-in restart (#5999)
* Improve DNS plug-in restart

Instead of simply go by PrimaryConnectioon change, use the DnsManager
Configuration property. This property is ultimately used to write the
DNS plug-in configuration, so it is really the relevant information
we pass on to the plug-in.

* Check for changes and restart DNS plugin

* Check for changes in plug-in DNS

Cache last local (NetworkManager) provided DNS servers. Check against
this DNS server list when deciding when to restart the DNS plug-in.

* Check connectivity unthrottled in certain situations

* Fix pytest

* Fix pytest

* Improve test coverage for DNS plugins restart functionality

* Apply suggestions from code review

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Debounce local DNS changes and event based connectivity checks

* Remove connection check logic

* Remove unthrottled connectivity check

* Fix delayed call

* Store restart task and cancel in case a restart is running

* Improve DNS configuration change tests

* Remove stale code

* Improve DNS plug-in tests, less mocking

* Cover multiple private functions at once

Improve tests around notify_locals_changed() to cover multiple
functions at once.

---------

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>
2025-07-09 11:35:03 +02:00

409 lines
14 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