diff --git a/scripts/test_env.sh b/scripts/test_env.sh index fb2b93567..08c52a535 100755 --- a/scripts/test_env.sh +++ b/scripts/test_env.sh @@ -125,6 +125,8 @@ echo "Start Test-Env" start_docker trap "stop_docker" ERR +docker system prune -f + build_supervisor cleanup_lastboot cleanup_docker diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index be053b83c..d6dd6f31c 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -6,6 +6,8 @@ from typing import Any, Awaitable, Dict from aiohttp import web import voluptuous as vol +from supervisor.resolution.const import ContextType, SuggestionType + from ..const import ( ATTR_ADDONS, ATTR_ADDONS_REPOSITORIES, @@ -143,10 +145,20 @@ class APISupervisor(CoreSysAttributes): if ATTR_ADDONS_REPOSITORIES in body: new = set(body[ATTR_ADDONS_REPOSITORIES]) await asyncio.shield(self.sys_store.update_repositories(new)) - if sorted(body[ATTR_ADDONS_REPOSITORIES]) != sorted( - self.sys_config.addons_repositories - ): - raise APIError("Not a valid add-on repository") + + # Fix invalid repository + found_invalid = False + for suggestion in self.sys_resolution.suggestions: + if ( + suggestion.type != SuggestionType.EXECUTE_REMOVE + and suggestion.context != ContextType + ): + continue + found_invalid = True + await self.sys_resolution.apply_suggestion(suggestion) + + if found_invalid: + raise APIError("Invalid Add-on repository!") self.sys_updater.save_data() self.sys_config.save_data() diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index e18e385cc..f832fa9e4 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -310,6 +310,10 @@ class StoreGitError(StoreError): """Raise if something on git is happening.""" +class StoreNotFound(StoreError): + """Raise if slug is not known.""" + + # JobManager diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index c553aa0ef..ff3c83076 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -66,5 +66,5 @@ class SuggestionType(str, Enum): EXECUTE_REPAIR = "execute_repair" EXECUTE_RESET = "execute_reset" EXECUTE_RELOAD = "execute_reload" + EXECUTE_REMOVE = "execute_remove" REGISTRY_LOGIN = "registry_login" - NEW_INITIALIZE = "new_initialize" diff --git a/supervisor/resolution/fixup.py b/supervisor/resolution/fixup.py index 696e4adb7..d07dd2837 100644 --- a/supervisor/resolution/fixup.py +++ b/supervisor/resolution/fixup.py @@ -9,6 +9,9 @@ from ..coresys import CoreSys, CoreSysAttributes from .fixups.base import FixupBase from .fixups.clear_full_snapshot import FixupClearFullSnapshot from .fixups.create_full_snapshot import FixupCreateFullSnapshot +from .fixups.store_execute_reload import FixupStoreExecuteReload +from .fixups.store_execute_remove import FixupStoreExecuteRemove +from .fixups.store_execute_reset import FixupStoreExecuteReset _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -22,11 +25,20 @@ class ResolutionFixup(CoreSysAttributes): self._create_full_snapshot = FixupCreateFullSnapshot(coresys) self._clear_full_snapshot = FixupClearFullSnapshot(coresys) + self._store_execute_reset = FixupStoreExecuteReset(coresys) + self._store_execute_reload = FixupStoreExecuteReload(coresys) + self._store_execute_remove = FixupStoreExecuteRemove(coresys) @property def all_fixes(self) -> List[FixupBase]: """Return a list of all fixups.""" - return [self._create_full_snapshot, self._clear_full_snapshot] + return [ + self._create_full_snapshot, + self._clear_full_snapshot, + self._store_execute_reload, + self._store_execute_reset, + self._store_execute_remove, + ] async def run_autofix(self) -> None: """Run all startup fixes.""" diff --git a/supervisor/resolution/fixups/base.py b/supervisor/resolution/fixups/base.py index d1b188f5f..38ef12f86 100644 --- a/supervisor/resolution/fixups/base.py +++ b/supervisor/resolution/fixups/base.py @@ -1,11 +1,10 @@ """Baseclass for system fixup.""" from abc import ABC, abstractmethod, abstractproperty -from contextlib import suppress import logging -from typing import Optional +from typing import List, Optional from ...coresys import CoreSys, CoreSysAttributes -from ...exceptions import ResolutionError, ResolutionFixupError +from ...exceptions import ResolutionFixupError from ..const import ContextType, IssueType, SuggestionType from ..data import Issue, Suggestion @@ -42,13 +41,12 @@ class FixupBase(ABC, CoreSysAttributes): self.sys_resolution.dismiss_suggestion(fixing_suggestion) - if self.issue is None: - return - - with suppress(ResolutionError): - self.sys_resolution.dismiss_issue( - Issue(self.issue, self.context, fixing_suggestion.reference) - ) + # Cleanup issue + for issue_type in self.issues: + issue = Issue(issue_type, self.context, fixing_suggestion.reference) + if issue not in self.sys_resolution.issues: + continue + self.sys_resolution.dismiss_issue(issue) @abstractmethod async def process_fixup(self, reference: Optional[str] = None) -> None: @@ -65,9 +63,9 @@ class FixupBase(ABC, CoreSysAttributes): """Return a ContextType enum.""" @property - def issue(self) -> Optional[IssueType]: - """Return a IssueType enum.""" - return None + def issues(self) -> List[IssueType]: + """Return a IssueType enum list.""" + return [] @property def auto(self) -> bool: diff --git a/supervisor/resolution/fixups/clear_full_snapshot.py b/supervisor/resolution/fixups/clear_full_snapshot.py index add696000..72b5e4652 100644 --- a/supervisor/resolution/fixups/clear_full_snapshot.py +++ b/supervisor/resolution/fixups/clear_full_snapshot.py @@ -1,6 +1,6 @@ """Helpers to check and fix issues with free space.""" import logging -from typing import Optional +from typing import List, Optional from ...const import SNAPSHOT_FULL from ..const import MINIMUM_FULL_SNAPSHOTS, ContextType, IssueType, SuggestionType @@ -36,6 +36,6 @@ class FixupClearFullSnapshot(FixupBase): return ContextType.SYSTEM @property - def issue(self) -> IssueType: - """Return a IssueType enum.""" - return IssueType.FREE_SPACE + def issues(self) -> List[IssueType]: + """Return a IssueType enum list.""" + return [IssueType.FREE_SPACE] diff --git a/supervisor/resolution/fixups/store_execute_reload.py b/supervisor/resolution/fixups/store_execute_reload.py new file mode 100644 index 000000000..97c848e3a --- /dev/null +++ b/supervisor/resolution/fixups/store_execute_reload.py @@ -0,0 +1,50 @@ +"""Helpers to check and fix issues with free space.""" +import logging +from typing import List, Optional + +from supervisor.exceptions import ResolutionFixupError, StoreError, StoreNotFound + +from ..const import ContextType, IssueType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class FixupStoreExecuteReload(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: Optional[str] = None) -> None: + """Initialize the fixup class.""" + _LOGGER.info("Reload Store: %s", reference) + try: + repository = self.sys_store.get(reference) + except StoreNotFound: + _LOGGER.warning("Can't find store %s for fixup", reference) + return + + # Load data again + try: + await repository.load() + await repository.update() + except StoreError: + raise ResolutionFixupError() from None + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.EXECUTE_RELOAD + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.STORE + + @property + def issues(self) -> List[IssueType]: + """Return a IssueType enum list.""" + return [IssueType.FATAL_ERROR] + + @property + def auto(self) -> bool: + """Return if a fixup can be apply as auto fix.""" + return True diff --git a/supervisor/resolution/fixups/store_execute_remove.py b/supervisor/resolution/fixups/store_execute_remove.py new file mode 100644 index 000000000..4e7ef7f98 --- /dev/null +++ b/supervisor/resolution/fixups/store_execute_remove.py @@ -0,0 +1,52 @@ +"""Helpers to check and fix issues with free space.""" +import logging +from typing import List, Optional + +from supervisor.exceptions import ResolutionFixupError, StoreError, StoreNotFound + +from ..const import ContextType, IssueType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class FixupStoreExecuteRemove(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: Optional[str] = None) -> None: + """Initialize the fixup class.""" + _LOGGER.info("Remove invalid Store: %s", reference) + try: + repository = self.sys_store.get(reference) + except StoreNotFound: + _LOGGER.warning("Can't find store %s for fixup", reference) + return + + # Remove repository + try: + await repository.remove() + except StoreError: + raise ResolutionFixupError() from None + + self.sys_config.drop_addon_repository(repository.source) + self.sys_config.save_data() + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.EXECUTE_REMOVE + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.STORE + + @property + def issues(self) -> List[IssueType]: + """Return a IssueType enum list.""" + return [IssueType.CORRUPT_REPOSITORY] + + @property + def auto(self) -> bool: + """Return if a fixup can be apply as auto fix.""" + return True diff --git a/supervisor/resolution/fixups/store_execute_reset.py b/supervisor/resolution/fixups/store_execute_reset.py new file mode 100644 index 000000000..1a1829546 --- /dev/null +++ b/supervisor/resolution/fixups/store_execute_reset.py @@ -0,0 +1,52 @@ +"""Helpers to check and fix issues with free space.""" +import logging +from typing import List, Optional + +from supervisor.exceptions import ResolutionFixupError, StoreError, StoreNotFound + +from ...utils import remove_folder +from ..const import ContextType, IssueType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class FixupStoreExecuteReset(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: Optional[str] = None) -> None: + """Initialize the fixup class.""" + _LOGGER.info("Reset corrupt Store: %s", reference) + try: + repository = self.sys_store.get(reference) + except StoreNotFound: + _LOGGER.warning("Can't find store %s for fixup", reference) + return + + await remove_folder(repository.git.path) + + # Load data again + try: + await repository.load() + except StoreError: + raise ResolutionFixupError() from None + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.EXECUTE_RESET + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.STORE + + @property + def issues(self) -> List[IssueType]: + """Return a IssueType enum list.""" + return [IssueType.CORRUPT_REPOSITORY, IssueType.FATAL_ERROR] + + @property + def auto(self) -> bool: + """Return if a fixup can be apply as auto fix.""" + return True diff --git a/supervisor/store/__init__.py b/supervisor/store/__init__.py index 53835de9b..ed2dd527e 100644 --- a/supervisor/store/__init__.py +++ b/supervisor/store/__init__.py @@ -1,25 +1,20 @@ """Add-on Store handler.""" import asyncio import logging -from pathlib import Path from typing import Dict, List -import voluptuous as vol - -from supervisor.store.validate import SCHEMA_REPOSITORY_CONFIG -from supervisor.utils.json import read_json_file - -from ..const import REPOSITORY_CORE, REPOSITORY_LOCAL from ..coresys import CoreSys, CoreSysAttributes -from ..exceptions import JsonFileError, StoreGitError +from ..exceptions import StoreGitError, StoreNotFound from ..jobs.decorator import Job, JobCondition +from ..resolution.const import ContextType, IssueType, SuggestionType from .addon import AddonStore +from .const import StoreType from .data import StoreData from .repository import Repository _LOGGER: logging.Logger = logging.getLogger(__name__) -BUILTIN_REPOSITORIES = {REPOSITORY_CORE, REPOSITORY_LOCAL} +BUILTIN_REPOSITORIES = {StoreType.CORE.value, StoreType.LOCAL.value} class StoreManager(CoreSysAttributes): @@ -36,6 +31,20 @@ class StoreManager(CoreSysAttributes): """Return list of add-on repositories.""" return list(self.repositories.values()) + def get(self, slug: str) -> Repository: + """Return Repository with slug.""" + if slug not in self.repositories: + raise StoreNotFound() + return self.repositories[slug] + + def get_from_url(self, url: str) -> Repository: + """Return Repository with slug.""" + for repository in self.all: + if repository.source != url: + continue + return repository + raise StoreNotFound() + async def load(self) -> None: """Start up add-on management.""" self.data.update() @@ -48,7 +57,7 @@ class StoreManager(CoreSysAttributes): async def reload(self) -> None: """Update add-ons from repository and reload list.""" - tasks = [repository.update() for repository in self.repositories.values()] + tasks = [repository.update() for repository in self.all] if tasks: await asyncio.wait(tasks) @@ -61,35 +70,33 @@ class StoreManager(CoreSysAttributes): """Add a new custom repository.""" job = self.sys_jobs.get_job("storemanager_update_repositories") new_rep = set(list_repositories) - old_rep = set(self.repositories) + old_rep = {repository.source for repository in self.all} # add new repository async def _add_repository(url: str, step: int): """Add a repository.""" job.update(progress=job.progress + step, stage=f"Checking {url} started") repository = Repository(self.coresys, url) + + # Load the repository try: await repository.load() except StoreGitError: _LOGGER.error("Can't load data from repository %s", url) - return - - # don't add built-in repository to config - if url not in BUILTIN_REPOSITORIES: - # Verify that it is a add-on repository - repository_file = Path(repository.git.path, "repository.json") - try: - await self.sys_run_in_executor( - SCHEMA_REPOSITORY_CONFIG, read_json_file(repository_file) + else: + if not repository.validate(): + _LOGGER.error("%s is not a valid add-on repository", url) + self.sys_resolution.create_issue( + IssueType.CORRUPT_REPOSITORY, + ContextType.STORE, + reference=repository.slug, + suggestions=[SuggestionType.EXECUTE_REMOVE], ) - except (JsonFileError, vol.Invalid) as err: - _LOGGER.error("%s is not a valid add-on repository. %s", url, err) - await repository.remove() - return - self.sys_config.add_addon_repository(url) - - self.repositories[url] = repository + # Add Repository to list + if repository.type == StoreType.GIT: + self.sys_config.add_addon_repository(repository.source) + self.repositories[repository.slug] = repository job.update(progress=10, stage="Check repositories") repos = new_rep - old_rep @@ -97,9 +104,10 @@ class StoreManager(CoreSysAttributes): if tasks: await asyncio.wait(tasks) - # del new repository + # Delete stale repositories for url in old_rep - new_rep - BUILTIN_REPOSITORIES: - await self.repositories.pop(url).remove() + repository = self.get_from_url(url) + await self.repositories.pop(repository.slug).remove() self.sys_config.drop_addon_repository(url) # update data diff --git a/supervisor/store/const.py b/supervisor/store/const.py new file mode 100644 index 000000000..43cbc989d --- /dev/null +++ b/supervisor/store/const.py @@ -0,0 +1,10 @@ +"""Constants for the add-on store.""" +from enum import Enum + + +class StoreType(str, Enum): + """Store Types.""" + + CORE = "core" + LOCAL = "local" + GIT = "git" diff --git a/supervisor/store/git.py b/supervisor/store/git.py index 40e24ca26..11f76e984 100644 --- a/supervisor/store/git.py +++ b/supervisor/store/git.py @@ -3,7 +3,6 @@ import asyncio import functools as ft import logging from pathlib import Path -import shutil from typing import Dict, Optional import git @@ -13,6 +12,7 @@ from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import StoreGitError from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType, SuggestionType +from ..utils import remove_folder from ..validate import RE_REPOSITORY from .utils import get_hash_from_repository @@ -30,7 +30,6 @@ class GitRepo(CoreSysAttributes): self.lock: asyncio.Lock = asyncio.Lock() self.data: Dict[str, str] = RE_REPOSITORY.match(url).groupdict() - self.slug: str = url @property def url(self) -> str: @@ -59,11 +58,12 @@ class GitRepo(CoreSysAttributes): git.NoSuchPathError, git.GitCommandError, ) as err: - _LOGGER.error("Can't load %s repo: %s.", self.path, err) + _LOGGER.error("Can't load %s", self.path) self.sys_resolution.create_issue( IssueType.FATAL_ERROR, ContextType.STORE, - reference=self.slug, + reference=self.path.stem, + suggestions=[SuggestionType.EXECUTE_RESET], ) raise StoreGitError() from err @@ -77,7 +77,7 @@ class GitRepo(CoreSysAttributes): self.sys_resolution.create_issue( IssueType.CORRUPT_REPOSITORY, ContextType.STORE, - reference=self.slug, + reference=self.path.stem, suggestions=[SuggestionType.EXECUTE_RESET], ) raise StoreGitError() from err @@ -114,8 +114,8 @@ class GitRepo(CoreSysAttributes): self.sys_resolution.create_issue( IssueType.FATAL_ERROR, ContextType.STORE, - reference=self.slug, - suggestions=[SuggestionType.NEW_INITIALIZE], + reference=self.path.stem, + suggestions=[SuggestionType.EXECUTE_RELOAD], ) raise StoreGitError() from err @@ -156,8 +156,8 @@ class GitRepo(CoreSysAttributes): self.sys_resolution.create_issue( IssueType.CORRUPT_REPOSITORY, ContextType.STORE, - reference=self.slug, - suggestions=[SuggestionType.EXECUTE_RELOAD], + reference=self.path.stem, + suggestions=[SuggestionType.EXECUTE_RESET], ) raise StoreGitError() from err @@ -169,14 +169,7 @@ class GitRepo(CoreSysAttributes): if not self.path.is_dir(): return - - def log_err(funct, path, _): - """Log error.""" - _LOGGER.warning("Can't remove %s", path) - - await self.sys_run_in_executor( - ft.partial(shutil.rmtree, self.path, onerror=log_err) - ) + await remove_folder(self.path) class GitRepoHassIO(GitRepo): diff --git a/supervisor/store/repository.py b/supervisor/store/repository.py index 7a6019e6e..59f6df341 100644 --- a/supervisor/store/repository.py +++ b/supervisor/store/repository.py @@ -1,75 +1,103 @@ """Represent a Supervisor repository.""" -from ..const import ( - ATTR_MAINTAINER, - ATTR_NAME, - ATTR_URL, - REPOSITORY_CORE, - REPOSITORY_LOCAL, -) -from ..coresys import CoreSysAttributes -from ..exceptions import APIError +import logging +from pathlib import Path +from typing import Dict, Optional + +import voluptuous as vol + +from ..const import ATTR_MAINTAINER, ATTR_NAME, ATTR_URL +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import JsonFileError, StoreError +from ..utils.json import read_json_file +from .const import StoreType from .git import GitRepoCustom, GitRepoHassIO from .utils import get_hash_from_repository +from .validate import SCHEMA_REPOSITORY_CONFIG +_LOGGER: logging.Logger = logging.getLogger(__name__) UNKNOWN = "unknown" class Repository(CoreSysAttributes): """Repository in Supervisor.""" - slug: str = None - - def __init__(self, coresys, repository): + def __init__(self, coresys: CoreSys, repository: str): """Initialize repository object.""" - self.coresys = coresys - self.source = None - self.git = None + self.coresys: CoreSys = coresys + self.git: Optional[str] = None - if repository == REPOSITORY_LOCAL: - self.slug = repository - elif repository == REPOSITORY_CORE: - self.slug = repository + self.source: str = repository + if repository == StoreType.LOCAL: + self._slug = repository + self._type = StoreType.LOCAL + elif repository == StoreType.CORE: self.git = GitRepoHassIO(coresys) + self._slug = repository + self._type = StoreType.CORE else: - self.slug = get_hash_from_repository(repository) self.git = GitRepoCustom(coresys, repository) self.source = repository + self._slug = get_hash_from_repository(repository) + self._type = StoreType.GIT @property - def data(self): + def slug(self) -> str: + """Return repo slug.""" + return self._slug + + @property + def type(self) -> StoreType: + """Return type of the store.""" + return self._type + + @property + def data(self) -> Dict: """Return data struct repository.""" return self.sys_store.data.repositories.get(self.slug, {}) @property - def name(self): + def name(self) -> str: """Return name of repository.""" return self.data.get(ATTR_NAME, UNKNOWN) @property - def url(self): + def url(self) -> str: """Return URL of repository.""" return self.data.get(ATTR_URL, self.source) @property - def maintainer(self): + def maintainer(self) -> str: """Return url of repository.""" return self.data.get(ATTR_MAINTAINER, UNKNOWN) - async def load(self): + def validate(self) -> bool: + """Check if store is valid.""" + if self.type != StoreType.GIT: + return True + + repository_file = Path(self.git.path, "repository.json") + try: + SCHEMA_REPOSITORY_CONFIG(read_json_file(repository_file)) + except (JsonFileError, vol.Invalid): + return False + return True + + async def load(self) -> None: """Load addon repository.""" if not self.git: return await self.git.load() - async def update(self): + async def update(self) -> None: """Update add-on repository.""" - if not self.git: + if self.type == StoreType.LOCAL: return await self.git.pull() - async def remove(self): + async def remove(self) -> None: """Remove add-on repository.""" - if self.slug in (REPOSITORY_CORE, REPOSITORY_LOCAL): - raise APIError("Can't remove built-in repositories!") + if self.type != StoreType.GIT: + _LOGGER.error("Can't remove built-in repositories!") + raise StoreError() await self.git.remove() diff --git a/supervisor/utils/__init__.py b/supervisor/utils/__init__.py index ca7bc9d6c..c27e0feb9 100644 --- a/supervisor/utils/__init__.py +++ b/supervisor/utils/__init__.py @@ -3,6 +3,7 @@ import asyncio from datetime import datetime from ipaddress import IPv4Address import logging +from pathlib import Path import re import socket from typing import Any, Optional @@ -132,3 +133,26 @@ def get_message_from_exception_chain(err: Exception) -> str: return "" return get_message_from_exception_chain(err.__context__) + + +async def remove_folder(folder: Path, content_only: bool = False) -> None: + """Remove folder and reset privileged. + + Is needed to avoid issue with: + - CAP_DAC_OVERRIDE + - CAP_DAC_READ_SEARCH + """ + del_folder = f"{folder}" + "/{,.[!.],..?}*" if content_only else f"{folder}" + try: + proc = await asyncio.create_subprocess_exec( + "bash", "-c", f"rm -rf {del_folder}", stdout=asyncio.subprocess.DEVNULL + ) + + _, error_msg = await proc.communicate() + except OSError as err: + error_msg = str(err) + else: + if proc.returncode == 0: + return + + _LOGGER.error("Can't remove folder %s: %s", folder, error_msg) diff --git a/tests/conftest.py b/tests/conftest.py index 7015907ad..af35fdff9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -182,7 +182,6 @@ async def api_client(aiohttp_client, coresys: CoreSys): def store_manager(coresys: CoreSys): """Fixture for the store manager.""" sm_obj = coresys.store - sm_obj.repositories = set(coresys.config.addons_repositories) with patch("supervisor.store.data.StoreData.update", return_value=MagicMock()): yield sm_obj diff --git a/tests/resolution/fixup/test_store_execute_reload.py b/tests/resolution/fixup/test_store_execute_reload.py new file mode 100644 index 000000000..177bf5192 --- /dev/null +++ b/tests/resolution/fixup/test_store_execute_reload.py @@ -0,0 +1,32 @@ +"""Test evaluation base.""" +# pylint: disable=import-error,protected-access +from unittest.mock import AsyncMock + +from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion +from supervisor.resolution.fixups.store_execute_reload import FixupStoreExecuteReload + + +async def test_fixup(coresys: CoreSys): + """Test fixup.""" + store_execute_reload = FixupStoreExecuteReload(coresys) + + assert store_execute_reload.auto + + coresys.resolution.suggestions = Suggestion( + SuggestionType.EXECUTE_RELOAD, ContextType.STORE, reference="test" + ) + coresys.resolution.issues = Issue( + IssueType.FATAL_ERROR, ContextType.STORE, reference="test" + ) + + mock_repositorie = AsyncMock() + coresys.store.repositories["test"] = mock_repositorie + + await store_execute_reload() + + assert mock_repositorie.load.called + assert mock_repositorie.update.called + assert len(coresys.resolution.suggestions) == 0 + assert len(coresys.resolution.issues) == 0 diff --git a/tests/resolution/fixup/test_store_execute_remove.py b/tests/resolution/fixup/test_store_execute_remove.py new file mode 100644 index 000000000..75de4c5dc --- /dev/null +++ b/tests/resolution/fixup/test_store_execute_remove.py @@ -0,0 +1,32 @@ +"""Test evaluation base.""" +# pylint: disable=import-error,protected-access +from unittest.mock import AsyncMock + +from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion +from supervisor.resolution.fixups.store_execute_remove import FixupStoreExecuteRemove + + +async def test_fixup(coresys: CoreSys): + """Test fixup.""" + store_execute_remove = FixupStoreExecuteRemove(coresys) + + assert store_execute_remove.auto + + coresys.resolution.suggestions = Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference="test" + ) + coresys.resolution.issues = Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference="test" + ) + + mock_repositorie = AsyncMock() + coresys.store.repositories["test"] = mock_repositorie + + await store_execute_remove() + + assert mock_repositorie.remove.called + assert coresys.config.save_data.called + assert len(coresys.resolution.suggestions) == 0 + assert len(coresys.resolution.issues) == 0 diff --git a/tests/resolution/fixup/test_store_execute_reset.py b/tests/resolution/fixup/test_store_execute_reset.py new file mode 100644 index 000000000..7c2ff331b --- /dev/null +++ b/tests/resolution/fixup/test_store_execute_reset.py @@ -0,0 +1,38 @@ +"""Test evaluation base.""" +# pylint: disable=import-error,protected-access +from pathlib import Path +from unittest.mock import AsyncMock + +from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion +from supervisor.resolution.fixups.store_execute_reset import FixupStoreExecuteReset + + +async def test_fixup(coresys: CoreSys, tmp_path): + """Test fixup.""" + store_execute_reset = FixupStoreExecuteReset(coresys) + test_repo = Path(tmp_path, "test_repo") + + assert store_execute_reset.auto + + coresys.resolution.suggestions = Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.STORE, reference="test" + ) + coresys.resolution.issues = Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference="test" + ) + + test_repo.mkdir() + assert test_repo.exists() + + mock_repositorie = AsyncMock() + mock_repositorie.git.path = test_repo + coresys.store.repositories["test"] = mock_repositorie + + await store_execute_reset() + + assert not test_repo.exists() + assert mock_repositorie.load.called + assert len(coresys.resolution.suggestions) == 0 + assert len(coresys.resolution.issues) == 0 diff --git a/tests/store/test_custom_repository.py b/tests/store/test_custom_repository.py index 3c808a235..2ca1ff1e2 100644 --- a/tests/store/test_custom_repository.py +++ b/tests/store/test_custom_repository.py @@ -4,16 +4,35 @@ from unittest.mock import patch import pytest +from supervisor.resolution.const import SuggestionType +from supervisor.store import BUILTIN_REPOSITORIES + @pytest.mark.asyncio async def test_add_valid_repository(coresys, store_manager): """Test add custom repository.""" current = coresys.config.addons_repositories - with patch("supervisor.store.repository.Repository.load", return_value=True), patch( + with patch("supervisor.store.repository.Repository.load", return_value=None), patch( "pathlib.Path.read_text", return_value=json.dumps({"name": "Awesome repository"}), ): await store_manager.update_repositories(current + ["http://example.com"]) + assert store_manager.get_from_url("http://example.com").validate() + assert "http://example.com" in coresys.config.addons_repositories + + +@pytest.mark.asyncio +async def test_add_valid_repository_url(coresys, store_manager): + """Test add custom repository.""" + current = coresys.config.addons_repositories + with patch("supervisor.store.repository.Repository.load", return_value=None), patch( + "pathlib.Path.read_text", + return_value=json.dumps( + {"name": "Awesome repository", "url": "http://example2.com/docs"} + ), + ): + await store_manager.update_repositories(current + ["http://example.com"]) + assert store_manager.get_from_url("http://example.com").validate() assert "http://example.com" in coresys.config.addons_repositories @@ -21,9 +40,21 @@ async def test_add_valid_repository(coresys, store_manager): async def test_add_invalid_repository(coresys, store_manager): """Test add custom repository.""" current = coresys.config.addons_repositories - with patch("supervisor.store.repository.Repository.load", return_value=True), patch( + with patch("supervisor.store.repository.Repository.load", return_value=None), patch( "pathlib.Path.read_text", return_value="", ): await store_manager.update_repositories(current + ["http://example.com"]) - assert "http://example.com" not in coresys.config.addons_repositories + assert not store_manager.get_from_url("http://example.com").validate() + + assert "http://example.com" in coresys.config.addons_repositories + assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE + + +@pytest.mark.asyncio +async def test_preinstall_valid_repository(coresys, store_manager): + """Test add core repository valid.""" + with patch("supervisor.store.repository.Repository.load", return_value=None): + await store_manager.update_repositories(BUILTIN_REPOSITORIES) + assert store_manager.get("core").validate() + assert store_manager.get("local").validate()