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
This commit is contained in:
Mike Degatano 2022-08-15 12:13:22 -04:00 committed by GitHub
parent e82cb5da45
commit c8f184f24c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 436 additions and 37 deletions

View File

@ -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:

View File

@ -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,
]

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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"

View File

@ -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!")

View File

@ -182,6 +182,8 @@ class HomeAssistantCore(CoreSysAttributes):
JobCondition.FREE_SPACE,
JobCondition.HEALTHY,
JobCondition.INTERNET_HOST,
JobCondition.PLUGINS_UPDATED,
JobCondition.SUPERVISOR_UPDATED,
],
on_condition=HomeAssistantJobError,
)

View File

@ -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"

View File

@ -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 (

View File

@ -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:

View File

@ -173,6 +173,7 @@ class OSManager(CoreSysAttributes):
JobCondition.HAOS,
JobCondition.INTERNET_SYSTEM,
JobCondition.RUNNING,
JobCondition.SUPERVISOR_UPDATED,
],
limit=JobExecutionLimit.ONCE,
on_condition=HassOSJobError,

View File

@ -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

View File

@ -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

View File

@ -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,
]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -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,

View File

@ -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,
)

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()