From c8f184f24ccd43dbf42aa7504593036997448dda Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Mon, 15 Aug 2022 12:13:22 -0400 Subject: [PATCH] Add auto update option (#3769) * Add update freeze option * Freeze to auto update and plugin condition * Add tests * Add supervisor_version evaluation * OS updates require supervisor up to date * Run version check during startup --- supervisor/addons/__init__.py | 13 ++--- supervisor/addons/const.py | 10 ++++ supervisor/api/supervisor.py | 6 +++ supervisor/backups/backup.py | 11 +++- supervisor/backups/manager.py | 16 ++++++ supervisor/backups/validate.py | 5 ++ supervisor/const.py | 2 + supervisor/core.py | 4 +- supervisor/homeassistant/core.py | 2 + supervisor/jobs/const.py | 14 ++--- supervisor/jobs/decorator.py | 15 ++++++ supervisor/misc/tasks.py | 26 +++++---- supervisor/os/manager.py | 1 + supervisor/plugins/audio.py | 5 ++ supervisor/plugins/cli.py | 5 ++ supervisor/plugins/const.py | 8 +++ supervisor/plugins/dns.py | 5 ++ supervisor/plugins/multicast.py | 5 ++ supervisor/plugins/observer.py | 5 ++ supervisor/resolution/const.py | 1 + .../evaluations/supervisor_version.py | 34 ++++++++++++ supervisor/supervisor.py | 2 +- supervisor/updater.py | 11 ++++ supervisor/validate.py | 2 + tests/addons/test_addon.py | 25 +++++++++ tests/api/test_supervisor.py | 13 +++++ tests/backups/conftest.py | 2 + tests/backups/test_manager.py | 52 +++++++++++++++++- tests/homeassistant/test_core.py | 22 ++++++++ tests/jobs/test_job_decorator.py | 50 +++++++++++++++++ tests/os/test_manager.py | 17 ++++++ tests/plugins/test_plugin_base.py | 31 ++++++++++- .../test_evaluate_supervisor_version.py | 53 +++++++++++++++++++ 33 files changed, 436 insertions(+), 37 deletions(-) create mode 100644 supervisor/resolution/evaluations/supervisor_version.py create mode 100644 tests/homeassistant/test_core.py create mode 100644 tests/resolution/evaluation/test_evaluate_supervisor_version.py diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index b7663af87..ee3a8e7ac 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -24,6 +24,7 @@ from ..resolution.const import ContextType, IssueType, SuggestionType from ..store.addon import AddonStore from ..utils import check_exception_chain from .addon import Addon +from .const import ADDON_UPDATE_CONDITIONS from .data import AddonsData _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -144,11 +145,7 @@ class AddonManager(CoreSysAttributes): self.sys_capture_exception(err) @Job( - conditions=[ - JobCondition.FREE_SPACE, - JobCondition.INTERNET_HOST, - JobCondition.HEALTHY, - ], + conditions=ADDON_UPDATE_CONDITIONS, on_condition=AddonsJobError, ) async def install(self, slug: str) -> None: @@ -246,11 +243,7 @@ class AddonManager(CoreSysAttributes): _LOGGER.info("Add-on '%s' successfully removed", slug) @Job( - conditions=[ - JobCondition.FREE_SPACE, - JobCondition.INTERNET_HOST, - JobCondition.HEALTHY, - ], + conditions=ADDON_UPDATE_CONDITIONS, on_condition=AddonsJobError, ) async def update(self, slug: str, backup: Optional[bool] = False) -> None: diff --git a/supervisor/addons/const.py b/supervisor/addons/const.py index 9819479d7..7db9d38ee 100644 --- a/supervisor/addons/const.py +++ b/supervisor/addons/const.py @@ -2,6 +2,8 @@ from datetime import timedelta from enum import Enum +from ..jobs.const import JobCondition + class AddonBackupMode(str, Enum): """Backup mode of an Add-on.""" @@ -16,3 +18,11 @@ WATCHDOG_RETRY_SECONDS = 10 WATCHDOG_MAX_ATTEMPTS = 5 WATCHDOG_THROTTLE_PERIOD = timedelta(minutes=30) WATCHDOG_THROTTLE_MAX_CALLS = 10 + +ADDON_UPDATE_CONDITIONS = [ + JobCondition.FREE_SPACE, + JobCondition.HEALTHY, + JobCondition.INTERNET_HOST, + JobCondition.PLUGINS_UPDATED, + JobCondition.SUPERVISOR_UPDATED, +] diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index 46f3fee03..fae9b59a4 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -10,6 +10,7 @@ from ..const import ( ATTR_ADDONS, ATTR_ADDONS_REPOSITORIES, ATTR_ARCH, + ATTR_AUTO_UPDATE, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_CHANNEL, @@ -64,6 +65,7 @@ SCHEMA_OPTIONS = vol.Schema( vol.Optional(ATTR_DIAGNOSTICS): vol.Boolean(), vol.Optional(ATTR_CONTENT_TRUST): vol.Boolean(), vol.Optional(ATTR_FORCE_SECURITY): vol.Boolean(), + vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(), } ) @@ -96,6 +98,7 @@ class APISupervisor(CoreSysAttributes): ATTR_DEBUG: self.sys_config.debug, ATTR_DEBUG_BLOCK: self.sys_config.debug_block, ATTR_DIAGNOSTICS: self.sys_config.diagnostics, + ATTR_AUTO_UPDATE: self.sys_updater.auto_update, # Depricated ATTR_ADDONS: [ { @@ -143,6 +146,9 @@ class APISupervisor(CoreSysAttributes): if ATTR_LOGGING in body: self.sys_config.logging = body[ATTR_LOGGING] + if ATTR_AUTO_UPDATE in body: + self.sys_updater.auto_update = body[ATTR_AUTO_UPDATE] + # Save changes before processing addons in case of errors self.sys_updater.save_data() self.sys_config.save_data() diff --git a/supervisor/backups/backup.py b/supervisor/backups/backup.py index 179e8f7de..c6ac3a736 100644 --- a/supervisor/backups/backup.py +++ b/supervisor/backups/backup.py @@ -7,7 +7,7 @@ import tarfile from tempfile import TemporaryDirectory from typing import Any, Awaitable, Optional -from awesomeversion import AwesomeVersionCompareException +from awesomeversion import AwesomeVersion, AwesomeVersionCompareException from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -31,6 +31,7 @@ from ..const import ( ATTR_REPOSITORIES, ATTR_SIZE, ATTR_SLUG, + ATTR_SUPERVISOR_VERSION, ATTR_TYPE, ATTR_USERNAME, ATTR_VERSION, @@ -121,7 +122,7 @@ class Backup(CoreSysAttributes): @property def homeassistant_version(self): - """Return backupbackup Home Assistant version.""" + """Return backup Home Assistant version.""" if self.homeassistant is None: return None return self._data[ATTR_HOMEASSISTANT][ATTR_VERSION] @@ -131,6 +132,11 @@ class Backup(CoreSysAttributes): """Return backup Home Assistant data.""" return self._data[ATTR_HOMEASSISTANT] + @property + def supervisor_version(self) -> AwesomeVersion: + """Return backup Supervisor version.""" + return self._data[ATTR_SUPERVISOR_VERSION] + @property def docker(self): """Return backup Docker config data.""" @@ -166,6 +172,7 @@ class Backup(CoreSysAttributes): self._data[ATTR_NAME] = name self._data[ATTR_DATE] = date self._data[ATTR_TYPE] = sys_type + self._data[ATTR_SUPERVISOR_VERSION] = self.sys_supervisor.version # Add defaults self._data = SCHEMA_BACKUP(self._data) diff --git a/supervisor/backups/manager.py b/supervisor/backups/manager.py index d01ea946b..dd856e0e7 100644 --- a/supervisor/backups/manager.py +++ b/supervisor/backups/manager.py @@ -316,6 +316,14 @@ class BackupManager(CoreSysAttributes): _LOGGER.error("Invalid password for backup %s", backup.slug) return False + if backup.supervisor_version > self.sys_supervisor.version: + _LOGGER.error( + "Backup was made on supervisor version %s, can't restore on %s. Must update supervisor first.", + backup.supervisor_version, + self.sys_supervisor.version, + ) + return False + _LOGGER.info("Full-Restore %s start", backup.slug) async with self.lock: self.sys_core.state = CoreState.FREEZE @@ -370,6 +378,14 @@ class BackupManager(CoreSysAttributes): _LOGGER.error("No Home Assistant Core data inside the backup") return False + if backup.supervisor_version > self.sys_supervisor.version: + _LOGGER.error( + "Backup was made on supervisor version %s, can't restore on %s. Must update supervisor first.", + backup.supervisor_version, + self.sys_supervisor.version, + ) + return False + _LOGGER.info("Partial-Restore %s start", backup.slug) async with self.lock: self.sys_core.state = CoreState.FREEZE diff --git a/supervisor/backups/validate.py b/supervisor/backups/validate.py index 4d83d7e2f..0fdfbbd46 100644 --- a/supervisor/backups/validate.py +++ b/supervisor/backups/validate.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from awesomeversion import AwesomeVersion import voluptuous as vol from ..backups.const import BackupType @@ -19,6 +20,7 @@ from ..const import ( ATTR_REPOSITORIES, ATTR_SIZE, ATTR_SLUG, + ATTR_SUPERVISOR_VERSION, ATTR_TYPE, ATTR_VERSION, CRYPTO_AES128, @@ -79,6 +81,9 @@ def v1_protected(protected: bool | str) -> bool: SCHEMA_BACKUP = vol.Schema( { vol.Optional(ATTR_VERSION, default=1): vol.All(vol.Coerce(int), vol.In((1, 2))), + vol.Optional( + ATTR_SUPERVISOR_VERSION, default=AwesomeVersion("2022.08.3") + ): version_tag, vol.Required(ATTR_SLUG): str, vol.Required(ATTR_TYPE): vol.Coerce(BackupType), vol.Required(ATTR_NAME): str, diff --git a/supervisor/const.py b/supervisor/const.py index f504a702b..c8537f69b 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -237,6 +237,7 @@ ATTR_PANEL_TITLE = "panel_title" ATTR_PANELS = "panels" ATTR_PARENT = "parent" ATTR_PASSWORD = "password" +ATTR_PLUGINS = "plugins" ATTR_PORT = "port" ATTR_PORTS = "ports" ATTR_PORTS_DESCRIPTION = "ports_description" @@ -278,6 +279,7 @@ ATTR_STORAGE = "storage" ATTR_SUGGESTIONS = "suggestions" ATTR_SUPERVISOR = "supervisor" ATTR_SUPERVISOR_INTERNET = "supervisor_internet" +ATTR_SUPERVISOR_VERSION = "supervisor_version" ATTR_SUPPORTED = "supported" ATTR_SUPPORTED_ARCH = "supported_arch" ATTR_SYSTEM = "system" diff --git a/supervisor/core.py b/supervisor/core.py index 6bcf9194a..206f197b6 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -182,8 +182,8 @@ class Core(CoreSysAttributes): # Mark booted partition as healthy await self.sys_os.mark_healthy() - # On release channel, try update itself - if self.sys_supervisor.need_update: + # On release channel, try update itself if auto update enabled + if self.sys_supervisor.need_update and self.sys_updater.auto_update: try: if not self.healthy: _LOGGER.warning("Ignoring Supervisor updates!") diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index 94b074c4e..7d6792dab 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -182,6 +182,8 @@ class HomeAssistantCore(CoreSysAttributes): JobCondition.FREE_SPACE, JobCondition.HEALTHY, JobCondition.INTERNET_HOST, + JobCondition.PLUGINS_UPDATED, + JobCondition.SUPERVISOR_UPDATED, ], on_condition=HomeAssistantJobError, ) diff --git a/supervisor/jobs/const.py b/supervisor/jobs/const.py index f8cde0c97..580e37b27 100644 --- a/supervisor/jobs/const.py +++ b/supervisor/jobs/const.py @@ -12,22 +12,24 @@ ATTR_IGNORE_CONDITIONS = "ignore_conditions" class JobCondition(str, Enum): """Job condition enum.""" + AUTO_UPDATE = "auto_update" FREE_SPACE = "free_space" - HEALTHY = "healthy" - INTERNET_SYSTEM = "internet_system" - INTERNET_HOST = "internet_host" - RUNNING = "running" HAOS = "haos" - OS_AGENT = "os_agent" + HEALTHY = "healthy" HOST_NETWORK = "host_network" + INTERNET_HOST = "internet_host" + INTERNET_SYSTEM = "internet_system" + OS_AGENT = "os_agent" + PLUGINS_UPDATED = "plugins_updated" + RUNNING = "running" SUPERVISOR_UPDATED = "supervisor_updated" class JobExecutionLimit(str, Enum): """Job Execution limits.""" - SINGLE_WAIT = "single_wait" ONCE = "once" + SINGLE_WAIT = "single_wait" THROTTLE = "throttle" THROTTLE_WAIT = "throttle_wait" THROTTLE_RATE_LIMIT = "throttle_rate_limit" diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index 9e020fda9..8069c9e3e 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -222,6 +222,14 @@ class Job(CoreSysAttributes): f"'{self._method.__qualname__}' blocked from execution, host Network Manager not available" ) + if ( + JobCondition.AUTO_UPDATE in self.conditions + and not self.sys_updater.auto_update + ): + raise JobConditionException( + f"'{self._method.__qualname__}' blocked from execution, supervisor auto updates disabled" + ) + if ( JobCondition.SUPERVISOR_UPDATED in self.conditions and self.sys_supervisor.need_update @@ -230,6 +238,13 @@ class Job(CoreSysAttributes): f"'{self._method.__qualname__}' blocked from execution, supervisor needs to be updated first" ) + if JobCondition.PLUGINS_UPDATED in self.conditions and 0 < len( + [plugin for plugin in self.sys_plugins.all_plugins if plugin.need_update] + ): + raise JobConditionException( + f"'{self._method.__qualname__}' blocked from execution, plugin(s) {', '.join([plugin.slug for plugin in self.sys_plugins.all_plugins if plugin.need_update])} need to be updated first" + ) + async def _acquire_exection_limit(self) -> None: """Process exection limits.""" if self.limit not in ( diff --git a/supervisor/misc/tasks.py b/supervisor/misc/tasks.py index 1a1ed0be2..9e840da74 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -1,11 +1,13 @@ """A collection of tasks.""" import logging +from ..addons.const import ADDON_UPDATE_CONDITIONS from ..const import AddonState from ..coresys import CoreSysAttributes from ..exceptions import AddonsError, HomeAssistantError, ObserverError from ..host.const import HostFeature from ..jobs.decorator import Job, JobCondition +from ..plugins.const import PLUGIN_UPDATE_CONDITIONS _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -34,6 +36,8 @@ RUN_REFRESH_ADDON = 15 RUN_CHECK_CONNECTIVITY = 30 +PLUGIN_AUTO_UPDATE_CONDITIONS = PLUGIN_UPDATE_CONDITIONS + [JobCondition.RUNNING] + class Tasks(CoreSysAttributes): """Handle Tasks inside Supervisor.""" @@ -82,15 +86,7 @@ class Tasks(CoreSysAttributes): _LOGGER.info("All core tasks are scheduled") - @Job( - conditions=[ - JobCondition.HEALTHY, - JobCondition.FREE_SPACE, - JobCondition.INTERNET_HOST, - JobCondition.RUNNING, - JobCondition.SUPERVISOR_UPDATED, - ] - ) + @Job(conditions=ADDON_UPDATE_CONDITIONS + [JobCondition.RUNNING]) async def _update_addons(self): """Check if an update is available for an Add-on and update it.""" for addon in self.sys_addons.all: @@ -116,7 +112,9 @@ class Tasks(CoreSysAttributes): @Job( conditions=[ + JobCondition.AUTO_UPDATE, JobCondition.FREE_SPACE, + JobCondition.HEALTHY, JobCondition.INTERNET_HOST, JobCondition.RUNNING, ] @@ -173,7 +171,7 @@ class Tasks(CoreSysAttributes): finally: self._cache[HASS_WATCHDOG_API] = 0 - @Job(conditions=[JobCondition.RUNNING, JobCondition.SUPERVISOR_UPDATED]) + @Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS) async def _update_cli(self): """Check and run update of cli.""" if not self.sys_plugins.cli.need_update: @@ -184,7 +182,7 @@ class Tasks(CoreSysAttributes): ) await self.sys_plugins.cli.update() - @Job(conditions=[JobCondition.RUNNING, JobCondition.SUPERVISOR_UPDATED]) + @Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS) async def _update_dns(self): """Check and run update of CoreDNS plugin.""" if not self.sys_plugins.dns.need_update: @@ -196,7 +194,7 @@ class Tasks(CoreSysAttributes): ) await self.sys_plugins.dns.update() - @Job(conditions=[JobCondition.RUNNING, JobCondition.SUPERVISOR_UPDATED]) + @Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS) async def _update_audio(self): """Check and run update of PulseAudio plugin.""" if not self.sys_plugins.audio.need_update: @@ -208,7 +206,7 @@ class Tasks(CoreSysAttributes): ) await self.sys_plugins.audio.update() - @Job(conditions=[JobCondition.RUNNING, JobCondition.SUPERVISOR_UPDATED]) + @Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS) async def _update_observer(self): """Check and run update of Observer plugin.""" if not self.sys_plugins.observer.need_update: @@ -220,7 +218,7 @@ class Tasks(CoreSysAttributes): ) await self.sys_plugins.observer.update() - @Job(conditions=[JobCondition.RUNNING, JobCondition.SUPERVISOR_UPDATED]) + @Job(conditions=PLUGIN_AUTO_UPDATE_CONDITIONS) async def _update_multicast(self): """Check and run update of multicast.""" if not self.sys_plugins.multicast.need_update: diff --git a/supervisor/os/manager.py b/supervisor/os/manager.py index 10f4c5926..04aea2395 100644 --- a/supervisor/os/manager.py +++ b/supervisor/os/manager.py @@ -173,6 +173,7 @@ class OSManager(CoreSysAttributes): JobCondition.HAOS, JobCondition.INTERNET_SYSTEM, JobCondition.RUNNING, + JobCondition.SUPERVISOR_UPDATED, ], limit=JobExecutionLimit.ONCE, on_condition=HassOSJobError, diff --git a/supervisor/plugins/audio.py b/supervisor/plugins/audio.py index 03de6cd47..e21402ead 100644 --- a/supervisor/plugins/audio.py +++ b/supervisor/plugins/audio.py @@ -30,6 +30,7 @@ from ..utils.json import write_json_file from .base import PluginBase from .const import ( FILE_HASSIO_AUDIO, + PLUGIN_UPDATE_CONDITIONS, WATCHDOG_THROTTLE_MAX_CALLS, WATCHDOG_THROTTLE_PERIOD, ) @@ -112,6 +113,10 @@ class PluginAudio(PluginBase): self.image = self.sys_updater.image_audio self.save_data() + @Job( + conditions=PLUGIN_UPDATE_CONDITIONS, + on_condition=AudioJobError, + ) async def update(self, version: Optional[str] = None) -> None: """Update Audio plugin.""" version = version or self.latest_version diff --git a/supervisor/plugins/cli.py b/supervisor/plugins/cli.py index a3dabcc41..2367dc103 100644 --- a/supervisor/plugins/cli.py +++ b/supervisor/plugins/cli.py @@ -21,6 +21,7 @@ from ..jobs.decorator import Job from .base import PluginBase from .const import ( FILE_HASSIO_CLI, + PLUGIN_UPDATE_CONDITIONS, WATCHDOG_THROTTLE_MAX_CALLS, WATCHDOG_THROTTLE_PERIOD, ) @@ -72,6 +73,10 @@ class PluginCli(PluginBase): self.image = self.sys_updater.image_cli self.save_data() + @Job( + conditions=PLUGIN_UPDATE_CONDITIONS, + on_condition=CliJobError, + ) async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update local HA cli.""" version = version or self.latest_version diff --git a/supervisor/plugins/const.py b/supervisor/plugins/const.py index 0f3086368..8b3bdaf57 100644 --- a/supervisor/plugins/const.py +++ b/supervisor/plugins/const.py @@ -3,6 +3,7 @@ from datetime import timedelta from pathlib import Path from ..const import SUPERVISOR_DATA +from ..jobs.const import JobCondition FILE_HASSIO_AUDIO = Path(SUPERVISOR_DATA, "audio.json") FILE_HASSIO_CLI = Path(SUPERVISOR_DATA, "cli.json") @@ -15,3 +16,10 @@ WATCHDOG_RETRY_SECONDS = 10 WATCHDOG_MAX_ATTEMPTS = 5 WATCHDOG_THROTTLE_PERIOD = timedelta(minutes=30) WATCHDOG_THROTTLE_MAX_CALLS = 10 + +PLUGIN_UPDATE_CONDITIONS = [ + JobCondition.FREE_SPACE, + JobCondition.HEALTHY, + JobCondition.INTERNET_HOST, + JobCondition.SUPERVISOR_UPDATED, +] diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index 076ef72f4..24a947674 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -37,6 +37,7 @@ from .base import PluginBase from .const import ( ATTR_FALLBACK, FILE_HASSIO_DNS, + PLUGIN_UPDATE_CONDITIONS, WATCHDOG_THROTTLE_MAX_CALLS, WATCHDOG_THROTTLE_PERIOD, ) @@ -179,6 +180,10 @@ class PluginDns(PluginBase): # Init Hosts self.write_hosts() + @Job( + conditions=PLUGIN_UPDATE_CONDITIONS, + on_condition=CoreDNSJobError, + ) async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update CoreDNS plugin.""" version = version or self.latest_version diff --git a/supervisor/plugins/multicast.py b/supervisor/plugins/multicast.py index 03bdc0a73..652e9dfc4 100644 --- a/supervisor/plugins/multicast.py +++ b/supervisor/plugins/multicast.py @@ -24,6 +24,7 @@ from ..jobs.decorator import Job from .base import PluginBase from .const import ( FILE_HASSIO_MULTICAST, + PLUGIN_UPDATE_CONDITIONS, WATCHDOG_THROTTLE_MAX_CALLS, WATCHDOG_THROTTLE_PERIOD, ) @@ -69,6 +70,10 @@ class PluginMulticast(PluginBase): self.image = self.sys_updater.image_multicast self.save_data() + @Job( + conditions=PLUGIN_UPDATE_CONDITIONS, + on_condition=MulticastJobError, + ) async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update Multicast plugin.""" version = version or self.latest_version diff --git a/supervisor/plugins/observer.py b/supervisor/plugins/observer.py index ff91c3ad8..faa5507b0 100644 --- a/supervisor/plugins/observer.py +++ b/supervisor/plugins/observer.py @@ -27,6 +27,7 @@ from ..jobs.decorator import Job from .base import PluginBase from .const import ( FILE_HASSIO_OBSERVER, + PLUGIN_UPDATE_CONDITIONS, WATCHDOG_THROTTLE_MAX_CALLS, WATCHDOG_THROTTLE_PERIOD, ) @@ -77,6 +78,10 @@ class PluginObserver(PluginBase): self.image = self.sys_updater.image_observer self.save_data() + @Job( + conditions=PLUGIN_UPDATE_CONDITIONS, + on_condition=ObserverJobError, + ) async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update local HA observer.""" version = version or self.latest_version diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 4b5247440..72b7b7dc3 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -46,6 +46,7 @@ class UnsupportedReason(str, Enum): PRIVILEGED = "privileged" SOFTWARE = "software" SOURCE_MODS = "source_mods" + SUPERVISOR_VERSION = "supervisor_version" SYSTEMD = "systemd" SYSTEMD_RESOLVED = "systemd_resolved" diff --git a/supervisor/resolution/evaluations/supervisor_version.py b/supervisor/resolution/evaluations/supervisor_version.py new file mode 100644 index 000000000..800adbc2d --- /dev/null +++ b/supervisor/resolution/evaluations/supervisor_version.py @@ -0,0 +1,34 @@ +"""Evaluation class for supervisor version.""" + +from ...const import CoreState +from ...coresys import CoreSys +from ..const import UnsupportedReason +from .base import EvaluateBase + + +def setup(coresys: CoreSys) -> EvaluateBase: + """Initialize evaluation-setup function.""" + return EvaluateSupervisorVersion(coresys) + + +class EvaluateSupervisorVersion(EvaluateBase): + """Evaluate supervisor version.""" + + @property + def reason(self) -> UnsupportedReason: + """Return a UnsupportedReason enum.""" + return UnsupportedReason.SUPERVISOR_VERSION + + @property + def on_failure(self) -> str: + """Return a string that is printed when self.evaluate is False.""" + return "Not using latest version of Supervisor and auto update is disabled." + + @property + def states(self) -> list[CoreState]: + """Return a list of valid states when this evaluation can run.""" + return [CoreState.RUNNING, CoreState.STARTUP] + + async def evaluate(self) -> None: + """Run evaluation.""" + return not self.sys_updater.auto_update and self.sys_supervisor.need_update diff --git a/supervisor/supervisor.py b/supervisor/supervisor.py index f11903597..0f082a27a 100644 --- a/supervisor/supervisor.py +++ b/supervisor/supervisor.py @@ -159,7 +159,7 @@ class Supervisor(CoreSysAttributes): ) from err async def update(self, version: Optional[AwesomeVersion] = None) -> None: - """Update Home Assistant version.""" + """Update Supervisor version.""" version = version or self.latest_version if version == self.sys_supervisor.version: diff --git a/supervisor/updater.py b/supervisor/updater.py index c9f9c1911..771adec8b 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -11,6 +11,7 @@ from awesomeversion import AwesomeVersion from .const import ( ATTR_AUDIO, + ATTR_AUTO_UPDATE, ATTR_CHANNEL, ATTR_CLI, ATTR_DNS, @@ -170,6 +171,16 @@ class Updater(FileConfiguration, CoreSysAttributes): """Set upstream mode.""" self._data[ATTR_CHANNEL] = value + @property + def auto_update(self) -> bool: + """Return if Supervisor auto updates enabled.""" + return self._data[ATTR_AUTO_UPDATE] + + @auto_update.setter + def auto_update(self, value: bool) -> None: + """Set Supervisor auto updates enabled.""" + self._data[ATTR_AUTO_UPDATE] = value + @Job( conditions=[JobCondition.INTERNET_SYSTEM], on_condition=UpdaterJobError, diff --git a/supervisor/validate.py b/supervisor/validate.py index f5e7ba3d6..75579ca09 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -9,6 +9,7 @@ import voluptuous as vol from .const import ( ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO, + ATTR_AUTO_UPDATE, ATTR_CHANNEL, ATTR_CLI, ATTR_CONTENT_TRUST, @@ -134,6 +135,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema( extra=vol.REMOVE_EXTRA, ), vol.Optional(ATTR_OTA): vol.Url(), + vol.Optional(ATTR_AUTO_UPDATE, default=True): bool, }, extra=vol.REMOVE_EXTRA, ) diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index bebd03f17..fa1cf2849 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -4,12 +4,14 @@ import asyncio from unittest.mock import MagicMock, PropertyMock, patch from docker.errors import DockerException +import pytest from supervisor.addons.addon import Addon from supervisor.const import AddonState, BusEvent from supervisor.coresys import CoreSys from supervisor.docker.const import ContainerState from supervisor.docker.monitor import DockerContainerStateEvent +from supervisor.exceptions import AddonsJobError from ..const import TEST_ADDON_SLUG @@ -267,3 +269,26 @@ async def test_listener_attached_on_install(coresys: CoreSys, repository): ) await asyncio.sleep(0) assert coresys.addons.get(TEST_ADDON_SLUG).state == AddonState.STARTED + + +async def test_install_update_fails_if_out_of_date( + coresys: CoreSys, install_addon_ssh: Addon +): + """Test install or update of addon fails when supervisor or plugin is out of date.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + with patch.object( + type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True) + ): + with pytest.raises(AddonsJobError): + await coresys.addons.install(TEST_ADDON_SLUG) + with pytest.raises(AddonsJobError): + await install_addon_ssh.update() + + with patch.object( + type(coresys.plugins.audio), "need_update", new=PropertyMock(return_value=True) + ): + with pytest.raises(AddonsJobError): + await coresys.addons.install(TEST_ADDON_SLUG) + with pytest.raises(AddonsJobError): + await install_addon_ssh.update() diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index 078a65308..217a14128 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -101,3 +101,16 @@ async def test_api_supervisor_options_repo_error_with_config_change( assert coresys.config.debug coresys.updater.save_data.assert_called_once() coresys.config.save_data.assert_called_once() + + +async def test_api_supervisor_options_auto_update( + api_client: TestClient, coresys: CoreSys +): + """Test disabling auto update via api.""" + assert coresys.updater.auto_update is True + + response = await api_client.post("/supervisor/options", json={"auto_update": False}) + + assert response.status == 200 + + assert coresys.updater.auto_update is False diff --git a/tests/backups/conftest.py b/tests/backups/conftest.py index 8e81a8313..a7f680c08 100644 --- a/tests/backups/conftest.py +++ b/tests/backups/conftest.py @@ -36,6 +36,7 @@ def partial_backup_mock(backup_mock): backup_instance.sys_type = BackupType.PARTIAL backup_instance.folders = [] backup_instance.addon_list = [TEST_ADDON_SLUG] + backup_instance.supervisor_version = "DEV" yield backup_mock @@ -46,4 +47,5 @@ def full_backup_mock(backup_mock): backup_instance.sys_type = BackupType.FULL backup_instance.folders = ALL_FOLDERS backup_instance.addon_list = [TEST_ADDON_SLUG] + backup_instance.supervisor_version = "DEV" yield backup_mock diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index 18a66cc74..b985a325d 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -1,6 +1,6 @@ """Test BackupManager class.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from supervisor.backups.const import BackupType from supervisor.backups.manager import BackupManager @@ -271,3 +271,53 @@ async def test_do_restore_partial_maximal(coresys: CoreSys, partial_backup_mock) backup_instance.restore_homeassistant.assert_called_once() assert coresys.core.state == CoreState.RUNNING + + +async def test_fail_invalid_full_backup(coresys: CoreSys, full_backup_mock: MagicMock): + """Test restore fails with invalid backup.""" + coresys.core.state = CoreState.RUNNING + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + manager = BackupManager(coresys) + + backup_instance = full_backup_mock.return_value + backup_instance.protected = True + backup_instance.set_password.return_value = False + + assert await manager.do_restore_full(backup_instance) is False + + backup_instance.protected = False + backup_instance.supervisor_version = "2022.08.4" + with patch.object( + type(coresys.supervisor), "version", new=PropertyMock(return_value="2022.08.3") + ): + assert await manager.do_restore_full(backup_instance) is False + + +async def test_fail_invalid_partial_backup( + coresys: CoreSys, partial_backup_mock: MagicMock +): + """Test restore fails with invalid backup.""" + coresys.core.state = CoreState.RUNNING + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + manager = BackupManager(coresys) + + backup_instance = partial_backup_mock.return_value + backup_instance.protected = True + backup_instance.set_password.return_value = False + + assert await manager.do_restore_partial(backup_instance) is False + + backup_instance.protected = False + backup_instance.homeassistant = None + + assert ( + await manager.do_restore_partial(backup_instance, homeassistant=True) is False + ) + + backup_instance.supervisor_version = "2022.08.4" + with patch.object( + type(coresys.supervisor), "version", new=PropertyMock(return_value="2022.08.3") + ): + assert await manager.do_restore_partial(backup_instance) is False diff --git a/tests/homeassistant/test_core.py b/tests/homeassistant/test_core.py new file mode 100644 index 000000000..f7f600957 --- /dev/null +++ b/tests/homeassistant/test_core.py @@ -0,0 +1,22 @@ +"""Test Home Assistant core.""" +from unittest.mock import PropertyMock, patch + +import pytest + +from supervisor.coresys import CoreSys +from supervisor.exceptions import HomeAssistantJobError + + +async def test_update_fails_if_out_of_date(coresys: CoreSys): + """Test update of Home Assistant fails when supervisor or plugin is out of date.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + with patch.object( + type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True) + ), pytest.raises(HomeAssistantJobError): + await coresys.homeassistant.core.update() + + with patch.object( + type(coresys.plugins.audio), "need_update", new=PropertyMock(return_value=True) + ), pytest.raises(HomeAssistantJobError): + await coresys.homeassistant.core.update() diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index 9c66ddacf..681a0aede 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -424,3 +424,53 @@ async def test_supervisor_updated(coresys: CoreSys): type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True) ): assert not await test.execute() + + +async def test_plugins_updated(coresys: CoreSys): + """Test the plugins updated decorator.""" + + class TestClass: + """Test class.""" + + def __init__(self, coresys: CoreSys): + """Initialize the test class.""" + self.coresys = coresys + + @Job(conditions=JobCondition.PLUGINS_UPDATED) + async def execute(self) -> bool: + """Execute the class method.""" + return True + + test = TestClass(coresys) + assert 0 == len( + [plugin.slug for plugin in coresys.plugins.all_plugins if plugin.need_update] + ) + assert await test.execute() + + with patch.object( + type(coresys.plugins.audio), "need_update", new=PropertyMock(return_value=True) + ): + assert not await test.execute() + + +async def test_auto_update(coresys: CoreSys): + """Test the auto update decorator.""" + + class TestClass: + """Test class.""" + + def __init__(self, coresys: CoreSys): + """Initialize the test class.""" + self.coresys = coresys + + @Job(conditions=JobCondition.AUTO_UPDATE) + async def execute(self) -> bool: + """Execute the class method.""" + return True + + test = TestClass(coresys) + assert coresys.updater.auto_update is True + assert await test.execute() + + coresys.updater.auto_update = False + assert not await test.execute() diff --git a/tests/os/test_manager.py b/tests/os/test_manager.py index a52b89ce1..1a1acff63 100644 --- a/tests/os/test_manager.py +++ b/tests/os/test_manager.py @@ -1,9 +1,13 @@ """Test Home Assistant OS functionality.""" +from unittest.mock import PropertyMock, patch + from awesomeversion import AwesomeVersion import pytest +from supervisor.const import CoreState from supervisor.coresys import CoreSys +from supervisor.exceptions import HassOSJobError # pylint: disable=protected-access @@ -57,3 +61,16 @@ def test_ota_url_os_name_rel_5_downgrade(coresys: CoreSys) -> None: url = coresys.os._get_download_url(AwesomeVersion(versionstr)) assert url == url_formatted + + +async def test_update_fails_if_out_of_date(coresys: CoreSys) -> None: + """Test update of OS fails if Supervisor is out of date.""" + coresys.core.state = CoreState.RUNNING + with patch.object( + type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True) + ), patch.object( + type(coresys.os), "available", new=PropertyMock(return_value=True) + ), pytest.raises( + HassOSJobError + ): + await coresys.os.update() diff --git a/tests/plugins/test_plugin_base.py b/tests/plugins/test_plugin_base.py index 689d3cc0e..802fc23a7 100644 --- a/tests/plugins/test_plugin_base.py +++ b/tests/plugins/test_plugin_base.py @@ -1,6 +1,6 @@ """Test base plugin functionality.""" import asyncio -from unittest.mock import patch +from unittest.mock import PropertyMock, patch from awesomeversion import AwesomeVersion import pytest @@ -11,12 +11,18 @@ from supervisor.docker.const import ContainerState from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.exceptions import ( AudioError, + AudioJobError, CliError, + CliJobError, CoreDNSError, + CoreDNSJobError, DockerError, MulticastError, + MulticastJobError, ObserverError, + ObserverJobError, PluginError, + PluginJobError, ) from supervisor.plugins.audio import PluginAudio from supervisor.plugins.base import PluginBase @@ -302,3 +308,26 @@ async def test_plugin_load_missing_container( ) install.assert_called_once() start.assert_called_once() + + +@pytest.mark.parametrize( + "plugin,error", + [ + (PluginAudio, AudioJobError), + (PluginCli, CliJobError), + (PluginDns, CoreDNSJobError), + (PluginMulticast, MulticastJobError), + (PluginObserver, ObserverJobError), + ], + indirect=["plugin"], +) +async def test_update_fails_if_out_of_date( + coresys: CoreSys, plugin: PluginBase, error: PluginJobError +): + """Test update of plugins fail when supervisor is out of date.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + + with patch.object( + type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True) + ), pytest.raises(error): + await plugin.update() diff --git a/tests/resolution/evaluation/test_evaluate_supervisor_version.py b/tests/resolution/evaluation/test_evaluate_supervisor_version.py new file mode 100644 index 000000000..454b95a03 --- /dev/null +++ b/tests/resolution/evaluation/test_evaluate_supervisor_version.py @@ -0,0 +1,53 @@ +"""Test evaluate supervisor version.""" + +from unittest.mock import PropertyMock, patch + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.resolution.evaluations.supervisor_version import ( + EvaluateSupervisorVersion, +) + + +async def test_evaluation(coresys: CoreSys): + """Test evaluation.""" + need_update_mock = PropertyMock() + with patch.object(type(coresys.supervisor), "need_update", new=need_update_mock): + supervisor_version = EvaluateSupervisorVersion(coresys) + coresys.core.state = CoreState.RUNNING + need_update_mock.return_value = False + + # Only unsupported if out of date and auto update is off + assert supervisor_version.reason not in coresys.resolution.unsupported + need_update_mock.return_value = True + await supervisor_version() + assert supervisor_version.reason not in coresys.resolution.unsupported + + coresys.updater.auto_update = False + await supervisor_version() + assert supervisor_version.reason in coresys.resolution.unsupported + + +async def test_did_run(coresys: CoreSys): + """Test that the evaluation ran as expected.""" + supervisor_version = EvaluateSupervisorVersion(coresys) + should_run = supervisor_version.states + should_not_run = [state for state in CoreState if state not in should_run] + assert len(should_run) != 0 + assert len(should_not_run) != 0 + + with patch( + "supervisor.resolution.evaluations.supervisor_version.EvaluateSupervisorVersion.evaluate", + return_value=None, + ) as evaluate: + for state in should_run: + coresys.core.state = state + await supervisor_version() + evaluate.assert_called_once() + evaluate.reset_mock() + + for state in should_not_run: + coresys.core.state = state + await supervisor_version() + evaluate.assert_not_called() + evaluate.reset_mock()