APIs for adding/removing an addon repository (#3649)

* APIs for adding/removing an addon repository

* Misunderstood addons.store, fixed usage
This commit is contained in:
Mike Degatano 2022-05-23 03:16:42 -04:00 committed by GitHub
parent 6e017a36c4
commit 138fd7eec9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 585 additions and 120 deletions

View File

@ -539,6 +539,10 @@ class RestAPI(CoreSysAttributes):
"/store/repositories/{repository}", "/store/repositories/{repository}",
api_store.repositories_repository_info, api_store.repositories_repository_info,
), ),
web.post("/store/repositories", api_store.add_repository),
web.delete(
"/store/repositories/{repository}", api_store.remove_repository
),
] ]
) )

View File

@ -35,6 +35,7 @@ from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APIForbidden from ..exceptions import APIError, APIForbidden
from ..store.addon import AddonStore from ..store.addon import AddonStore
from ..store.repository import Repository from ..store.repository import Repository
from ..validate import validate_repository
SCHEMA_UPDATE = vol.Schema( SCHEMA_UPDATE = vol.Schema(
{ {
@ -42,6 +43,10 @@ SCHEMA_UPDATE = vol.Schema(
} }
) )
SCHEMA_ADD_REPOSITORY = vol.Schema(
{vol.Required(ATTR_REPOSITORY): vol.All(str, validate_repository)}
)
class APIStore(CoreSysAttributes): class APIStore(CoreSysAttributes):
"""Handle RESTful API for store functions.""" """Handle RESTful API for store functions."""
@ -173,3 +178,15 @@ class APIStore(CoreSysAttributes):
"""Return repository information.""" """Return repository information."""
repository: Repository = self._extract_repository(request) repository: Repository = self._extract_repository(request)
return self._generate_repository_information(repository) return self._generate_repository_information(repository)
@api_process
async def add_repository(self, request: web.Request):
"""Add repository to the store."""
body = await api_validate(SCHEMA_ADD_REPOSITORY, request)
await asyncio.shield(self.sys_store.add_repository(body[ATTR_REPOSITORY]))
@api_process
async def remove_repository(self, request: web.Request):
"""Remove repository from the store."""
repository: Repository = self._extract_repository(request)
await asyncio.shield(self.sys_store.remove_repository(repository))

View File

@ -6,8 +6,6 @@ from typing import Any, Awaitable
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,
@ -155,27 +153,15 @@ class APISupervisor(CoreSysAttributes):
if ATTR_FORCE_SECURITY in body: if ATTR_FORCE_SECURITY in body:
self.sys_security.force = body[ATTR_FORCE_SECURITY] self.sys_security.force = body[ATTR_FORCE_SECURITY]
if ATTR_ADDONS_REPOSITORIES in body: # Save changes before processing addons in case of errors
new = set(body[ATTR_ADDONS_REPOSITORIES])
await asyncio.shield(self.sys_store.update_repositories(new))
# 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_updater.save_data()
self.sys_config.save_data() self.sys_config.save_data()
if ATTR_ADDONS_REPOSITORIES in body:
await asyncio.shield(
self.sys_store.update_repositories(set(body[ATTR_ADDONS_REPOSITORIES]))
)
await self.sys_resolution.evaluate.evaluate_system() await self.sys_resolution.evaluate.evaluate_system()
@api_process @api_process

View File

@ -508,7 +508,7 @@ class Backup(CoreSysAttributes):
if not replace: if not replace:
new_list.update(self.sys_config.addons_repositories) new_list.update(self.sys_config.addons_repositories)
await self.sys_store.update_repositories(list(new_list)) await self.sys_store.update_repositories(list(new_list), add_with_errors=True)
def store_dockerconfig(self): def store_dockerconfig(self):
"""Store the configuration for Docker.""" """Store the configuration for Docker."""

View File

@ -454,6 +454,10 @@ class StoreGitError(StoreError):
"""Raise if something on git is happening.""" """Raise if something on git is happening."""
class StoreGitCloneError(StoreGitError):
"""Raise if error occurred while cloning repository."""
class StoreNotFound(StoreError): class StoreNotFound(StoreError):
"""Raise if slug is not known.""" """Raise if slug is not known."""
@ -462,6 +466,10 @@ class StoreJobError(StoreError, JobException):
"""Raise on job error with git.""" """Raise on job error with git."""
class StoreInvalidAddonRepo(StoreError):
"""Raise on invalid addon repo."""
# Backup # Backup

View File

@ -56,4 +56,4 @@ class FixupStoreExecuteRemove(FixupBase):
@property @property
def auto(self) -> bool: def auto(self) -> bool:
"""Return if a fixup can be apply as auto fix.""" """Return if a fixup can be apply as auto fix."""
return True return False

View File

@ -4,7 +4,14 @@ import logging
from ..const import URL_HASSIO_ADDONS from ..const import URL_HASSIO_ADDONS
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import StoreError, StoreGitError, StoreJobError, StoreNotFound from ..exceptions import (
StoreError,
StoreGitCloneError,
StoreGitError,
StoreInvalidAddonRepo,
StoreJobError,
StoreNotFound,
)
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 .addon import AddonStore from .addon import AddonStore
@ -53,7 +60,7 @@ class StoreManager(CoreSysAttributes):
repositories = set(self.sys_config.addons_repositories) | BUILTIN_REPOSITORIES repositories = set(self.sys_config.addons_repositories) | BUILTIN_REPOSITORIES
# Init custom repositories and load add-ons # Init custom repositories and load add-ons
await self.update_repositories(repositories) await self.update_repositories(repositories, add_with_errors=True)
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."""
@ -66,34 +73,63 @@ class StoreManager(CoreSysAttributes):
self._read_addons() self._read_addons()
@Job(conditions=[JobCondition.INTERNET_SYSTEM]) @Job(conditions=[JobCondition.INTERNET_SYSTEM])
async def update_repositories(self, list_repositories): async def add_repository(
"""Add a new custom repository.""" self, url: str, *, persist: bool = True, add_with_errors: bool = False
new_rep = set(list_repositories) ):
old_rep = {repository.source for repository in self.all}
# add new repository
async def _add_repository(url: str):
"""Add a repository.""" """Add a repository."""
if url == URL_HASSIO_ADDONS: if url == URL_HASSIO_ADDONS:
url = StoreType.CORE url = StoreType.CORE
repository = Repository(self.coresys, url) repository = Repository(self.coresys, url)
if repository.slug in self.repositories:
raise StoreError(f"Can't add {url}, already in the store", _LOGGER.error)
# Load the repository # Load the repository
try: try:
await repository.load() await repository.load()
except StoreGitError: except StoreGitCloneError as err:
_LOGGER.error("Can't load data from repository %s", url) _LOGGER.error("Can't retrieve data from %s due to %s", url, err)
except StoreJobError: if add_with_errors:
_LOGGER.warning("Skip update to later for %s", repository.slug) self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference=repository.slug,
suggestions=[SuggestionType.EXECUTE_REMOVE],
)
else:
await repository.remove()
raise err
except StoreGitError as err:
_LOGGER.error("Can't load data from repository %s due to %s", url, err)
if add_with_errors:
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference=repository.slug,
suggestions=[SuggestionType.EXECUTE_RESET],
)
else:
await repository.remove()
raise err
except StoreJobError as err:
_LOGGER.error("Can't add repository %s due to %s", url, err)
if add_with_errors:
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.FATAL_ERROR, IssueType.FATAL_ERROR,
ContextType.STORE, ContextType.STORE,
reference=repository.slug, reference=repository.slug,
suggestions=[SuggestionType.EXECUTE_RELOAD], suggestions=[SuggestionType.EXECUTE_RELOAD],
) )
else:
await repository.remove()
raise err
else: else:
if not repository.validate(): if not repository.validate():
if add_with_errors:
_LOGGER.error("%s is not a valid add-on repository", url) _LOGGER.error("%s is not a valid add-on repository", url)
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY, IssueType.CORRUPT_REPOSITORY,
@ -101,34 +137,76 @@ class StoreManager(CoreSysAttributes):
reference=repository.slug, reference=repository.slug,
suggestions=[SuggestionType.EXECUTE_REMOVE], suggestions=[SuggestionType.EXECUTE_REMOVE],
) )
else:
await repository.remove()
raise StoreInvalidAddonRepo(
f"{url} is not a valid add-on repository", logger=_LOGGER.error
)
# Add Repository to list # Add Repository to list
if repository.type == StoreType.GIT: if repository.type == StoreType.GIT:
self.sys_config.add_addon_repository(repository.source) self.sys_config.add_addon_repository(repository.source)
self.repositories[repository.slug] = repository self.repositories[repository.slug] = repository
repos = new_rep - old_rep # Persist changes
tasks = [self.sys_create_task(_add_repository(url)) for url in repos] if persist:
if tasks: await self.data.update()
await asyncio.wait(tasks) self._read_addons()
# Delete stale repositories async def remove_repository(self, repository: Repository, *, persist: bool = True):
for url in old_rep - new_rep - BUILTIN_REPOSITORIES: """Remove a repository."""
repository = self.get_from_url(url) if repository.type != StoreType.GIT:
if repository.slug in ( raise StoreInvalidAddonRepo(
addon.repository for addon in self.sys_addons.installed "Can't remove built-in repositories!", logger=_LOGGER.error
): )
if repository.slug in (addon.repository for addon in self.sys_addons.installed):
raise StoreError( raise StoreError(
f"Can't remove '{repository.source}'. It's used by installed add-ons", f"Can't remove '{repository.source}'. It's used by installed add-ons",
logger=_LOGGER.error, logger=_LOGGER.error,
) )
await self.repositories.pop(repository.slug).remove() await self.repositories.pop(repository.slug).remove()
self.sys_config.drop_addon_repository(url) self.sys_config.drop_addon_repository(repository.url)
# update data if persist:
await self.data.update() await self.data.update()
self._read_addons() self._read_addons()
@Job(conditions=[JobCondition.INTERNET_SYSTEM])
async def update_repositories(
self, list_repositories, *, add_with_errors: bool = False
):
"""Add a new custom repository."""
new_rep = set(list_repositories)
old_rep = {repository.source for repository in self.all}
# Add new repositories
add_errors = await asyncio.gather(
*[
self.add_repository(url, persist=False, add_with_errors=add_with_errors)
for url in new_rep - old_rep
],
return_exceptions=True,
)
# Delete stale repositories
remove_errors = await asyncio.gather(
*[
self.remove_repository(self.get_from_url(url), persist=False)
for url in old_rep - new_rep - BUILTIN_REPOSITORIES
],
return_exceptions=True,
)
# Always update data, even there are errors, some changes may have succeeded
await self.data.update()
self._read_addons()
# Raise the first error we found (if any)
for error in add_errors + remove_errors:
if error:
raise error
def _read_addons(self) -> None: def _read_addons(self) -> None:
"""Reload add-ons inside store.""" """Reload add-ons inside store."""
all_addons = set(self.data.addons) all_addons = set(self.data.addons)

View File

@ -9,7 +9,7 @@ import git
from ..const import ATTR_BRANCH, ATTR_URL, URL_HASSIO_ADDONS from ..const import ATTR_BRANCH, ATTR_URL, URL_HASSIO_ADDONS
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import StoreGitError, StoreJobError from ..exceptions import StoreGitCloneError, StoreGitError, StoreJobError
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 ..utils import remove_folder
@ -65,12 +65,6 @@ class GitRepo(CoreSysAttributes):
git.GitCommandError, git.GitCommandError,
) as err: ) as err:
_LOGGER.error("Can't load %s", self.path) _LOGGER.error("Can't load %s", self.path)
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference=self.path.stem,
suggestions=[SuggestionType.EXECUTE_RESET],
)
raise StoreGitError() from err raise StoreGitError() from err
# Fix possible corruption # Fix possible corruption
@ -80,12 +74,6 @@ class GitRepo(CoreSysAttributes):
await self.sys_run_in_executor(self.repo.git.execute, ["git", "fsck"]) await self.sys_run_in_executor(self.repo.git.execute, ["git", "fsck"])
except git.GitCommandError as err: except git.GitCommandError as err:
_LOGGER.error("Integrity check on %s failed: %s.", self.path, err) _LOGGER.error("Integrity check on %s failed: %s.", self.path, err)
self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY,
ContextType.STORE,
reference=self.path.stem,
suggestions=[SuggestionType.EXECUTE_RESET],
)
raise StoreGitError() from err raise StoreGitError() from err
@Job( @Job(
@ -120,17 +108,7 @@ class GitRepo(CoreSysAttributes):
git.GitCommandError, git.GitCommandError,
) as err: ) as err:
_LOGGER.error("Can't clone %s repository: %s.", self.url, err) _LOGGER.error("Can't clone %s repository: %s.", self.url, err)
self.sys_resolution.create_issue( raise StoreGitCloneError() from err
IssueType.FATAL_ERROR,
ContextType.STORE,
reference=self.path.stem,
suggestions=[
SuggestionType.EXECUTE_RELOAD
if self.builtin
else SuggestionType.EXECUTE_REMOVE
],
)
raise StoreGitError() from err
@Job( @Job(
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM], conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM],

View File

@ -10,7 +10,7 @@ from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ConfigurationFileError, StoreError from ..exceptions import ConfigurationFileError, StoreError
from ..utils.common import read_json_or_yaml_file from ..utils.common import read_json_or_yaml_file
from .const import StoreType from .const import StoreType
from .git import GitRepoCustom, GitRepoHassIO from .git import GitRepo, GitRepoCustom, GitRepoHassIO
from .utils import get_hash_from_repository from .utils import get_hash_from_repository
from .validate import SCHEMA_REPOSITORY_CONFIG from .validate import SCHEMA_REPOSITORY_CONFIG
@ -24,7 +24,7 @@ class Repository(CoreSysAttributes):
def __init__(self, coresys: CoreSys, repository: str): def __init__(self, coresys: CoreSys, repository: str):
"""Initialize repository object.""" """Initialize repository object."""
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
self.git: Optional[str] = None self.git: Optional[GitRepo] = None
self.source: str = repository self.source: str = repository
if repository == StoreType.LOCAL: if repository == StoreType.LOCAL:

View File

@ -1,10 +1,15 @@
"""Test Store API.""" """Test Store API."""
from unittest.mock import patch
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
import pytest import pytest
from supervisor.coresys import CoreSys
from supervisor.store.addon import AddonStore from supervisor.store.addon import AddonStore
from supervisor.store.repository import Repository from supervisor.store.repository import Repository
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_api_store( async def test_api_store(
@ -65,3 +70,28 @@ async def test_api_store_repositories_repository(
result = await resp.json() result = await resp.json()
assert result["data"]["slug"] == repository.slug assert result["data"]["slug"] == repository.slug
async def test_api_store_add_repository(api_client: TestClient, coresys: CoreSys):
"""Test POST /store/repositories REST API."""
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"supervisor.store.repository.Repository.validate", return_value=True
):
response = await api_client.post(
"/store/repositories", json={"repository": REPO_URL}
)
assert response.status == 200
assert REPO_URL in coresys.config.addons_repositories
assert isinstance(coresys.store.get_from_url(REPO_URL), Repository)
async def test_api_store_remove_repository(
api_client: TestClient, coresys: CoreSys, repository: Repository
):
"""Test DELETE /store/repositories/{repository} REST API."""
response = await api_client.delete(f"/store/repositories/{repository.slug}")
assert response.status == 200
assert repository.url not in coresys.config.addons_repositories
assert repository.slug not in coresys.store.repositories

View File

@ -1,15 +1,99 @@
"""Test Supervisor API.""" """Test Supervisor API."""
# pylint: disable=protected-access # pylint: disable=protected-access
from unittest.mock import patch
from aiohttp.test_utils import TestClient
import pytest import pytest
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.exceptions import StoreGitError, StoreNotFound
from supervisor.store.repository import Repository
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_api_supervisor_options_debug(api_client, coresys: CoreSys): async def test_api_supervisor_options_debug(api_client: TestClient, coresys: CoreSys):
"""Test security options force security.""" """Test security options force security."""
assert not coresys.config.debug assert not coresys.config.debug
await api_client.post("/supervisor/options", json={"debug": True}) await api_client.post("/supervisor/options", json={"debug": True})
assert coresys.config.debug assert coresys.config.debug
async def test_api_supervisor_options_add_repository(
api_client: TestClient, coresys: CoreSys
):
"""Test add a repository via POST /supervisor/options REST API."""
assert REPO_URL not in coresys.config.addons_repositories
with pytest.raises(StoreNotFound):
coresys.store.get_from_url(REPO_URL)
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"supervisor.store.repository.Repository.validate", return_value=True
):
response = await api_client.post(
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
)
assert response.status == 200
assert REPO_URL in coresys.config.addons_repositories
assert isinstance(coresys.store.get_from_url(REPO_URL), Repository)
async def test_api_supervisor_options_remove_repository(
api_client: TestClient, coresys: CoreSys, repository: Repository
):
"""Test remove a repository via POST /supervisor/options REST API."""
assert repository.url in coresys.config.addons_repositories
assert repository.slug in coresys.store.repositories
response = await api_client.post(
"/supervisor/options", json={"addons_repositories": []}
)
assert response.status == 200
assert repository.url not in coresys.config.addons_repositories
assert repository.slug not in coresys.store.repositories
@pytest.mark.parametrize("git_error", [None, StoreGitError()])
async def test_api_supervisor_options_repositories_skipped_on_error(
api_client: TestClient, coresys: CoreSys, git_error: StoreGitError
):
"""Test repositories skipped on error via POST /supervisor/options REST API."""
with patch(
"supervisor.store.repository.Repository.load", side_effect=git_error
), patch("supervisor.store.repository.Repository.validate", return_value=False):
response = await api_client.post(
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
)
assert response.status == 400
assert len(coresys.resolution.suggestions) == 0
assert REPO_URL not in coresys.config.addons_repositories
with pytest.raises(StoreNotFound):
coresys.store.get_from_url(REPO_URL)
async def test_api_supervisor_options_repo_error_with_config_change(
api_client: TestClient, coresys: CoreSys
):
"""Test config change with add repository error via POST /supervisor/options REST API."""
assert not coresys.config.debug
with patch(
"supervisor.store.repository.Repository.load", side_effect=StoreGitError()
):
response = await api_client.post(
"/supervisor/options",
json={"debug": True, "addons_repositories": [REPO_URL]},
)
assert response.status == 400
assert REPO_URL not in coresys.config.addons_repositories
assert coresys.config.debug
coresys.updater.save_data.assert_called_once()
coresys.config.save_data.assert_called_once()

View File

@ -316,12 +316,18 @@ def store_addon(coresys: CoreSys, tmp_path, repository):
async def repository(coresys: CoreSys): async def repository(coresys: CoreSys):
"""Repository fixture.""" """Repository fixture."""
coresys.config.drop_addon_repository("https://github.com/hassio-addons/repository") coresys.config.drop_addon_repository("https://github.com/hassio-addons/repository")
coresys.config.drop_addon_repository(
"https://github.com/esphome/home-assistant-addon"
)
await coresys.store.load() await coresys.store.load()
repository_obj = Repository( repository_obj = Repository(
coresys, "https://github.com/awesome-developer/awesome-repo" coresys, "https://github.com/awesome-developer/awesome-repo"
) )
coresys.store.repositories[repository_obj.slug] = repository_obj coresys.store.repositories[repository_obj.slug] = repository_obj
coresys.config.add_addon_repository(
"https://github.com/awesome-developer/awesome-repo"
)
yield repository_obj yield repository_obj

View File

@ -12,7 +12,7 @@ async def test_fixup(coresys: CoreSys):
"""Test fixup.""" """Test fixup."""
store_execute_remove = FixupStoreExecuteRemove(coresys) store_execute_remove = FixupStoreExecuteRemove(coresys)
assert store_execute_remove.auto assert store_execute_remove.auto is False
coresys.resolution.suggestions = Suggestion( coresys.resolution.suggestions = Suggestion(
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference="test" SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference="test"

View File

@ -5,70 +5,178 @@ from unittest.mock import patch
import pytest import pytest
from supervisor.addons.addon import Addon from supervisor.addons.addon import Addon
from supervisor.exceptions import StoreError from supervisor.coresys import CoreSys
from supervisor.exceptions import (
StoreError,
StoreGitCloneError,
StoreGitError,
StoreNotFound,
)
from supervisor.resolution.const import SuggestionType from supervisor.resolution.const import SuggestionType
from supervisor.store import BUILTIN_REPOSITORIES from supervisor.store import BUILTIN_REPOSITORIES, StoreManager
from supervisor.store.addon import AddonStore
from supervisor.store.repository import Repository
@pytest.mark.asyncio @pytest.mark.parametrize("use_update", [True, False])
async def test_add_valid_repository(coresys, store_manager): async def test_add_valid_repository(
coresys: CoreSys, store_manager: StoreManager, use_update: bool
):
"""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=None), patch( with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"supervisor.utils.common.read_yaml_file", "supervisor.utils.common.read_yaml_file",
return_value={"name": "Awesome repository"}, return_value={"name": "Awesome repository"},
), patch("pathlib.Path.exists", return_value=True): ), patch("pathlib.Path.exists", return_value=True):
if use_update:
await store_manager.update_repositories(current + ["http://example.com"]) await store_manager.update_repositories(current + ["http://example.com"])
else:
await store_manager.add_repository("http://example.com")
assert store_manager.get_from_url("http://example.com").validate() 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
@pytest.mark.asyncio @pytest.mark.parametrize("use_update", [True, False])
async def test_add_valid_repository_url(coresys, store_manager): async def test_add_invalid_repository(
"""Test add custom repository.""" coresys: CoreSys, store_manager: StoreManager, use_update: bool
current = coresys.config.addons_repositories ):
with patch("supervisor.store.repository.Repository.load", return_value=None), patch( """Test add invalid custom repository."""
"supervisor.utils.common.read_yaml_file",
return_value={"name": "Awesome repository"},
), patch("pathlib.Path.exists", return_value=True):
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_invalid_repository(coresys, store_manager):
"""Test add custom repository."""
current = coresys.config.addons_repositories current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", return_value=None), 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"]) if use_update:
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
)
else:
await store_manager.add_repository(
"http://example.com", add_with_errors=True
)
assert not store_manager.get_from_url("http://example.com").validate() assert not 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
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@pytest.mark.asyncio @pytest.mark.parametrize("use_update", [True, False])
async def test_add_invalid_repository_file(coresys, store_manager): async def test_error_on_invalid_repository(
"""Test add custom repository.""" coresys: CoreSys, store_manager: StoreManager, use_update
):
"""Test invalid repository not added."""
current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"pathlib.Path.read_text",
return_value="",
), pytest.raises(StoreError):
if use_update:
await store_manager.update_repositories(current + ["http://example.com"])
else:
await store_manager.add_repository("http://example.com")
assert "http://example.com" not in coresys.config.addons_repositories
assert len(coresys.resolution.suggestions) == 0
with pytest.raises(StoreNotFound):
store_manager.get_from_url("http://example.com")
@pytest.mark.parametrize("use_update", [True, False])
async def test_add_invalid_repository_file(
coresys: CoreSys, store_manager: StoreManager, use_update: bool
):
"""Test add invalid custom repository file."""
current = coresys.config.addons_repositories current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", return_value=None), 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"}),
), patch("pathlib.Path.exists", return_value=False): ), patch("pathlib.Path.exists", return_value=False):
await store_manager.update_repositories(current + ["http://example.com"]) if use_update:
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
)
else:
await store_manager.add_repository(
"http://example.com", add_with_errors=True
)
assert not store_manager.get_from_url("http://example.com").validate() assert not 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
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@pytest.mark.parametrize(
"use_update,git_error,suggestion_type",
[
(True, StoreGitCloneError(), SuggestionType.EXECUTE_REMOVE),
(True, StoreGitError(), SuggestionType.EXECUTE_RESET),
(False, StoreGitCloneError(), SuggestionType.EXECUTE_REMOVE),
(False, StoreGitError(), SuggestionType.EXECUTE_RESET),
],
)
async def test_add_repository_with_git_error(
coresys: CoreSys,
store_manager: StoreManager,
use_update: bool,
git_error: StoreGitError,
suggestion_type: SuggestionType,
):
"""Test repo added with issue on git error."""
current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", side_effect=git_error):
if use_update:
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
)
else:
await store_manager.add_repository(
"http://example.com", add_with_errors=True
)
assert "http://example.com" in coresys.config.addons_repositories
assert coresys.resolution.suggestions[-1].type == suggestion_type
assert isinstance(store_manager.get_from_url("http://example.com"), Repository)
@pytest.mark.parametrize(
"use_update,git_error",
[
(True, StoreGitCloneError()),
(True, StoreGitError()),
(False, StoreGitCloneError()),
(False, StoreGitError()),
],
)
async def test_error_on_repository_with_git_error(
coresys: CoreSys,
store_manager: StoreManager,
use_update: bool,
git_error: StoreGitError,
):
"""Test repo not added on git error."""
current = coresys.config.addons_repositories
with patch(
"supervisor.store.repository.Repository.load", side_effect=git_error
), pytest.raises(StoreError):
if use_update:
await store_manager.update_repositories(current + ["http://example.com"])
else:
await store_manager.add_repository("http://example.com")
assert "http://example.com" not in coresys.config.addons_repositories
assert len(coresys.resolution.suggestions) == 0
with pytest.raises(StoreNotFound):
store_manager.get_from_url("http://example.com")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_preinstall_valid_repository(coresys, store_manager): async def test_preinstall_valid_repository(
coresys: CoreSys, store_manager: StoreManager
):
"""Test add core repository valid.""" """Test add core repository valid."""
with patch("supervisor.store.repository.Repository.load", return_value=None): with patch("supervisor.store.repository.Repository.load", return_value=None):
await store_manager.update_repositories(BUILTIN_REPOSITORIES) await store_manager.update_repositories(BUILTIN_REPOSITORIES)
@ -76,15 +184,86 @@ async def test_preinstall_valid_repository(coresys, store_manager):
assert store_manager.get("local").validate() assert store_manager.get("local").validate()
@pytest.mark.asyncio @pytest.mark.parametrize("use_update", [True, False])
async def test_remove_used_repository(coresys, store_manager, store_addon): async def test_remove_repository(
coresys: CoreSys,
store_manager: StoreManager,
repository: Repository,
use_update: bool,
):
"""Test removing a custom repository."""
assert repository.url in coresys.config.addons_repositories
assert repository.slug in coresys.store.repositories
if use_update:
await store_manager.update_repositories([])
else:
await store_manager.remove_repository(repository)
assert repository.url not in coresys.config.addons_repositories
assert repository.slug not in coresys.addons.store
assert repository.slug not in coresys.store.repositories
@pytest.mark.parametrize("use_update", [True, False])
async def test_remove_used_repository(
coresys: CoreSys,
store_manager: StoreManager,
store_addon: AddonStore,
use_update: bool,
):
"""Test removing used custom repository.""" """Test removing used custom repository."""
coresys.addons.data.install(store_addon) coresys.addons.data.install(store_addon)
addon = Addon(coresys, store_addon.slug) addon = Addon(coresys, store_addon.slug)
coresys.addons.local[addon.slug] = addon coresys.addons.local[addon.slug] = addon
assert store_addon.repository in coresys.store.repositories
with pytest.raises( with pytest.raises(
StoreError, StoreError,
match="Can't remove 'https://github.com/awesome-developer/awesome-repo'. It's used by installed add-ons", match="Can't remove 'https://github.com/awesome-developer/awesome-repo'. It's used by installed add-ons",
): ):
if use_update:
await store_manager.update_repositories([]) await store_manager.update_repositories([])
else:
await store_manager.remove_repository(
coresys.store.repositories[store_addon.repository]
)
async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManager):
"""Test partial error on update does partial save and errors."""
current = coresys.config.addons_repositories
initial = len(current)
with patch("supervisor.store.repository.Repository.validate", return_value=True):
with patch("supervisor.store.repository.Repository.load", return_value=None):
await store_manager.update_repositories(current)
store_manager.data.update.assert_called_once()
store_manager.data.update.reset_mock()
with patch(
"supervisor.store.repository.Repository.load",
side_effect=[None, StoreGitError()],
), pytest.raises(StoreError):
await store_manager.update_repositories(
current + ["http://example.com", "http://example2.com"]
)
assert len(coresys.config.addons_repositories) == initial + 1
store_manager.data.update.assert_called_once()
async def test_error_adding_duplicate(
coresys: CoreSys, store_manager: StoreManager, repository: Repository
):
"""Test adding a duplicate repository causes an error."""
assert repository.url in coresys.config.addons_repositories
with patch(
"supervisor.store.repository.Repository.validate", return_value=True
), patch(
"supervisor.store.repository.Repository.load", return_value=None
), pytest.raises(
StoreError
):
await store_manager.add_repository(repository.url)

