Delay initial version fetch until there is connectivity (#5603)

* Delay inital version fetch until there is connectivity

* Add test

* Only mock get not whole websession object

* drive delayed fetch off of supervisor connectivity not host

* Fix test to not rely on sleep guessing to track tasks

* Use fixture to remove job throttle temporarily
This commit is contained in:
Mike Degatano 2025-02-11 07:22:33 -05:00 committed by GitHub
parent fa6949f4e4
commit 52cc17fa3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 121 additions and 36 deletions

View File

@ -484,6 +484,7 @@ class BusEvent(StrEnum):
DOCKER_CONTAINER_STATE_CHANGE = "docker_container_state_change" DOCKER_CONTAINER_STATE_CHANGE = "docker_container_state_change"
HARDWARE_NEW_DEVICE = "hardware_new_device" HARDWARE_NEW_DEVICE = "hardware_new_device"
HARDWARE_REMOVE_DEVICE = "hardware_remove_device" HARDWARE_REMOVE_DEVICE = "hardware_remove_device"
SUPERVISOR_CONNECTIVITY_CHANGE = "supervisor_connectivity_change"
SUPERVISOR_JOB_END = "supervisor_job_end" SUPERVISOR_JOB_END = "supervisor_job_end"
SUPERVISOR_JOB_START = "supervisor_job_start" SUPERVISOR_JOB_START = "supervisor_job_start"
SUPERVISOR_STATE_CHANGE = "supervisor_state_change" SUPERVISOR_STATE_CHANGE = "supervisor_state_change"

View File

@ -67,6 +67,8 @@ class NetworkManager(CoreSysAttributes):
self.sys_homeassistant.websocket.supervisor_update_event( self.sys_homeassistant.websocket.supervisor_update_event(
"network", {ATTR_HOST_INTERNET: state} "network", {ATTR_HOST_INTERNET: state}
) )
if state and not self.sys_supervisor.connectivity:
self.sys_create_task(self.sys_supervisor.check_connectivity())
@property @property
def interfaces(self) -> list[Interface]: def interfaces(self) -> list[Interface]:
@ -148,7 +150,7 @@ class NetworkManager(CoreSysAttributes):
return return
connectivity_check: bool | None = changed.get(DBUS_ATTR_CONNECTION_ENABLED) connectivity_check: bool | None = changed.get(DBUS_ATTR_CONNECTION_ENABLED)
connectivity: bool | None = changed.get(DBUS_ATTR_CONNECTIVITY) connectivity: int | None = changed.get(DBUS_ATTR_CONNECTIVITY)
# This potentially updated the DNS configuration. Make sure the DNS plug-in # This potentially updated the DNS configuration. Make sure the DNS plug-in
# picks up the latest settings. # picks up the latest settings.

View File

@ -213,9 +213,9 @@ class JobManager(FileConfiguration, CoreSysAttributes):
if attribute.name == "done": if attribute.name == "done":
if value is False: if value is False:
self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_START, job.uuid) self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_START, job)
if value is True: if value is True:
self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_END, job.uuid) self.sys_bus.fire_event(BusEvent.SUPERVISOR_JOB_END, job)
def new_job( def new_job(
self, self,

View File

@ -13,7 +13,12 @@ import aiohttp
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
from awesomeversion import AwesomeVersion, AwesomeVersionException from awesomeversion import AwesomeVersion, AwesomeVersionException
from .const import ATTR_SUPERVISOR_INTERNET, SUPERVISOR_VERSION, URL_HASSIO_APPARMOR from .const import (
ATTR_SUPERVISOR_INTERNET,
SUPERVISOR_VERSION,
URL_HASSIO_APPARMOR,
BusEvent,
)
from .coresys import CoreSys, CoreSysAttributes from .coresys import CoreSys, CoreSysAttributes
from .docker.stats import DockerStats from .docker.stats import DockerStats
from .docker.supervisor import DockerSupervisor from .docker.supervisor import DockerSupervisor
@ -74,6 +79,7 @@ class Supervisor(CoreSysAttributes):
if self._connectivity == state: if self._connectivity == state:
return return
self._connectivity = state self._connectivity = state
self.sys_bus.fire_event(BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, state)
self.sys_homeassistant.websocket.supervisor_update_event( self.sys_homeassistant.websocket.supervisor_update_event(
"network", {ATTR_SUPERVISOR_INTERNET: state} "network", {ATTR_SUPERVISOR_INTERNET: state}
) )

View File

@ -8,6 +8,7 @@ import logging
import aiohttp import aiohttp
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from .bus import EventListener
from .const import ( from .const import (
ATTR_AUDIO, ATTR_AUDIO,
ATTR_AUTO_UPDATE, ATTR_AUTO_UPDATE,
@ -23,6 +24,7 @@ from .const import (
ATTR_SUPERVISOR, ATTR_SUPERVISOR,
FILE_HASSIO_UPDATER, FILE_HASSIO_UPDATER,
URL_HASSIO_VERSION, URL_HASSIO_VERSION,
BusEvent,
UpdateChannel, UpdateChannel,
) )
from .coresys import CoreSysAttributes from .coresys import CoreSysAttributes
@ -47,11 +49,18 @@ class Updater(FileConfiguration, CoreSysAttributes):
"""Initialize updater.""" """Initialize updater."""
super().__init__(FILE_HASSIO_UPDATER, SCHEMA_UPDATER_CONFIG) super().__init__(FILE_HASSIO_UPDATER, SCHEMA_UPDATER_CONFIG)
self.coresys = coresys self.coresys = coresys
self._connectivity_listener: EventListener | None = None
async def load(self) -> None: async def load(self) -> None:
"""Update internal data.""" """Update internal data."""
with suppress(UpdaterError): # If there's no connectivity, delay initial version fetch
await self.fetch_data() if not self.sys_supervisor.connectivity:
self._connectivity_listener = self.sys_bus.register_event(
BusEvent.SUPERVISOR_CONNECTIVITY_CHANGE, self._check_connectivity
)
return
await self.reload()
async def reload(self) -> None: async def reload(self) -> None:
"""Update internal data.""" """Update internal data."""
@ -180,6 +189,11 @@ class Updater(FileConfiguration, CoreSysAttributes):
"""Set Supervisor auto updates enabled.""" """Set Supervisor auto updates enabled."""
self._data[ATTR_AUTO_UPDATE] = value self._data[ATTR_AUTO_UPDATE] = value
async def _check_connectivity(self, connectivity: bool):
"""Fetch data once connectivity is true."""
if connectivity:
await self.reload()
@Job( @Job(
name="updater_fetch_data", name="updater_fetch_data",
conditions=[JobCondition.INTERNET_SYSTEM], conditions=[JobCondition.INTERNET_SYSTEM],
@ -214,6 +228,11 @@ class Updater(FileConfiguration, CoreSysAttributes):
_LOGGER.warning, _LOGGER.warning,
) from err ) from err
# Fetch was successful. If there's a connectivity listener, time to remove it
if self._connectivity_listener:
self.sys_bus.remove_listener(self._connectivity_listener)
self._connectivity_listener = None
# Validate # Validate
try: try:
await self.sys_security.verify_own_content(calc_checksum(data)) await self.sys_security.verify_own_content(calc_checksum(data))

View File

@ -2,8 +2,7 @@
import asyncio import asyncio
from collections.abc import AsyncGenerator, Generator from collections.abc import AsyncGenerator, Generator
from functools import partial from datetime import datetime
from inspect import unwrap
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
@ -381,11 +380,6 @@ async def coresys(
ha_version=AwesomeVersion("2021.2.4") ha_version=AwesomeVersion("2021.2.4")
) )
# Remove rate limiting decorator from fetch_data
coresys_obj.updater.fetch_data = partial(
unwrap(coresys_obj.updater.fetch_data), coresys_obj.updater
)
# Don't remove files/folders related to addons and stores # Don't remove files/folders related to addons and stores
with patch("supervisor.store.git.GitRepo._remove"): with patch("supervisor.store.git.GitRepo._remove"):
yield coresys_obj yield coresys_obj
@ -765,3 +759,10 @@ def mock_is_mount() -> MagicMock:
"""Mock is_mount in mounts.""" """Mock is_mount in mounts."""
with patch("supervisor.mounts.mount.Path.is_mount", return_value=True) as is_mount: with patch("supervisor.mounts.mount.Path.is_mount", return_value=True) as is_mount:
yield is_mount yield is_mount
@pytest.fixture
def no_job_throttle():
"""Remove job throttle for tests."""
with patch("supervisor.jobs.decorator.Job.last_call", return_value=datetime.min):
yield

View File

@ -22,6 +22,7 @@ class NetworkManager(DBusServiceMock):
interface = "org.freedesktop.NetworkManager" interface = "org.freedesktop.NetworkManager"
object_path = "/org/freedesktop/NetworkManager" object_path = "/org/freedesktop/NetworkManager"
version = "1.22.10" version = "1.22.10"
connectivity_check_enabled = True
connectivity = 4 connectivity = 4
devices = [ devices = [
"/org/freedesktop/NetworkManager/Devices/1", "/org/freedesktop/NetworkManager/Devices/1",
@ -155,7 +156,7 @@ class NetworkManager(DBusServiceMock):
@dbus_property() @dbus_property()
def ConnectivityCheckEnabled(self) -> "b": def ConnectivityCheckEnabled(self) -> "b":
"""Get ConnectivityCheckEnabled.""" """Get ConnectivityCheckEnabled."""
return True return self.connectivity_check_enabled
@ConnectivityCheckEnabled.setter @ConnectivityCheckEnabled.setter
def ConnectivityCheckEnabled(self, value: "b"): def ConnectivityCheckEnabled(self, value: "b"):

View File

@ -1144,13 +1144,13 @@ async def test_job_scheduled_delay(coresys: CoreSys):
started = False started = False
ended = False ended = False
async def start_listener(job_id: str): async def start_listener(evt_job: SupervisorJob):
nonlocal started nonlocal started
started = started or job_id == job.uuid started = started or evt_job.uuid == job.uuid
async def end_listener(job_id: str): async def end_listener(evt_job: SupervisorJob):
nonlocal ended nonlocal ended
ended = ended or job_id == job.uuid ended = ended or evt_job.uuid == job.uuid
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener) coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener)
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener) coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener)
@ -1196,13 +1196,13 @@ async def test_job_scheduled_at(coresys: CoreSys):
started = False started = False
ended = False ended = False
async def start_listener(job_id: str): async def start_listener(evt_job: SupervisorJob):
nonlocal started nonlocal started
started = started or job_id == job.uuid started = started or evt_job.uuid == job.uuid
async def end_listener(job_id: str): async def end_listener(evt_job: SupervisorJob):
nonlocal ended nonlocal ended
ended = ended or job_id == job.uuid ended = ended or evt_job.uuid == job.uuid
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener) coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, start_listener)
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener) coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, end_listener)

