mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-08 17:56:33 +00:00
Cache existence of addon paths (#4944)
* Cache existence of addon paths * Always update submodules * Switch to an always cached model * Cache on store addon only * Fix tests * refresh_cache to refresh_path_cache * Fix name change in test * Move logic into StoreManager
This commit is contained in:
parent
2148de45a0
commit
a8af04ff82
@ -180,6 +180,9 @@ class Addon(AddonModel):
|
|||||||
|
|
||||||
async def load(self) -> None:
|
async def load(self) -> None:
|
||||||
"""Async initialize of object."""
|
"""Async initialize of object."""
|
||||||
|
if self.is_detached:
|
||||||
|
await super().refresh_path_cache()
|
||||||
|
|
||||||
self._listeners.append(
|
self._listeners.append(
|
||||||
self.sys_bus.register_event(
|
self.sys_bus.register_event(
|
||||||
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.container_state_changed
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.container_state_changed
|
||||||
@ -230,6 +233,34 @@ class Addon(AddonModel):
|
|||||||
"""Return True if add-on is detached."""
|
"""Return True if add-on is detached."""
|
||||||
return self.slug not in self.sys_store.data.addons
|
return self.slug not in self.sys_store.data.addons
|
||||||
|
|
||||||
|
@property
|
||||||
|
def with_icon(self) -> bool:
|
||||||
|
"""Return True if an icon exists."""
|
||||||
|
if self.is_detached:
|
||||||
|
return super().with_icon
|
||||||
|
return self.addon_store.with_icon
|
||||||
|
|
||||||
|
@property
|
||||||
|
def with_logo(self) -> bool:
|
||||||
|
"""Return True if a logo exists."""
|
||||||
|
if self.is_detached:
|
||||||
|
return super().with_logo
|
||||||
|
return self.addon_store.with_logo
|
||||||
|
|
||||||
|
@property
|
||||||
|
def with_changelog(self) -> bool:
|
||||||
|
"""Return True if a changelog exists."""
|
||||||
|
if self.is_detached:
|
||||||
|
return super().with_changelog
|
||||||
|
return self.addon_store.with_changelog
|
||||||
|
|
||||||
|
@property
|
||||||
|
def with_documentation(self) -> bool:
|
||||||
|
"""Return True if a documentation exists."""
|
||||||
|
if self.is_detached:
|
||||||
|
return super().with_documentation
|
||||||
|
return self.addon_store.with_documentation
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return True if this add-on is available on this platform."""
|
"""Return True if this add-on is available on this platform."""
|
||||||
@ -1399,3 +1430,9 @@ class Addon(AddonModel):
|
|||||||
ContainerState.UNHEALTHY,
|
ContainerState.UNHEALTHY,
|
||||||
]:
|
]:
|
||||||
await self._restart_after_problem(event.state)
|
await self._restart_after_problem(event.state)
|
||||||
|
|
||||||
|
def refresh_path_cache(self) -> Awaitable[None]:
|
||||||
|
"""Refresh cache of existing paths."""
|
||||||
|
if self.is_detached:
|
||||||
|
return super().refresh_path_cache()
|
||||||
|
return self.addon_store.refresh_path_cache()
|
||||||
|
@ -77,15 +77,20 @@ class AddonManager(CoreSysAttributes):
|
|||||||
|
|
||||||
async def load(self) -> None:
|
async def load(self) -> None:
|
||||||
"""Start up add-on management."""
|
"""Start up add-on management."""
|
||||||
tasks = []
|
# Refresh cache for all store addons
|
||||||
|
tasks: list[Awaitable[None]] = [
|
||||||
|
store.refresh_path_cache() for store in self.store.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Load all installed addons
|
||||||
for slug in self.data.system:
|
for slug in self.data.system:
|
||||||
addon = self.local[slug] = Addon(self.coresys, slug)
|
addon = self.local[slug] = Addon(self.coresys, slug)
|
||||||
tasks.append(self.sys_create_task(addon.load()))
|
tasks.append(addon.load())
|
||||||
|
|
||||||
# Run initial tasks
|
# Run initial tasks
|
||||||
_LOGGER.info("Found %d installed add-ons", len(tasks))
|
_LOGGER.info("Found %d installed add-ons", len(self.data.system))
|
||||||
if tasks:
|
if tasks:
|
||||||
await asyncio.wait(tasks)
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
# Sync DNS
|
# Sync DNS
|
||||||
await self.sync_dns()
|
await self.sync_dns()
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"""Init file for Supervisor add-ons."""
|
"""Init file for Supervisor add-ons."""
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
@ -118,6 +118,10 @@ class AddonModel(JobGroup, ABC):
|
|||||||
coresys, JOB_GROUP_ADDON.format_map(defaultdict(str, slug=slug)), slug
|
coresys, JOB_GROUP_ADDON.format_map(defaultdict(str, slug=slug)), slug
|
||||||
)
|
)
|
||||||
self.slug: str = slug
|
self.slug: str = slug
|
||||||
|
self._path_icon_exists: bool = False
|
||||||
|
self._path_logo_exists: bool = False
|
||||||
|
self._path_changelog_exists: bool = False
|
||||||
|
self._path_documentation_exists: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -511,22 +515,22 @@ class AddonModel(JobGroup, ABC):
|
|||||||
@property
|
@property
|
||||||
def with_icon(self) -> bool:
|
def with_icon(self) -> bool:
|
||||||
"""Return True if an icon exists."""
|
"""Return True if an icon exists."""
|
||||||
return self.path_icon.exists()
|
return self._path_icon_exists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_logo(self) -> bool:
|
def with_logo(self) -> bool:
|
||||||
"""Return True if a logo exists."""
|
"""Return True if a logo exists."""
|
||||||
return self.path_logo.exists()
|
return self._path_logo_exists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_changelog(self) -> bool:
|
def with_changelog(self) -> bool:
|
||||||
"""Return True if a changelog exists."""
|
"""Return True if a changelog exists."""
|
||||||
return self.path_changelog.exists()
|
return self._path_changelog_exists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_documentation(self) -> bool:
|
def with_documentation(self) -> bool:
|
||||||
"""Return True if a documentation exists."""
|
"""Return True if a documentation exists."""
|
||||||
return self.path_documentation.exists()
|
return self._path_documentation_exists
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_arch(self) -> list[str]:
|
def supported_arch(self) -> list[str]:
|
||||||
@ -635,6 +639,17 @@ class AddonModel(JobGroup, ABC):
|
|||||||
"""Return breaking versions of addon."""
|
"""Return breaking versions of addon."""
|
||||||
return self.data[ATTR_BREAKING_VERSIONS]
|
return self.data[ATTR_BREAKING_VERSIONS]
|
||||||
|
|
||||||
|
def refresh_path_cache(self) -> Awaitable[None]:
|
||||||
|
"""Refresh cache of existing paths."""
|
||||||
|
|
||||||
|
def check_paths():
|
||||||
|
self._path_icon_exists = self.path_icon.exists()
|
||||||
|
self._path_logo_exists = self.path_logo.exists()
|
||||||
|
self._path_changelog_exists = self.path_changelog.exists()
|
||||||
|
self._path_documentation_exists = self.path_documentation.exists()
|
||||||
|
|
||||||
|
return self.sys_run_in_executor(check_paths)
|
||||||
|
|
||||||
def validate_availability(self) -> None:
|
def validate_availability(self) -> None:
|
||||||
"""Validate if addon is available for current system."""
|
"""Validate if addon is available for current system."""
|
||||||
return self._validate_availability(self.data, logger=_LOGGER.error)
|
return self._validate_availability(self.data, logger=_LOGGER.error)
|
||||||
|
@ -41,7 +41,7 @@ class FixupStoreExecuteReload(FixupBase):
|
|||||||
# Load data again
|
# Load data again
|
||||||
try:
|
try:
|
||||||
await repository.load()
|
await repository.load()
|
||||||
await repository.update()
|
await self.sys_store.reload(repository)
|
||||||
except StoreError:
|
except StoreError:
|
||||||
raise ResolutionFixupError() from None
|
raise ResolutionFixupError() from None
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""Add-on Store handler."""
|
"""Add-on Store handler."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ..const import ATTR_REPOSITORIES, URL_HASSIO_ADDONS
|
from ..const import ATTR_REPOSITORIES, URL_HASSIO_ADDONS
|
||||||
@ -85,15 +86,39 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
|
|||||||
conditions=[JobCondition.SUPERVISOR_UPDATED],
|
conditions=[JobCondition.SUPERVISOR_UPDATED],
|
||||||
on_condition=StoreJobError,
|
on_condition=StoreJobError,
|
||||||
)
|
)
|
||||||
async def reload(self) -> None:
|
async def reload(self, repository: Repository | None = None) -> None:
|
||||||
"""Update add-ons from repository and reload list."""
|
"""Update add-ons from repository and reload list."""
|
||||||
tasks = [self.sys_create_task(repository.update()) for repository in self.all]
|
# Make a copy to prevent race with other tasks
|
||||||
if tasks:
|
repositories = [repository] if repository else self.all.copy()
|
||||||
await asyncio.wait(tasks)
|
results: list[bool | Exception] = await asyncio.gather(
|
||||||
|
*[repo.update() for repo in repositories], return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
# read data from repositories
|
# Determine which repositories were updated
|
||||||
await self.load()
|
updated_repos: set[str] = set()
|
||||||
self._read_addons()
|
for i, result in enumerate(results):
|
||||||
|
if result is True:
|
||||||
|
updated_repos.add(repositories[i].slug)
|
||||||
|
elif result:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Could not reload repository %s due to %r",
|
||||||
|
repositories[i].slug,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update path cache for all addons in updated repos
|
||||||
|
if updated_repos:
|
||||||
|
await asyncio.gather(
|
||||||
|
*[
|
||||||
|
addon.refresh_path_cache()
|
||||||
|
for addon in self.sys_addons.store.values()
|
||||||
|
if addon.repository in updated_repos
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# read data from repositories
|
||||||
|
await self.load()
|
||||||
|
await self._read_addons()
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
name="store_manager_add_repository",
|
name="store_manager_add_repository",
|
||||||
@ -185,7 +210,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
|
|||||||
# Persist changes
|
# Persist changes
|
||||||
if persist:
|
if persist:
|
||||||
await self.data.update()
|
await self.data.update()
|
||||||
self._read_addons()
|
await self._read_addons()
|
||||||
|
|
||||||
async def remove_repository(self, repository: Repository, *, persist: bool = True):
|
async def remove_repository(self, repository: Repository, *, persist: bool = True):
|
||||||
"""Remove a repository."""
|
"""Remove a repository."""
|
||||||
@ -205,7 +230,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
|
|||||||
|
|
||||||
if persist:
|
if persist:
|
||||||
await self.data.update()
|
await self.data.update()
|
||||||
self._read_addons()
|
await self._read_addons()
|
||||||
|
|
||||||
@Job(name="store_manager_update_repositories")
|
@Job(name="store_manager_update_repositories")
|
||||||
async def update_repositories(
|
async def update_repositories(
|
||||||
@ -245,14 +270,14 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
|
|||||||
|
|
||||||
# Always update data, even there are errors, some changes may have succeeded
|
# Always update data, even there are errors, some changes may have succeeded
|
||||||
await self.data.update()
|
await self.data.update()
|
||||||
self._read_addons()
|
await self._read_addons()
|
||||||
|
|
||||||
# Raise the first error we found (if any)
|
# Raise the first error we found (if any)
|
||||||
for error in add_errors + remove_errors:
|
for error in add_errors + remove_errors:
|
||||||
if error:
|
if error:
|
||||||
raise error
|
raise error
|
||||||
|
|
||||||
def _read_addons(self) -> None:
|
async def _read_addons(self) -> None:
|
||||||
"""Reload add-ons inside store."""
|
"""Reload add-ons inside store."""
|
||||||
all_addons = set(self.data.addons)
|
all_addons = set(self.data.addons)
|
||||||
|
|
||||||
@ -268,8 +293,13 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# new addons
|
# new addons
|
||||||
for slug in add_addons:
|
if add_addons:
|
||||||
self.sys_addons.store[slug] = AddonStore(self.coresys, slug)
|
cache_updates: list[Awaitable[None]] = []
|
||||||
|
for slug in add_addons:
|
||||||
|
self.sys_addons.store[slug] = AddonStore(self.coresys, slug)
|
||||||
|
cache_updates.append(self.sys_addons.store[slug].refresh_path_cache())
|
||||||
|
|
||||||
|
await asyncio.gather(*cache_updates)
|
||||||
|
|
||||||
# remove
|
# remove
|
||||||
for slug in del_addons:
|
for slug in del_addons:
|
||||||
|
@ -117,7 +117,7 @@ class GitRepo(CoreSysAttributes):
|
|||||||
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM],
|
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM],
|
||||||
on_condition=StoreJobError,
|
on_condition=StoreJobError,
|
||||||
)
|
)
|
||||||
async def pull(self):
|
async def pull(self) -> bool:
|
||||||
"""Pull Git add-on repo."""
|
"""Pull Git add-on repo."""
|
||||||
if self.lock.locked():
|
if self.lock.locked():
|
||||||
_LOGGER.warning("There is already a task in progress")
|
_LOGGER.warning("There is already a task in progress")
|
||||||
@ -140,10 +140,13 @@ class GitRepo(CoreSysAttributes):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Jump on top of that
|
if changed := self.repo.commit(branch) != self.repo.commit(
|
||||||
await self.sys_run_in_executor(
|
f"origin/{branch}"
|
||||||
ft.partial(self.repo.git.reset, f"origin/{branch}", hard=True)
|
):
|
||||||
)
|
# Jump on top of that
|
||||||
|
await self.sys_run_in_executor(
|
||||||
|
ft.partial(self.repo.git.reset, f"origin/{branch}", hard=True)
|
||||||
|
)
|
||||||
|
|
||||||
# Update submodules
|
# Update submodules
|
||||||
await self.sys_run_in_executor(
|
await self.sys_run_in_executor(
|
||||||
@ -160,6 +163,8 @@ class GitRepo(CoreSysAttributes):
|
|||||||
# Cleanup old data
|
# Cleanup old data
|
||||||
await self.sys_run_in_executor(ft.partial(self.repo.git.clean, "-xdf"))
|
await self.sys_run_in_executor(ft.partial(self.repo.git.clean, "-xdf"))
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
except (
|
except (
|
||||||
git.InvalidGitRepositoryError,
|
git.InvalidGitRepositoryError,
|
||||||
git.NoSuchPathError,
|
git.NoSuchPathError,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
"""Represent a Supervisor repository."""
|
"""Represent a Supervisor repository."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -101,11 +102,11 @@ class Repository(CoreSysAttributes):
|
|||||||
return
|
return
|
||||||
await self.git.load()
|
await self.git.load()
|
||||||
|
|
||||||
async def update(self) -> None:
|
async def update(self) -> bool:
|
||||||
"""Update add-on repository."""
|
"""Update add-on repository."""
|
||||||
if self.type == StoreType.LOCAL or not self.validate():
|
if not self.validate():
|
||||||
return
|
return False
|
||||||
await self.git.pull()
|
return self.type == StoreType.LOCAL or await self.git.pull()
|
||||||
|
|
||||||
async def remove(self) -> None:
|
async def remove(self) -> None:
|
||||||
"""Remove add-on repository."""
|
"""Remove add-on repository."""
|
||||||
|
@ -748,3 +748,18 @@ def test_auto_update_available(coresys: CoreSys, install_addon_example: Addon):
|
|||||||
Addon, "version", new=PropertyMock(return_value=AwesomeVersion("test"))
|
Addon, "version", new=PropertyMock(return_value=AwesomeVersion("test"))
|
||||||
):
|
):
|
||||||
assert install_addon_example.auto_update_available is False
|
assert install_addon_example.auto_update_available is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paths_cache(coresys: CoreSys, install_addon_ssh: Addon):
|
||||||
|
"""Test cache for key paths that may or may not exist."""
|
||||||
|
with patch("supervisor.addons.addon.Path.exists", return_value=True):
|
||||||
|
assert not install_addon_ssh.with_logo
|
||||||
|
assert not install_addon_ssh.with_icon
|
||||||
|
assert not install_addon_ssh.with_changelog
|
||||||
|
assert not install_addon_ssh.with_documentation
|
||||||
|
|
||||||
|
await coresys.store.reload(coresys.store.get("local"))
|
||||||
|
assert install_addon_ssh.with_logo
|
||||||
|
assert install_addon_ssh.with_icon
|
||||||
|
assert install_addon_ssh.with_changelog
|
||||||
|
assert install_addon_ssh.with_documentation
|
||||||
|
@ -393,7 +393,7 @@ async def test_store_data_changes_during_update(
|
|||||||
update_task = coresys.create_task(simulate_update())
|
update_task = coresys.create_task(simulate_update())
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
with patch.object(Repository, "update"):
|
with patch.object(Repository, "update", return_value=True):
|
||||||
await coresys.store.reload()
|
await coresys.store.reload()
|
||||||
|
|
||||||
assert "image" not in coresys.store.data.addons["local_ssh"]
|
assert "image" not in coresys.store.data.addons["local_ssh"]
|
||||||
|
@ -24,12 +24,19 @@ from tests.common import load_yaml_fixture
|
|||||||
async def test_default_load(coresys: CoreSys):
|
async def test_default_load(coresys: CoreSys):
|
||||||
"""Test default load from config."""
|
"""Test default load from config."""
|
||||||
store_manager = StoreManager(coresys)
|
store_manager = StoreManager(coresys)
|
||||||
|
refresh_cache_calls: set[str] = set()
|
||||||
|
|
||||||
|
async def mock_refresh_cache(obj: AddonStore):
|
||||||
|
nonlocal refresh_cache_calls
|
||||||
|
refresh_cache_calls.add(obj.slug)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"supervisor.store.repository.Repository.load", return_value=None
|
"supervisor.store.repository.Repository.load", return_value=None
|
||||||
), patch.object(
|
), patch.object(
|
||||||
type(coresys.config), "addons_repositories", return_value=[]
|
type(coresys.config), "addons_repositories", return_value=[]
|
||||||
), patch("pathlib.Path.exists", return_value=True):
|
), patch("pathlib.Path.exists", return_value=True), patch.object(
|
||||||
|
AddonStore, "refresh_path_cache", new=mock_refresh_cache
|
||||||
|
):
|
||||||
await store_manager.load()
|
await store_manager.load()
|
||||||
|
|
||||||
assert len(store_manager.all) == 4
|
assert len(store_manager.all) == 4
|
||||||
@ -44,10 +51,15 @@ async def test_default_load(coresys: CoreSys):
|
|||||||
"https://github.com/esphome/home-assistant-addon"
|
"https://github.com/esphome/home-assistant-addon"
|
||||||
in store_manager.repository_urls
|
in store_manager.repository_urls
|
||||||
)
|
)
|
||||||
|
assert refresh_cache_calls == {"local_ssh", "local_example", "core_samba"}
|
||||||
|
|
||||||
|
|
||||||
async def test_load_with_custom_repository(coresys: CoreSys):
|
async def test_load_with_custom_repository(coresys: CoreSys):
|
||||||
"""Test load from config with custom repository."""
|
"""Test load from config with custom repository."""
|
||||||
|
|
||||||
|
async def mock_refresh_cache(_):
|
||||||
|
pass
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"supervisor.utils.common.read_json_or_yaml_file",
|
"supervisor.utils.common.read_json_or_yaml_file",
|
||||||
return_value={"repositories": ["http://example.com"]},
|
return_value={"repositories": ["http://example.com"]},
|
||||||
@ -60,7 +72,9 @@ async def test_load_with_custom_repository(coresys: CoreSys):
|
|||||||
type(coresys.config), "addons_repositories", return_value=[]
|
type(coresys.config), "addons_repositories", return_value=[]
|
||||||
), patch(
|
), patch(
|
||||||
"supervisor.store.repository.Repository.validate", return_value=True
|
"supervisor.store.repository.Repository.validate", return_value=True
|
||||||
), patch("pathlib.Path.exists", return_value=True):
|
), patch("pathlib.Path.exists", return_value=True), patch.object(
|
||||||
|
AddonStore, "refresh_path_cache", new=mock_refresh_cache
|
||||||
|
):
|
||||||
await store_manager.load()
|
await store_manager.load()
|
||||||
|
|
||||||
assert len(store_manager.all) == 5
|
assert len(store_manager.all) == 5
|
||||||
|
Loading…
x
Reference in New Issue
Block a user