View File

@ -0,0 +1,95 @@
"""Test git repository."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, patch
from git import GitCommandError, GitError, InvalidGitRepositoryError, NoSuchPathError
import pytest
from supervisor.coresys import CoreSys
from supervisor.exceptions import StoreGitCloneError, StoreGitError
from supervisor.store.git import GitRepo
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
@pytest.fixture(name="clone_from")
async def fixture_clone_from():
"""Mock git clone_from."""
with patch("git.Repo.clone_from") as clone_from:
yield clone_from
@pytest.mark.parametrize("branch", [None, "dev"])
async def test_git_clone(
coresys: CoreSys, tmp_path: Path, clone_from: AsyncMock, branch: str | None
):
"""Test git clone."""
fragment = f"#{branch}" if branch else ""
repo = GitRepo(coresys, tmp_path, f"{REPO_URL}{fragment}")
await repo.clone.__wrapped__(repo)
kwargs = {"recursive": True, "depth": 1, "shallow-submodules": True}
if branch:
kwargs["branch"] = branch
clone_from.assert_called_once_with(
REPO_URL,
str(tmp_path),
**kwargs,
)
@pytest.mark.parametrize(
"git_error",
[InvalidGitRepositoryError(), NoSuchPathError(), GitCommandError("clone")],
)
async def test_git_clone_error(
coresys: CoreSys, tmp_path: Path, clone_from: AsyncMock, git_error: GitError
):
"""Test git clone error."""
repo = GitRepo(coresys, tmp_path, REPO_URL)
clone_from.side_effect = git_error
with pytest.raises(StoreGitCloneError):
await repo.clone.__wrapped__(repo)
assert len(coresys.resolution.suggestions) == 0
async def test_git_load(coresys: CoreSys, tmp_path: Path):
"""Test git load."""
repo = GitRepo(coresys, tmp_path, REPO_URL)
with patch("pathlib.Path.is_dir", return_value=True), patch.object(
GitRepo, "sys_run_in_executor", new_callable=AsyncMock
) as run_in_executor:
await repo.load()
assert run_in_executor.call_count == 2
@pytest.mark.parametrize(
"git_errors",
[
InvalidGitRepositoryError(),
NoSuchPathError(),
GitCommandError("init"),
[AsyncMock(), GitCommandError("fsck")],
],
)
async def test_git_load_error(
coresys: CoreSys, tmp_path: Path, git_errors: GitError | list[GitError | None]
):
"""Test git load error."""
repo = GitRepo(coresys, tmp_path, REPO_URL)
with patch("pathlib.Path.is_dir", return_value=True), patch.object(
GitRepo, "sys_run_in_executor", new_callable=AsyncMock
) as run_in_executor, pytest.raises(StoreGitError):
run_in_executor.side_effect = git_errors
await repo.load()
assert len(coresys.resolution.suggestions) == 0