Handle Store suggestion (#2306)

* Handle Store suggestion

* Add fixup

* Add more fixup & list

* Enable fixups

* Add tests

* fix index

* fix break

* fix import

* Load it anyway

* Run suFix ccestion on load too

* fix error message

* fix error message

* Fix remove

* Finishing

* Add tests

* Fix error

* fix cleanup stale stuff

* Fix source

* use source as url

* add test for url

* Apply suggestions from code review

Co-authored-by: Joakim Sørensen <joasoe@gmail.com>

Co-authored-by: Joakim Sørensen <joasoe@gmail.com>
This commit is contained in:
Pascal Vizeli 2020-11-28 15:03:44 +01:00 committed by GitHub
parent 841520b75e
commit aa5297026f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 480 additions and 103 deletions

View File

@ -125,6 +125,8 @@ echo "Start Test-Env"
start_docker start_docker
trap "stop_docker" ERR trap "stop_docker" ERR
docker system prune -f
build_supervisor build_supervisor
cleanup_lastboot cleanup_lastboot
cleanup_docker cleanup_docker

View File

@ -6,6 +6,8 @@ from typing import Any, Awaitable, Dict
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
from supervisor.resolution.const import ContextType, SuggestionType
from ..const import ( from ..const import (
ATTR_ADDONS, ATTR_ADDONS,
ATTR_ADDONS_REPOSITORIES, ATTR_ADDONS_REPOSITORIES,
@ -143,10 +145,20 @@ class APISupervisor(CoreSysAttributes):
if ATTR_ADDONS_REPOSITORIES in body: if ATTR_ADDONS_REPOSITORIES in body:
new = set(body[ATTR_ADDONS_REPOSITORIES]) new = set(body[ATTR_ADDONS_REPOSITORIES])
await asyncio.shield(self.sys_store.update_repositories(new)) await asyncio.shield(self.sys_store.update_repositories(new))
if sorted(body[ATTR_ADDONS_REPOSITORIES]) != sorted(
self.sys_config.addons_repositories # Fix invalid repository
found_invalid = False
for suggestion in self.sys_resolution.suggestions:
if (
suggestion.type != SuggestionType.EXECUTE_REMOVE
and suggestion.context != ContextType
): ):
raise APIError("Not a valid add-on repository") 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_updater.save_data()
self.sys_config.save_data() self.sys_config.save_data()

View File

@ -310,6 +310,10 @@ class StoreGitError(StoreError):
"""Raise if something on git is happening.""" """Raise if something on git is happening."""
class StoreNotFound(StoreError):
"""Raise if slug is not known."""
# JobManager # JobManager

View File

@ -66,5 +66,5 @@ class SuggestionType(str, Enum):
EXECUTE_REPAIR = "execute_repair" EXECUTE_REPAIR = "execute_repair"
EXECUTE_RESET = "execute_reset" EXECUTE_RESET = "execute_reset"
EXECUTE_RELOAD = "execute_reload" EXECUTE_RELOAD = "execute_reload"
EXECUTE_REMOVE = "execute_remove"
REGISTRY_LOGIN = "registry_login" REGISTRY_LOGIN = "registry_login"
NEW_INITIALIZE = "new_initialize"

View File

@ -9,6 +9,9 @@ from ..coresys import CoreSys, CoreSysAttributes
from .fixups.base import FixupBase from .fixups.base import FixupBase
from .fixups.clear_full_snapshot import FixupClearFullSnapshot from .fixups.clear_full_snapshot import FixupClearFullSnapshot
from .fixups.create_full_snapshot import FixupCreateFullSnapshot 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__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -22,11 +25,20 @@ class ResolutionFixup(CoreSysAttributes):
self._create_full_snapshot = FixupCreateFullSnapshot(coresys) self._create_full_snapshot = FixupCreateFullSnapshot(coresys)
self._clear_full_snapshot = FixupClearFullSnapshot(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 @property
def all_fixes(self) -> List[FixupBase]: def all_fixes(self) -> List[FixupBase]:
"""Return a list of all fixups.""" """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: async def run_autofix(self) -> None:
"""Run all startup fixes.""" """Run all startup fixes."""

View File

@ -1,11 +1,10 @@
"""Baseclass for system fixup.""" """Baseclass for system fixup."""
from abc import ABC, abstractmethod, abstractproperty from abc import ABC, abstractmethod, abstractproperty
from contextlib import suppress
import logging import logging
from typing import Optional from typing import List, Optional
from ...coresys import CoreSys, CoreSysAttributes from ...coresys import CoreSys, CoreSysAttributes
from ...exceptions import ResolutionError, ResolutionFixupError from ...exceptions import ResolutionFixupError
from ..const import ContextType, IssueType, SuggestionType from ..const import ContextType, IssueType, SuggestionType
from ..data import Issue, Suggestion from ..data import Issue, Suggestion
@ -42,13 +41,12 @@ class FixupBase(ABC, CoreSysAttributes):
self.sys_resolution.dismiss_suggestion(fixing_suggestion) self.sys_resolution.dismiss_suggestion(fixing_suggestion)
if self.issue is None: # Cleanup issue
return for issue_type in self.issues:
issue = Issue(issue_type, self.context, fixing_suggestion.reference)
with suppress(ResolutionError): if issue not in self.sys_resolution.issues:
self.sys_resolution.dismiss_issue( continue
Issue(self.issue, self.context, fixing_suggestion.reference) self.sys_resolution.dismiss_issue(issue)
)
@abstractmethod @abstractmethod
async def process_fixup(self, reference: Optional[str] = None) -> None: async def process_fixup(self, reference: Optional[str] = None) -> None:
@ -65,9 +63,9 @@ class FixupBase(ABC, CoreSysAttributes):
"""Return a ContextType enum.""" """Return a ContextType enum."""
@property @property
def issue(self) -> Optional[IssueType]: def issues(self) -> List[IssueType]:
"""Return a IssueType enum.""" """Return a IssueType enum list."""
return None return []
@property @property
def auto(self) -> bool: def auto(self) -> bool:

View File

@ -1,6 +1,6 @@
"""Helpers to check and fix issues with free space.""" """Helpers to check and fix issues with free space."""
import logging import logging
from typing import Optional from typing import List, Optional
from ...const import SNAPSHOT_FULL from ...const import SNAPSHOT_FULL
from ..const import MINIMUM_FULL_SNAPSHOTS, ContextType, IssueType, SuggestionType from ..const import MINIMUM_FULL_SNAPSHOTS, ContextType, IssueType, SuggestionType
@ -36,6 +36,6 @@ class FixupClearFullSnapshot(FixupBase):
return ContextType.SYSTEM return ContextType.SYSTEM
@property @property
def issue(self) -> IssueType: def issues(self) -> List[IssueType]:
"""Return a IssueType enum.""" """Return a IssueType enum list."""
return IssueType.FREE_SPACE return [IssueType.FREE_SPACE]

View File

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

View File

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

View File

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

View File

@ -1,25 +1,20 @@
"""Add-on Store handler.""" """Add-on Store handler."""
import asyncio import asyncio
import logging import logging
from pathlib import Path
from typing import Dict, List 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 ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import JsonFileError, StoreGitError from ..exceptions import StoreGitError, StoreNotFound
from ..jobs.decorator import Job, JobCondition from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType
from .addon import AddonStore from .addon import AddonStore
from .const import StoreType
from .data import StoreData from .data import StoreData
from .repository import Repository from .repository import Repository
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
BUILTIN_REPOSITORIES = {REPOSITORY_CORE, REPOSITORY_LOCAL} BUILTIN_REPOSITORIES = {StoreType.CORE.value, StoreType.LOCAL.value}
class StoreManager(CoreSysAttributes): class StoreManager(CoreSysAttributes):
@ -36,6 +31,20 @@ class StoreManager(CoreSysAttributes):
"""Return list of add-on repositories.""" """Return list of add-on repositories."""
return list(self.repositories.values()) 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: async def load(self) -> None:
"""Start up add-on management.""" """Start up add-on management."""
self.data.update() self.data.update()
@ -48,7 +57,7 @@ class StoreManager(CoreSysAttributes):
async def reload(self) -> None: async def reload(self) -> None:
"""Update add-ons from repository and reload list.""" """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: if tasks:
await asyncio.wait(tasks) await asyncio.wait(tasks)
@ -61,35 +70,33 @@ class StoreManager(CoreSysAttributes):
"""Add a new custom repository.""" """Add a new custom repository."""
job = self.sys_jobs.get_job("storemanager_update_repositories") job = self.sys_jobs.get_job("storemanager_update_repositories")
new_rep = set(list_repositories) new_rep = set(list_repositories)
old_rep = set(self.repositories) old_rep = {repository.source for repository in self.all}
# add new repository # add new repository
async def _add_repository(url: str, step: int): async def _add_repository(url: str, step: int):
"""Add a repository.""" """Add a repository."""
job.update(progress=job.progress + step, stage=f"Checking {url} started") job.update(progress=job.progress + step, stage=f"Checking {url} started")
repository = Repository(self.coresys, url) repository = Repository(self.coresys, url)
# Load the repository
try: try:
await repository.load() await repository.load()
except StoreGitError: except StoreGitError:
_LOGGER.error("Can't load data from repository %s", url) _LOGGER.error("Can't load data from repository %s", url)
return else:
if not repository.validate():
# don't add built-in repository to config _LOGGER.error("%s is not a valid add-on repository", url)
if url not in BUILTIN_REPOSITORIES: self.sys_resolution.create_issue(
# Verify that it is a add-on repository IssueType.CORRUPT_REPOSITORY,
repository_file = Path(repository.git.path, "repository.json") ContextType.STORE,
try: reference=repository.slug,
await self.sys_run_in_executor( suggestions=[SuggestionType.EXECUTE_REMOVE],
SCHEMA_REPOSITORY_CONFIG, read_json_file(repository_file)
) )
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) # Add Repository to list
if repository.type == StoreType.GIT:
self.repositories[url] = repository self.sys_config.add_addon_repository(repository.source)
self.repositories[repository.slug] = repository
job.update(progress=10, stage="Check repositories") job.update(progress=10, stage="Check repositories")
repos = new_rep - old_rep repos = new_rep - old_rep
@ -97,9 +104,10 @@ class StoreManager(CoreSysAttributes):
if tasks: if tasks:
await asyncio.wait(tasks) await asyncio.wait(tasks)
# del new repository # Delete stale repositories
for url in old_rep - new_rep - BUILTIN_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) self.sys_config.drop_addon_repository(url)
# update data # update data

10
supervisor/store/const.py Normal file
View File

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

View File

@ -3,7 +3,6 @@ import asyncio
import functools as ft import functools as ft
import logging import logging
from pathlib import Path from pathlib import Path
import shutil
from typing import Dict, Optional from typing import Dict, Optional
import git import git
@ -13,6 +12,7 @@ from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import StoreGitError from ..exceptions import StoreGitError
from ..jobs.decorator import Job, JobCondition from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType from ..resolution.const import ContextType, IssueType, SuggestionType
from ..utils import remove_folder
from ..validate import RE_REPOSITORY from ..validate import RE_REPOSITORY
from .utils import get_hash_from_repository from .utils import get_hash_from_repository
@ -30,7 +30,6 @@ class GitRepo(CoreSysAttributes):
self.lock: asyncio.Lock = asyncio.Lock() self.lock: asyncio.Lock = asyncio.Lock()
self.data: Dict[str, str] = RE_REPOSITORY.match(url).groupdict() self.data: Dict[str, str] = RE_REPOSITORY.match(url).groupdict()
self.slug: str = url
@property @property
def url(self) -> str: def url(self) -> str:
@ -59,11 +58,12 @@ class GitRepo(CoreSysAttributes):
git.NoSuchPathError, git.NoSuchPathError,
git.GitCommandError, git.GitCommandError,
) as err: ) 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( self.sys_resolution.create_issue(
IssueType.FATAL_ERROR, IssueType.FATAL_ERROR,
ContextType.STORE, ContextType.STORE,
reference=self.slug, reference=self.path.stem,
suggestions=[SuggestionType.EXECUTE_RESET],
) )
raise StoreGitError() from err raise StoreGitError() from err
@ -77,7 +77,7 @@ class GitRepo(CoreSysAttributes):
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY, IssueType.CORRUPT_REPOSITORY,
ContextType.STORE, ContextType.STORE,
reference=self.slug, reference=self.path.stem,
suggestions=[SuggestionType.EXECUTE_RESET], suggestions=[SuggestionType.EXECUTE_RESET],
) )
raise StoreGitError() from err raise StoreGitError() from err
@ -114,8 +114,8 @@ class GitRepo(CoreSysAttributes):
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.FATAL_ERROR, IssueType.FATAL_ERROR,
ContextType.STORE, ContextType.STORE,
reference=self.slug, reference=self.path.stem,
suggestions=[SuggestionType.NEW_INITIALIZE], suggestions=[SuggestionType.EXECUTE_RELOAD],
) )
raise StoreGitError() from err raise StoreGitError() from err
@ -156,8 +156,8 @@ class GitRepo(CoreSysAttributes):
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY, IssueType.CORRUPT_REPOSITORY,
ContextType.STORE, ContextType.STORE,
reference=self.slug, reference=self.path.stem,
suggestions=[SuggestionType.EXECUTE_RELOAD], suggestions=[SuggestionType.EXECUTE_RESET],
) )
raise StoreGitError() from err raise StoreGitError() from err
@ -169,14 +169,7 @@ class GitRepo(CoreSysAttributes):
if not self.path.is_dir(): if not self.path.is_dir():
return return
await remove_folder(self.path)
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)
)
class GitRepoHassIO(GitRepo): class GitRepoHassIO(GitRepo):

