Use add-on config timestamp to determine add-on update age (#5897)

* Use add-on config timestamp to determine add-on update age

Instead of using the current timestamp when loading the add-on config,
simply use the add-on config modification timestamp. This way, we can
get a timetsamp even when Supervisor got restarted. It also simplifies
the code a bit.

* Fix pytest

* Patch stat() instead of modifing fixture files
This commit is contained in:
Stefan Agner 2025-05-21 13:46:20 +02:00 committed by GitHub
parent 86c016b35d
commit 1faf529b42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 34 additions and 34 deletions

View File

@ -121,6 +121,12 @@ class Tasks(CoreSysAttributes):
continue continue
# Delay auto-updates for a day in case of issues # Delay auto-updates for a day in case of issues
if utcnow() < addon.latest_version_timestamp + timedelta(days=1): if utcnow() < addon.latest_version_timestamp + timedelta(days=1):
_LOGGER.debug(
"Not updating add-on %s from %s to %s as the latest version is less than a day old",
addon.slug,
addon.version,
addon.latest_version,
)
continue continue
if not addon.test_update_schema(): if not addon.test_update_schema():
_LOGGER.warning( _LOGGER.warning(

View File

@ -15,7 +15,6 @@ from ..const import (
ATTR_REPOSITORY, ATTR_REPOSITORY,
ATTR_SLUG, ATTR_SLUG,
ATTR_TRANSLATIONS, ATTR_TRANSLATIONS,
ATTR_VERSION,
ATTR_VERSION_TIMESTAMP, ATTR_VERSION_TIMESTAMP,
FILE_SUFFIX_CONFIGURATION, FILE_SUFFIX_CONFIGURATION,
REPOSITORY_CORE, REPOSITORY_CORE,
@ -25,7 +24,6 @@ from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ConfigurationFileError from ..exceptions import ConfigurationFileError
from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from ..utils.common import find_one_filetype, read_json_or_yaml_file 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 ..utils.json import read_json_file
from .const import StoreType from .const import StoreType
from .utils import extract_hash_from_path from .utils import extract_hash_from_path
@ -142,23 +140,12 @@ class StoreData(CoreSysAttributes):
repositories[repo.slug] = repo.config repositories[repo.slug] = repo.config
addons.update(await self._read_addons_folder(repo.path, repo.slug)) 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.repositories = repositories
self.addons = addons self.addons = addons
async def _find_addons(self, path: Path, repository: dict) -> list[Path] | None: async def _find_addon_configs(
self, path: Path, repository: dict
) -> list[Path] | None:
"""Find add-ons in the path.""" """Find add-ons in the path."""
def _get_addons_list() -> list[Path]: def _get_addons_list() -> list[Path]:
@ -200,14 +187,14 @@ class StoreData(CoreSysAttributes):
self, path: Path, repository: str self, path: Path, repository: str
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Read data from add-ons folder.""" """Read data from add-ons folder."""
if not (addon_list := await self._find_addons(path, repository)): if not (addon_config_list := await self._find_addon_configs(path, repository)):
return {} return {}
def _process_addons_config() -> dict[str, dict[str, Any]]: def _process_addons_config() -> dict[str, dict[str, Any]]:
addons_config: dict[str, dict[str, Any]] = {} addons: dict[str, dict[str, Any]] = {}
for addon in addon_list: for addon_config in addon_config_list:
try: try:
addon_config = read_json_or_yaml_file(addon) addon = read_json_or_yaml_file(addon_config)
except ConfigurationFileError: except ConfigurationFileError:
_LOGGER.warning( _LOGGER.warning(
"Can't read %s from repository %s", addon, repository "Can't read %s from repository %s", addon, repository
@ -216,23 +203,24 @@ class StoreData(CoreSysAttributes):
# validate # validate
try: try:
addon_config = SCHEMA_ADDON_CONFIG(addon_config) addon = SCHEMA_ADDON_CONFIG(addon)
except vol.Invalid as ex: except vol.Invalid as ex:
_LOGGER.warning( _LOGGER.warning(
"Can't read %s: %s", addon, humanize_error(addon_config, ex) "Can't read %s: %s", addon, humanize_error(addon, ex)
) )
continue continue
# Generate slug # Generate slug
addon_slug = f"{repository}_{addon_config[ATTR_SLUG]}" addon_slug = f"{repository}_{addon[ATTR_SLUG]}"
# store # store
addon_config[ATTR_REPOSITORY] = repository addon[ATTR_REPOSITORY] = repository
addon_config[ATTR_LOCATION] = str(addon.parent) addon[ATTR_LOCATION] = str(addon_config.parent)
addon_config[ATTR_TRANSLATIONS] = _read_addon_translations(addon.parent) addon[ATTR_TRANSLATIONS] = _read_addon_translations(addon_config.parent)
addons_config[addon_slug] = addon_config addon[ATTR_VERSION_TIMESTAMP] = addon_config.stat().st_mtime
addons[addon_slug] = addon
return addons_config return addons
return await self.sys_run_in_executor(_process_addons_config) return await self.sys_run_in_executor(_process_addons_config)

View File

@ -25,7 +25,7 @@ async def test_read_addon_files(coresys: CoreSys):
Path(".circleci/config.yml"), Path(".circleci/config.yml"),
], ],
): ):
addon_list = await coresys.store.data._find_addons(Path("test"), {}) addon_list = await coresys.store.data._find_addon_configs(Path("test"), {})
assert len(addon_list) == 1 assert len(addon_list) == 1
assert str(addon_list[0]) == "addon/config.yml" assert str(addon_list[0]) == "addon/config.yml"
@ -38,14 +38,14 @@ async def test_reading_addon_files_error(coresys: CoreSys):
with patch("pathlib.Path.glob", side_effect=(err := OSError())): with patch("pathlib.Path.glob", side_effect=(err := OSError())):
err.errno = errno.EBUSY err.errno = errno.EBUSY
assert (await coresys.store.data._find_addons(Path("test"), {})) is None assert (await coresys.store.data._find_addon_configs(Path("test"), {})) is None
assert corrupt_repo in coresys.resolution.issues assert corrupt_repo in coresys.resolution.issues
assert reset_repo in coresys.resolution.suggestions assert reset_repo in coresys.resolution.suggestions
assert coresys.core.healthy is True assert coresys.core.healthy is True
coresys.resolution.dismiss_issue(corrupt_repo) coresys.resolution.dismiss_issue(corrupt_repo)
err.errno = errno.EBADMSG err.errno = errno.EBADMSG
assert (await coresys.store.data._find_addons(Path("test"), {})) is None assert (await coresys.store.data._find_addon_configs(Path("test"), {})) is None
assert corrupt_repo in coresys.resolution.issues assert corrupt_repo in coresys.resolution.issues
assert reset_repo not in coresys.resolution.suggestions assert reset_repo not in coresys.resolution.suggestions
assert coresys.core.healthy is False assert coresys.core.healthy is False

View File

@ -1,5 +1,7 @@
"""Test store manager.""" """Test store manager."""
from datetime import datetime
from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import PropertyMock, patch from unittest.mock import PropertyMock, patch
@ -251,7 +253,11 @@ async def test_addon_version_timestamp(coresys: CoreSys, install_addon_example:
# If a new version is seen processing repo, reset to utc now # If a new version is seen processing repo, reset to utc now
install_addon_example.data_store["version"] = "1.1.0" install_addon_example.data_store["version"] = "1.1.0"
# Signal the store repositories got updated with patch(
with patch("supervisor.store.repository.Repository.update", return_value=True): "pathlib.Path.stat",
return_value=SimpleNamespace(
st_mode=0o100644, st_mtime=datetime.now().timestamp()
),
):
await coresys.store.reload() await coresys.store.reload()
assert timestamp < install_addon_example.latest_version_timestamp assert timestamp < install_addon_example.latest_version_timestamp