View File

@ -171,6 +171,7 @@ async def test_watchdog_homeassistant_api_reanimation_limit(
rebuild.assert_not_called() rebuild.assert_not_called()
@pytest.mark.usefixtures("no_job_throttle")
async def test_reload_updater_triggers_supervisor_update( async def test_reload_updater_triggers_supervisor_update(
tasks: Tasks, coresys: CoreSys tasks: Tasks, coresys: CoreSys
): ):

View File

@ -16,7 +16,7 @@ from tests.dbus_service_mocks.rauc import Rauc as RaucService
# pylint: disable=protected-access # pylint: disable=protected-access
@pytest.mark.asyncio @pytest.mark.usefixtures("no_job_throttle")
async def test_ota_url_generic_x86_64_rename(coresys: CoreSys) -> None: async def test_ota_url_generic_x86_64_rename(coresys: CoreSys) -> None:
"""Test download URL generated.""" """Test download URL generated."""
coresys.os._board = "intel-nuc" coresys.os._board = "intel-nuc"

View File

@ -3,6 +3,7 @@
from unittest.mock import PropertyMock, patch from unittest.mock import PropertyMock, patch
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
import pytest
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.docker.interface import DockerInterface from supervisor.docker.interface import DockerInterface
@ -35,6 +36,7 @@ async def test_repair(coresys: CoreSys):
assert install.call_count == len(coresys.plugins.all_plugins) assert install.call_count == len(coresys.plugins.all_plugins)
@pytest.mark.usefixtures("no_job_throttle")
async def test_load(coresys: CoreSys): async def test_load(coresys: CoreSys):
"""Test plugin manager load.""" """Test plugin manager load."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000 coresys.hardware.disk.get_disk_free_space = lambda x: 5000

View File

@ -36,29 +36,23 @@ async def fixture_webession(coresys: CoreSys) -> AsyncMock:
yield mock_websession yield mock_websession
@pytest.fixture(name="supervisor_unthrottled")
async def fixture_supervisor_unthrottled(coresys: CoreSys) -> Supervisor:
"""Get supervisor object with connectivity check throttle removed."""
with patch("supervisor.jobs.decorator.Job.last_call", return_value=datetime.min):
yield coresys.supervisor
@pytest.mark.parametrize( @pytest.mark.parametrize(
"side_effect,connectivity", [(ClientError(), False), (None, True)] "side_effect,connectivity", [(ClientError(), False), (None, True)]
) )
@pytest.mark.usefixtures("no_job_throttle")
async def test_connectivity_check( async def test_connectivity_check(
supervisor_unthrottled: Supervisor, coresys: CoreSys,
websession: AsyncMock, websession: AsyncMock,
side_effect: Exception | None, side_effect: Exception | None,
connectivity: bool, connectivity: bool,
): ):
"""Test connectivity check.""" """Test connectivity check."""
assert supervisor_unthrottled.connectivity is True assert coresys.supervisor.connectivity is True
websession.head.side_effect = side_effect websession.head.side_effect = side_effect
await supervisor_unthrottled.check_connectivity() await coresys.supervisor.check_connectivity()
assert supervisor_unthrottled.connectivity is connectivity assert coresys.supervisor.connectivity is connectivity
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -1,16 +1,25 @@
"""Test updater files.""" """Test updater files."""
from unittest.mock import patch import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
import pytest import pytest
from supervisor.const import BusEvent
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.dbus.const import ConnectivityState
from supervisor.jobs import SupervisorJob
from tests.common import load_binary_fixture
from tests.dbus_service_mocks.network_manager import (
NetworkManager as NetworkManagerService,
)
URL_TEST = "https://version.home-assistant.io/stable.json" URL_TEST = "https://version.home-assistant.io/stable.json"
@pytest.mark.asyncio @pytest.mark.usefixtures("no_job_throttle")
async def test_fetch_versions(coresys: CoreSys) -> None: async def test_fetch_versions(coresys: CoreSys) -> None:
"""Test download and sync version.""" """Test download and sync version."""
@ -53,6 +62,7 @@ async def test_fetch_versions(coresys: CoreSys) -> None:
) )
@pytest.mark.usefixtures("no_job_throttle")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"version, expected", "version, expected",
[ [
@ -71,3 +81,51 @@ async def test_os_update_path(coresys: CoreSys, version: str, expected: str):
await coresys.updater.fetch_data() await coresys.updater.fetch_data()
assert coresys.updater.version_hassos == AwesomeVersion(expected) assert coresys.updater.version_hassos == AwesomeVersion(expected)
@pytest.mark.usefixtures("no_job_throttle")
async def test_delayed_fetch_for_connectivity(
coresys: CoreSys, network_manager_service: NetworkManagerService
):
"""Test initial version fetch waits for connectivity on load."""
coresys.websession.get = MagicMock()
coresys.websession.get.return_value.__aenter__.return_value.status = 200
coresys.websession.get.return_value.__aenter__.return_value.read.return_value = (
load_binary_fixture("version_stable.json")
)
coresys.websession.head = AsyncMock()
coresys.security.verify_own_content = AsyncMock()
# Network connectivity change causes a series of async tasks to eventually do a version fetch
# Rather then use some kind of sleep loop, set up listener for start of fetch data job
event = asyncio.Event()
async def find_fetch_data_job_start(job: SupervisorJob):
if job.name == "updater_fetch_data":
event.set()
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_START, find_fetch_data_job_start)
# Start with no connectivity and confirm there is no version fetch on load
coresys.supervisor.connectivity = False
network_manager_service.connectivity = ConnectivityState.CONNECTIVITY_NONE.value
await coresys.host.network.load()
await coresys.host.network.check_connectivity()
await coresys.updater.load()
coresys.websession.get.assert_not_called()
# Now signal host has connectivity and wait for fetch data to complete to assert
network_manager_service.emit_properties_changed(
{"Connectivity": ConnectivityState.CONNECTIVITY_FULL}
)
await network_manager_service.ping()
async with asyncio.timeout(5):
await event.wait()
await asyncio.sleep(0)
coresys.websession.get.assert_called_once()
assert (
coresys.websession.get.call_args[0][0]
== "https://version.home-assistant.io/stable.json"
)