View File

@ -1,75 +1,103 @@
"""Represent a Supervisor repository.""" """Represent a Supervisor repository."""
from ..const import ( import logging
ATTR_MAINTAINER, from pathlib import Path
ATTR_NAME, from typing import Dict, Optional
ATTR_URL,
REPOSITORY_CORE, import voluptuous as vol
REPOSITORY_LOCAL,
) from ..const import ATTR_MAINTAINER, ATTR_NAME, ATTR_URL
from ..coresys import CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import APIError from ..exceptions import JsonFileError, StoreError
from ..utils.json import read_json_file
from .const import StoreType
from .git import GitRepoCustom, GitRepoHassIO from .git import GitRepoCustom, GitRepoHassIO
from .utils import get_hash_from_repository from .utils import get_hash_from_repository
from .validate import SCHEMA_REPOSITORY_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
UNKNOWN = "unknown" UNKNOWN = "unknown"
class Repository(CoreSysAttributes): class Repository(CoreSysAttributes):
"""Repository in Supervisor.""" """Repository in Supervisor."""
slug: str = None def __init__(self, coresys: CoreSys, repository: str):
def __init__(self, coresys, repository):
"""Initialize repository object.""" """Initialize repository object."""
self.coresys = coresys self.coresys: CoreSys = coresys
self.source = None self.git: Optional[str] = None
self.git = None
if repository == REPOSITORY_LOCAL: self.source: str = repository
self.slug = repository if repository == StoreType.LOCAL:
elif repository == REPOSITORY_CORE: self._slug = repository
self.slug = repository self._type = StoreType.LOCAL
elif repository == StoreType.CORE:
self.git = GitRepoHassIO(coresys) self.git = GitRepoHassIO(coresys)
self._slug = repository
self._type = StoreType.CORE
else: else:
self.slug = get_hash_from_repository(repository)
self.git = GitRepoCustom(coresys, repository) self.git = GitRepoCustom(coresys, repository)
self.source = repository self.source = repository
self._slug = get_hash_from_repository(repository)
self._type = StoreType.GIT
@property @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 data struct repository."""
return self.sys_store.data.repositories.get(self.slug, {}) return self.sys_store.data.repositories.get(self.slug, {})
@property @property
def name(self): def name(self) -> str:
"""Return name of repository.""" """Return name of repository."""
return self.data.get(ATTR_NAME, UNKNOWN) return self.data.get(ATTR_NAME, UNKNOWN)
@property @property
def url(self): def url(self) -> str:
"""Return URL of repository.""" """Return URL of repository."""
return self.data.get(ATTR_URL, self.source) return self.data.get(ATTR_URL, self.source)
@property @property
def maintainer(self): def maintainer(self) -> str:
"""Return url of repository.""" """Return url of repository."""
return self.data.get(ATTR_MAINTAINER, UNKNOWN) 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.""" """Load addon repository."""
if not self.git: if not self.git:
return return
await self.git.load() await self.git.load()
async def update(self): async def update(self) -> None:
"""Update add-on repository.""" """Update add-on repository."""
if not self.git: if self.type == StoreType.LOCAL:
return return
await self.git.pull() await self.git.pull()
async def remove(self): async def remove(self) -> None:
"""Remove add-on repository.""" """Remove add-on repository."""
if self.slug in (REPOSITORY_CORE, REPOSITORY_LOCAL): if self.type != StoreType.GIT:
raise APIError("Can't remove built-in repositories!") _LOGGER.error("Can't remove built-in repositories!")
raise StoreError()
await self.git.remove() await self.git.remove()

