diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index db6c26680..1d9d01cee 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Awaitable from contextlib import suppress from copy import deepcopy +from datetime import datetime import errno from ipaddress import IPv4Address import logging @@ -47,6 +48,7 @@ from ..const import ( ATTR_USER, ATTR_UUID, ATTR_VERSION, + ATTR_VERSION_TIMESTAMP, ATTR_WATCHDOG, DNS_SUFFIX, AddonBoot, @@ -344,6 +346,11 @@ class Addon(AddonModel): """Return version of add-on.""" return self.data_store[ATTR_VERSION] + @property + def latest_version_timestamp(self) -> datetime: + """Return when latest version was first seen.""" + return datetime.fromtimestamp(self.data_store[ATTR_VERSION_TIMESTAMP]) + @property def protected(self) -> bool: """Return if add-on is in protected mode.""" diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index b1c4f657d..fb04729bd 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Callable from contextlib import suppress +from datetime import datetime import logging from pathlib import Path from typing import Any @@ -71,6 +72,7 @@ from ..const import ( ATTR_URL, ATTR_USB, ATTR_VERSION, + ATTR_VERSION_TIMESTAMP, ATTR_VIDEO, ATTR_WATCHDOG, ATTR_WEBUI, @@ -222,6 +224,11 @@ class AddonModel(JobGroup, ABC): """Return latest version of add-on.""" return self.data[ATTR_VERSION] + @property + def latest_version_timestamp(self) -> datetime: + """Return when latest version was first seen.""" + return datetime.fromtimestamp(self.data[ATTR_VERSION_TIMESTAMP]) + @property def version(self) -> AwesomeVersion: """Return version of add-on.""" diff --git a/supervisor/const.py b/supervisor/const.py index 937956555..c87f8c119 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -332,6 +332,7 @@ ATTR_UUID = "uuid" ATTR_VALID = "valid" ATTR_VALUE = "value" ATTR_VERSION = "version" +ATTR_VERSION_TIMESTAMP = "version_timestamp" ATTR_VERSION_LATEST = "version_latest" ATTR_VIDEO = "video" ATTR_VLAN = "vlan" diff --git a/supervisor/misc/tasks.py b/supervisor/misc/tasks.py index 55333d966..f2a17204a 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -1,6 +1,7 @@ """A collection of tasks.""" import asyncio from collections.abc import Awaitable +from datetime import timedelta import logging from ..addons.const import ADDON_UPDATE_CONDITIONS @@ -10,6 +11,7 @@ from ..exceptions import AddonsError, HomeAssistantError, ObserverError from ..homeassistant.const import LANDINGPAGE from ..jobs.decorator import Job, JobCondition from ..plugins.const import PLUGIN_UPDATE_CONDITIONS +from ..utils.dt import utcnow from ..utils.sentry import capture_exception _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -102,6 +104,8 @@ class Tasks(CoreSysAttributes): addon.version, addon.latest_version, ) + # Delay auto-updates for a day in case of issues + if utcnow() + timedelta(days=1) > addon.latest_version_timestamp: continue if not addon.test_update_schema(): _LOGGER.warning( diff --git a/supervisor/store/data.py b/supervisor/store/data.py index 76253b639..1e33b923f 100644 --- a/supervisor/store/data.py +++ b/supervisor/store/data.py @@ -14,6 +14,8 @@ from ..const import ( ATTR_REPOSITORY, ATTR_SLUG, ATTR_TRANSLATIONS, + ATTR_VERSION, + ATTR_VERSION_TIMESTAMP, FILE_SUFFIX_CONFIGURATION, REPOSITORY_CORE, REPOSITORY_LOCAL, @@ -22,6 +24,7 @@ from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ConfigurationFileError from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason from ..utils.common import find_one_filetype, read_json_or_yaml_file +from ..utils.dt import utcnow from ..utils.json import read_json_file from .const import StoreType from .utils import extract_hash_from_path @@ -135,6 +138,19 @@ class StoreData(CoreSysAttributes): repositories[repo.slug] = repo.config addons.update(await self._read_addons_folder(repo.path, repo.slug)) + # Add a timestamp when we first see a new version + for slug, config in addons.items(): + old_config = self.addons.get(slug) + + if ( + not old_config + or ATTR_VERSION_TIMESTAMP not in old_config + or old_config.get(ATTR_VERSION) != config.get(ATTR_VERSION) + ): + config[ATTR_VERSION_TIMESTAMP] = utcnow().timestamp() + else: + config[ATTR_VERSION_TIMESTAMP] = old_config[ATTR_VERSION_TIMESTAMP] + self.repositories = repositories self.addons = addons diff --git a/tests/store/test_store_manager.py b/tests/store/test_store_manager.py index 2348440bb..8961c406e 100644 --- a/tests/store/test_store_manager.py +++ b/tests/store/test_store_manager.py @@ -243,3 +243,18 @@ async def test_reload(coresys: CoreSys): await coresys.store.reload() assert git_pull.call_count == 3 + + +async def test_addon_version_timestamp(coresys: CoreSys, install_addon_example: Addon): + """Test timestamp tracked for addon's version.""" + # When unset, version timestamp set to utcnow on store load + assert (timestamp := install_addon_example.latest_version_timestamp) + + # Reload of the store does not change timestamp unless version changes + await coresys.store.reload() + assert timestamp == install_addon_example.latest_version_timestamp + + # If a new version is seen processing repo, reset to utc now + install_addon_example.data_store["version"] = "1.1.0" + await coresys.store.reload() + assert timestamp < install_addon_example.latest_version_timestamp