mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-27 02:56:31 +00:00
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:
parent
841520b75e
commit
aa5297026f
@ -125,6 +125,8 @@ echo "Start Test-Env"
|
||||
start_docker
|
||||
trap "stop_docker" ERR
|
||||
|
||||
docker system prune -f
|
||||
|
||||
build_supervisor
|
||||
cleanup_lastboot
|
||||
cleanup_docker
|
||||
|
@ -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()
|
||||
|
@ -310,6 +310,10 @@ class StoreGitError(StoreError):
|
||||
"""Raise if something on git is happening."""
|
||||
|
||||
|
||||
class StoreNotFound(StoreError):
|
||||
"""Raise if slug is not known."""
|
||||
|
||||
|
||||
# JobManager
|
||||
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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."""
|
||||
|
@ -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:
|
||||
|
@ -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]
|
||||
|
50
supervisor/resolution/fixups/store_execute_reload.py
Normal file
50
supervisor/resolution/fixups/store_execute_reload.py
Normal 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
|
52
supervisor/resolution/fixups/store_execute_remove.py
Normal file
52
supervisor/resolution/fixups/store_execute_remove.py
Normal 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
|
52
supervisor/resolution/fixups/store_execute_reset.py
Normal file
52
supervisor/resolution/fixups/store_execute_reset.py
Normal 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
|
@ -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
|
||||
|
10
supervisor/store/const.py
Normal file
10
supervisor/store/const.py
Normal 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"
|
@ -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):
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
32
tests/resolution/fixup/test_store_execute_reload.py
Normal file
32
tests/resolution/fixup/test_store_execute_reload.py
Normal 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
|
32
tests/resolution/fixup/test_store_execute_remove.py
Normal file
32
tests/resolution/fixup/test_store_execute_remove.py
Normal 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
|
38
tests/resolution/fixup/test_store_execute_reset.py
Normal file
38
tests/resolution/fixup/test_store_execute_reset.py
Normal 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
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user