View File

@ -3,6 +3,7 @@ import asyncio
from datetime import datetime from datetime import datetime
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
from pathlib import Path
import re import re
import socket import socket
from typing import Any, Optional from typing import Any, Optional
@ -132,3 +133,26 @@ def get_message_from_exception_chain(err: Exception) -> str:
return "" return ""
return get_message_from_exception_chain(err.__context__) 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)

View File

@ -182,7 +182,6 @@ async def api_client(aiohttp_client, coresys: CoreSys):
def store_manager(coresys: CoreSys): def store_manager(coresys: CoreSys):
"""Fixture for the store manager.""" """Fixture for the store manager."""
sm_obj = coresys.store sm_obj = coresys.store
sm_obj.repositories = set(coresys.config.addons_repositories)
with patch("supervisor.store.data.StoreData.update", return_value=MagicMock()): with patch("supervisor.store.data.StoreData.update", return_value=MagicMock()):
yield sm_obj yield sm_obj

View File

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

View File

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

View File

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

View File

@ -4,16 +4,35 @@ from unittest.mock import patch
import pytest import pytest
from supervisor.resolution.const import SuggestionType
from supervisor.store import BUILTIN_REPOSITORIES
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_valid_repository(coresys, store_manager): async def test_add_valid_repository(coresys, store_manager):
"""Test add custom repository.""" """Test add custom repository."""
current = coresys.config.addons_repositories 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", "pathlib.Path.read_text",
return_value=json.dumps({"name": "Awesome repository"}), return_value=json.dumps({"name": "Awesome repository"}),
): ):
await store_manager.update_repositories(current + ["http://example.com"]) 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 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): async def test_add_invalid_repository(coresys, store_manager):
"""Test add custom repository.""" """Test add custom repository."""
current = coresys.config.addons_repositories 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", "pathlib.Path.read_text",
return_value="", return_value="",
): ):
await store_manager.update_repositories(current + ["http://example.com"]) 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()