Move repository urls to store settings file (#3665)

* Move repository urls to store settings file

* Remove default repos from supervisor config

* Fix clone at initial store load

* Mock git load in repository fixture
This commit is contained in:
Mike Degatano 2022-06-07 04:02:21 -04:00 committed by GitHub
parent deeaf2133b
commit ccd2c31390
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 369 additions and 97 deletions

View File

@ -35,7 +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 from ..store.validate import validate_repository
SCHEMA_UPDATE = vol.Schema( SCHEMA_UPDATE = vol.Schema(
{ {

View File

@ -46,8 +46,9 @@ from ..const import (
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
from ..store.validate import repositories
from ..utils.validate import validate_timezone from ..utils.validate import validate_timezone
from ..validate import repositories, version_tag, wait_boot from ..validate import version_tag, wait_boot
from .utils import api_process, api_process_raw, api_validate from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -115,7 +116,7 @@ class APISupervisor(CoreSysAttributes):
ATTR_DEBUG_BLOCK: self.sys_config.debug_block, ATTR_DEBUG_BLOCK: self.sys_config.debug_block,
ATTR_DIAGNOSTICS: self.sys_config.diagnostics, ATTR_DIAGNOSTICS: self.sys_config.diagnostics,
ATTR_ADDONS: list_addons, ATTR_ADDONS: list_addons,
ATTR_ADDONS_REPOSITORIES: self.sys_config.addons_repositories, ATTR_ADDONS_REPOSITORIES: self.sys_store.repository_urls,
} }
@api_process @api_process

View File

@ -497,18 +497,16 @@ class Backup(CoreSysAttributes):
def store_repositories(self): def store_repositories(self):
"""Store repository list into backup.""" """Store repository list into backup."""
self.repositories = self.sys_config.addons_repositories self.repositories = self.sys_store.repository_urls
async def restore_repositories(self, replace: bool = False): async def restore_repositories(self, replace: bool = False):
"""Restore repositories from backup. """Restore repositories from backup.
Return a coroutine. Return a coroutine.
""" """
new_list: set[str] = set(self.repositories) await self.sys_store.update_repositories(
if not replace: self.repositories, add_with_errors=True, replace=replace
new_list.update(self.sys_config.addons_repositories) )
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

@ -28,7 +28,8 @@ from ..const import (
FOLDER_SHARE, FOLDER_SHARE,
FOLDER_SSL, FOLDER_SSL,
) )
from ..validate import SCHEMA_DOCKER_CONFIG, repositories, version_tag from ..store.validate import repositories
from ..validate import SCHEMA_DOCKER_CONFIG, version_tag
ALL_FOLDERS = [ ALL_FOLDERS = [
FOLDER_SHARE, FOLDER_SHARE,

View File

@ -327,3 +327,7 @@ class CoreConfig(FileConfiguration):
return return
self._data[ATTR_ADDONS_CUSTOM_LIST].remove(repo) self._data[ATTR_ADDONS_CUSTOM_LIST].remove(repo)
def clear_addons_repositories(self) -> None:
"""Clear custom repositories list from core config."""
self._data[ATTR_ADDONS_CUSTOM_LIST] = []

View File

@ -52,7 +52,6 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict:
"supervisor": { "supervisor": {
"channel": coresys.updater.channel, "channel": coresys.updater.channel,
"installed_addons": installed_addons, "installed_addons": installed_addons,
"repositories": coresys.config.addons_repositories,
}, },
"host": { "host": {
"arch": coresys.arch.default, "arch": coresys.arch.default,
@ -79,6 +78,9 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict:
], ],
"unhealthy": coresys.resolution.unhealthy, "unhealthy": coresys.resolution.unhealthy,
}, },
"store": {
"repositories": coresys.store.repository_urls,
},
"misc": { "misc": {
"fallback_dns": coresys.plugins.dns.fallback, "fallback_dns": coresys.plugins.dns.fallback,
}, },

View File

@ -29,14 +29,9 @@ class FixupStoreExecuteRemove(FixupBase):
# Remove repository # Remove repository
try: try:
await repository.remove() await self.sys_store.remove_repository(repository)
except StoreError: except StoreError:
raise ResolutionFixupError() from None raise ResolutionFixupError() from None
else:
self.sys_store.repositories.pop(repository.slug, None)
self.sys_config.drop_addon_repository(repository.source)
self.sys_config.save_data()
@property @property
def suggestion(self) -> SuggestionType: def suggestion(self) -> SuggestionType:

View File

@ -2,7 +2,10 @@
import asyncio import asyncio
import logging import logging
from ..const import URL_HASSIO_ADDONS from supervisor.store.validate import SCHEMA_STORE_FILE
from supervisor.utils.common import FileConfiguration
from ..const import ATTR_REPOSITORIES, URL_HASSIO_ADDONS
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ( from ..exceptions import (
StoreError, StoreError,
@ -15,29 +18,43 @@ from ..exceptions import (
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
from .const import StoreType from .const import FILE_HASSIO_STORE, StoreType
from .data import StoreData from .data import StoreData
from .repository import Repository from .repository import Repository
from .validate import BUILTIN_REPOSITORIES, ensure_builtin_repositories
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
BUILTIN_REPOSITORIES = {StoreType.CORE.value, StoreType.LOCAL.value}
class StoreManager(CoreSysAttributes, FileConfiguration):
class StoreManager(CoreSysAttributes):
"""Manage add-ons inside Supervisor.""" """Manage add-ons inside Supervisor."""
def __init__(self, coresys: CoreSys): def __init__(self, coresys: CoreSys):
"""Initialize Docker base wrapper.""" """Initialize Docker base wrapper."""
super().__init__(FILE_HASSIO_STORE, SCHEMA_STORE_FILE)
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
self.data = StoreData(coresys) self.data = StoreData(coresys)
self.repositories: dict[str, Repository] = {} self._repositories: dict[str, Repository] = {}
@property @property
def all(self) -> list[Repository]: def all(self) -> list[Repository]:
"""Return list of add-on repositories.""" """Return list of add-on repositories."""
return list(self.repositories.values()) return list(self.repositories.values())
@property
def repositories(self) -> dict[str, Repository]:
"""Return repositories dictionary."""
return self._repositories
@property
def repository_urls(self) -> list[str]:
"""Return source URL for all git repositories."""
return [
repository.url
for repository in self.all
if repository.type == StoreType.GIT
]
def get(self, slug: str) -> Repository: def get(self, slug: str) -> Repository:
"""Return Repository with slug.""" """Return Repository with slug."""
if slug not in self.repositories: if slug not in self.repositories:
@ -56,11 +73,17 @@ class StoreManager(CoreSysAttributes):
"""Start up add-on management.""" """Start up add-on management."""
await self.data.update() await self.data.update()
# Init Supervisor built-in repositories # Backwards compatibility - Remove after 2022.9
repositories = set(self.sys_config.addons_repositories) | BUILTIN_REPOSITORIES if len(self.sys_config.addons_repositories) > 0:
self._data[ATTR_REPOSITORIES] = ensure_builtin_repositories(
self.sys_config.addons_repositories
)
self.sys_config.clear_addons_repositories()
# Init custom repositories and load add-ons # Init custom repositories and load add-ons
await self.update_repositories(repositories, add_with_errors=True) await self.update_repositories(
self._data[ATTR_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."""
@ -144,9 +167,9 @@ class StoreManager(CoreSysAttributes):
) )
# Add Repository to list # Add Repository to list
if repository.type == StoreType.GIT: self._data[ATTR_REPOSITORIES].append(url)
self.sys_config.add_addon_repository(repository.source)
self.repositories[repository.slug] = repository self.repositories[repository.slug] = repository
self.save_data()
# Persist changes # Persist changes
if persist: if persist:
@ -155,7 +178,7 @@ class StoreManager(CoreSysAttributes):
async def remove_repository(self, repository: Repository, *, persist: bool = True): async def remove_repository(self, repository: Repository, *, persist: bool = True):
"""Remove a repository.""" """Remove a repository."""
if repository.type != StoreType.GIT: if repository.url in BUILTIN_REPOSITORIES:
raise StoreInvalidAddonRepo( raise StoreInvalidAddonRepo(
"Can't remove built-in repositories!", logger=_LOGGER.error "Can't remove built-in repositories!", logger=_LOGGER.error
) )
@ -166,7 +189,8 @@ class StoreManager(CoreSysAttributes):
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(repository.url) self._data[ATTR_REPOSITORIES].remove(repository.url)
self.save_data()
if persist: if persist:
await self.data.update() await self.data.update()
@ -174,10 +198,18 @@ class StoreManager(CoreSysAttributes):
@Job(conditions=[JobCondition.INTERNET_SYSTEM]) @Job(conditions=[JobCondition.INTERNET_SYSTEM])
async def update_repositories( async def update_repositories(
self, list_repositories, *, add_with_errors: bool = False self,
list_repositories: list[str],
*,
add_with_errors: bool = False,
replace: bool = True,
): ):
"""Add a new custom repository.""" """Add a new custom repository."""
new_rep = set(list_repositories) new_rep = set(
ensure_builtin_repositories(list_repositories)
if replace
else list_repositories + self.repository_urls
)
old_rep = {repository.source for repository in self.all} old_rep = {repository.source for repository in self.all}
# Add new repositories # Add new repositories

View File

@ -1,5 +1,10 @@
"""Constants for the add-on store.""" """Constants for the add-on store."""
from enum import Enum from enum import Enum
from pathlib import Path
from supervisor.const import SUPERVISOR_DATA
FILE_HASSIO_STORE = Path(SUPERVISOR_DATA, "store.json")
class StoreType(str, Enum): class StoreType(str, Enum):

View File

@ -13,8 +13,8 @@ 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
from ..validate import RE_REPOSITORY
from .utils import get_hash_from_repository from .utils import get_hash_from_repository
from .validate import RE_REPOSITORY
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)

View File

@ -36,7 +36,6 @@ class Repository(CoreSysAttributes):
self._type = StoreType.CORE self._type = StoreType.CORE
else: else:
self.git = GitRepoCustom(coresys, repository) self.git = GitRepoCustom(coresys, repository)
self.source = repository
self._slug = get_hash_from_repository(repository) self._slug = get_hash_from_repository(repository)
self._type = StoreType.GIT self._type = StoreType.GIT

View File

@ -2,7 +2,19 @@
import voluptuous as vol import voluptuous as vol
from ..const import ATTR_MAINTAINER, ATTR_NAME, ATTR_URL from supervisor.store.const import StoreType
from ..const import ATTR_MAINTAINER, ATTR_NAME, ATTR_REPOSITORIES, ATTR_URL
from ..validate import RE_REPOSITORY
URL_COMMUNITY_ADDONS = "https://github.com/hassio-addons/repository"
URL_ESPHOME = "https://github.com/esphome/home-assistant-addon"
BUILTIN_REPOSITORIES = {
StoreType.CORE.value,
StoreType.LOCAL.value,
URL_COMMUNITY_ADDONS,
URL_ESPHOME,
}
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_REPOSITORY_CONFIG = vol.Schema( SCHEMA_REPOSITORY_CONFIG = vol.Schema(
@ -13,3 +25,37 @@ SCHEMA_REPOSITORY_CONFIG = vol.Schema(
}, },
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
) )
def validate_repository(repository: str) -> str:
"""Validate a valid repository."""
if repository in [StoreType.CORE.value, StoreType.LOCAL.value]:
return repository
data = RE_REPOSITORY.match(repository)
if not data:
raise vol.Invalid("No valid repository format!") from None
# Validate URL
# pylint: disable=no-value-for-parameter
vol.Url()(data.group("url"))
return repository
def ensure_builtin_repositories(addon_repositories: list[str]) -> list[str]:
"""Ensure builtin repositories are in list."""
return list(set(addon_repositories) | BUILTIN_REPOSITORIES)
# pylint: disable=no-value-for-parameter
repositories = vol.All([validate_repository], vol.Unique(), ensure_builtin_repositories)
SCHEMA_STORE_FILE = vol.Schema(
{
vol.Optional(
ATTR_REPOSITORIES, default=list(BUILTIN_REPOSITORIES)
): repositories,
},
extra=vol.REMOVE_EXTRA,
)

View File

@ -41,6 +41,7 @@ from .const import (
) )
from .utils.validate import validate_timezone from .utils.validate import validate_timezone
# Move to store.validate when addons_repository config removed
RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$") RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
RE_REGISTRY = re.compile(r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$") RE_REGISTRY = re.compile(r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$")
@ -84,6 +85,7 @@ def dns_url(url: str) -> str:
dns_server_list = vol.All(vol.Length(max=8), [dns_url]) dns_server_list = vol.All(vol.Length(max=8), [dns_url])
# Remove with addons_repositories config
def validate_repository(repository: str) -> str: def validate_repository(repository: str) -> str:
"""Validate a valid repository.""" """Validate a valid repository."""
data = RE_REPOSITORY.match(repository) data = RE_REPOSITORY.match(repository)
@ -146,13 +148,7 @@ SCHEMA_SUPERVISOR_CONFIG = vol.Schema(
ATTR_VERSION, default=AwesomeVersion(SUPERVISOR_VERSION) ATTR_VERSION, default=AwesomeVersion(SUPERVISOR_VERSION)
): version_tag, ): version_tag,
vol.Optional(ATTR_IMAGE): docker_image, vol.Optional(ATTR_IMAGE): docker_image,
vol.Optional( vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[]): repositories,
ATTR_ADDONS_CUSTOM_LIST,
default=[
"https://github.com/hassio-addons/repository",
"https://github.com/esphome/home-assistant-addon",
],
): repositories,
vol.Optional(ATTR_WAIT_BOOT, default=5): wait_boot, vol.Optional(ATTR_WAIT_BOOT, default=5): wait_boot,
vol.Optional(ATTR_LOGGING, default=LogLevel.INFO): vol.Coerce(LogLevel), vol.Optional(ATTR_LOGGING, default=LogLevel.INFO): vol.Coerce(LogLevel),
vol.Optional(ATTR_DEBUG, default=False): vol.Boolean(), vol.Optional(ATTR_DEBUG, default=False): vol.Boolean(),

View File

@ -82,7 +82,7 @@ async def test_api_store_add_repository(api_client: TestClient, coresys: CoreSys
) )
assert response.status == 200 assert response.status == 200
assert REPO_URL in coresys.config.addons_repositories assert REPO_URL in coresys.store.repository_urls
assert isinstance(coresys.store.get_from_url(REPO_URL), Repository) assert isinstance(coresys.store.get_from_url(REPO_URL), Repository)
@ -93,5 +93,5 @@ async def test_api_store_remove_repository(
response = await api_client.delete(f"/store/repositories/{repository.slug}") response = await api_client.delete(f"/store/repositories/{repository.slug}")
assert response.status == 200 assert response.status == 200
assert repository.url not in coresys.config.addons_repositories assert repository.url not in coresys.store.repository_urls
assert repository.slug not in coresys.store.repositories assert repository.slug not in coresys.store.repositories

View File

@ -26,7 +26,7 @@ async def test_api_supervisor_options_add_repository(
api_client: TestClient, coresys: CoreSys api_client: TestClient, coresys: CoreSys
): ):
"""Test add a repository via POST /supervisor/options REST API.""" """Test add a repository via POST /supervisor/options REST API."""
assert REPO_URL not in coresys.config.addons_repositories assert REPO_URL not in coresys.store.repository_urls
with pytest.raises(StoreNotFound): with pytest.raises(StoreNotFound):
coresys.store.get_from_url(REPO_URL) coresys.store.get_from_url(REPO_URL)
@ -38,7 +38,7 @@ async def test_api_supervisor_options_add_repository(
) )
assert response.status == 200 assert response.status == 200
assert REPO_URL in coresys.config.addons_repositories assert REPO_URL in coresys.store.repository_urls
assert isinstance(coresys.store.get_from_url(REPO_URL), Repository) assert isinstance(coresys.store.get_from_url(REPO_URL), Repository)
@ -46,7 +46,7 @@ async def test_api_supervisor_options_remove_repository(
api_client: TestClient, coresys: CoreSys, repository: Repository api_client: TestClient, coresys: CoreSys, repository: Repository
): ):
"""Test remove a repository via POST /supervisor/options REST API.""" """Test remove a repository via POST /supervisor/options REST API."""
assert repository.url in coresys.config.addons_repositories assert repository.url in coresys.store.repository_urls
assert repository.slug in coresys.store.repositories assert repository.slug in coresys.store.repositories
response = await api_client.post( response = await api_client.post(
@ -54,7 +54,7 @@ async def test_api_supervisor_options_remove_repository(
) )
assert response.status == 200 assert response.status == 200
assert repository.url not in coresys.config.addons_repositories assert repository.url not in coresys.store.repository_urls
assert repository.slug not in coresys.store.repositories assert repository.slug not in coresys.store.repositories
@ -65,14 +65,18 @@ async def test_api_supervisor_options_repositories_skipped_on_error(
"""Test repositories skipped on error via POST /supervisor/options REST API.""" """Test repositories skipped on error via POST /supervisor/options REST API."""
with patch( with patch(
"supervisor.store.repository.Repository.load", side_effect=git_error "supervisor.store.repository.Repository.load", side_effect=git_error
), patch("supervisor.store.repository.Repository.validate", return_value=False): ), patch(
"supervisor.store.repository.Repository.validate", return_value=False
), patch(
"supervisor.store.repository.Repository.remove"
):
response = await api_client.post( response = await api_client.post(
"/supervisor/options", json={"addons_repositories": [REPO_URL]} "/supervisor/options", json={"addons_repositories": [REPO_URL]}
) )
assert response.status == 400 assert response.status == 400
assert len(coresys.resolution.suggestions) == 0 assert len(coresys.resolution.suggestions) == 0
assert REPO_URL not in coresys.config.addons_repositories assert REPO_URL not in coresys.store.repository_urls
with pytest.raises(StoreNotFound): with pytest.raises(StoreNotFound):
coresys.store.get_from_url(REPO_URL) coresys.store.get_from_url(REPO_URL)
@ -92,7 +96,7 @@ async def test_api_supervisor_options_repo_error_with_config_change(
) )
assert response.status == 400 assert response.status == 400
assert REPO_URL not in coresys.config.addons_repositories assert REPO_URL not in coresys.store.repository_urls
assert coresys.config.debug assert coresys.config.debug
coresys.updater.save_data.assert_called_once() coresys.updater.save_data.assert_called_once()

View File

@ -16,7 +16,7 @@ from supervisor import config as su_config
from supervisor.addons.addon import Addon from supervisor.addons.addon import Addon
from supervisor.api import RestAPI from supervisor.api import RestAPI
from supervisor.bootstrap import initialize_coresys from supervisor.bootstrap import initialize_coresys
from supervisor.const import REQUEST_FROM from supervisor.const import ATTR_REPOSITORIES, REQUEST_FROM
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.dbus.agent import OSAgent from supervisor.dbus.agent import OSAgent
from supervisor.dbus.const import DBUS_SIGNAL_NM_CONNECTION_ACTIVE_CHANGED from supervisor.dbus.const import DBUS_SIGNAL_NM_CONNECTION_ACTIVE_CHANGED
@ -39,7 +39,6 @@ from .const import TEST_ADDON_SLUG
async def mock_async_return_true() -> bool: async def mock_async_return_true() -> bool:
"""Mock methods to return True.""" """Mock methods to return True."""
return True return True
@ -74,7 +73,6 @@ def docker() -> DockerAPI:
@pytest.fixture @pytest.fixture
def dbus() -> DBus: def dbus() -> DBus:
"""Mock DBUS.""" """Mock DBUS."""
dbus_commands = [] dbus_commands = []
async def mock_get_properties(dbus_obj, interface): async def mock_get_properties(dbus_obj, interface):
@ -208,6 +206,7 @@ async def coresys(loop, docker, network_manager, aiohttp_client, run_dir) -> Cor
coresys_obj._jobs.save_data = MagicMock() coresys_obj._jobs.save_data = MagicMock()
coresys_obj._resolution.save_data = MagicMock() coresys_obj._resolution.save_data = MagicMock()
coresys_obj._addons.data.save_data = MagicMock() coresys_obj._addons.data.save_data = MagicMock()
coresys_obj._store.save_data = MagicMock()
# Mock test client # Mock test client
coresys_obj.arch._default_arch = "amd64" coresys_obj.arch._default_arch = "amd64"
@ -246,7 +245,9 @@ async def coresys(loop, docker, network_manager, aiohttp_client, run_dir) -> Cor
unwrap(coresys_obj.updater.fetch_data), coresys_obj.updater unwrap(coresys_obj.updater.fetch_data), coresys_obj.updater
) )
yield coresys_obj # Don't remove files/folders related to addons and stores
with patch("supervisor.store.git.GitRepo._remove"):
yield coresys_obj
await coresys_obj.websession.close() await coresys_obj.websession.close()
@ -315,21 +316,29 @@ def store_addon(coresys: CoreSys, tmp_path, repository):
@pytest.fixture @pytest.fixture
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.store._data[ATTR_REPOSITORIES].remove(
coresys.config.drop_addon_repository( "https://github.com/hassio-addons/repository"
)
coresys.store._data[ATTR_REPOSITORIES].remove(
"https://github.com/esphome/home-assistant-addon" "https://github.com/esphome/home-assistant-addon"
) )
await coresys.store.load() coresys.config.clear_addons_repositories()
repository_obj = Repository(
coresys, "https://github.com/awesome-developer/awesome-repo"
)
coresys.store.repositories[repository_obj.slug] = repository_obj with patch(
coresys.config.add_addon_repository( "supervisor.store.validate.BUILTIN_REPOSITORIES", {"local", "core"}
"https://github.com/awesome-developer/awesome-repo" ), patch("supervisor.store.git.GitRepo.load", return_value=None):
) await coresys.store.load()
yield repository_obj repository_obj = Repository(
coresys, "https://github.com/awesome-developer/awesome-repo"
)
coresys.store.repositories[repository_obj.slug] = repository_obj
coresys.store._data[ATTR_REPOSITORIES].append(
"https://github.com/awesome-developer/awesome-repo"
)
yield repository_obj
@pytest.fixture @pytest.fixture

View File

@ -0,0 +1,3 @@
name: ESPHome add-ons
url: "https://home-assistant.io"
maintainer: dev@ha.com

View File

@ -0,0 +1,3 @@
name: Community add-ons
url: "https://home-assistant.io"
maintainer: dev@ha.com

View File

@ -1,36 +1,34 @@
"""Test evaluation base.""" """Test evaluation base."""
# pylint: disable=import-error,protected-access # pylint: disable=import-error,protected-access
from unittest.mock import AsyncMock from unittest.mock import patch
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.resolution.const import ContextType, IssueType, SuggestionType from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion from supervisor.resolution.data import Issue, Suggestion
from supervisor.resolution.fixups.store_execute_remove import FixupStoreExecuteRemove from supervisor.resolution.fixups.store_execute_remove import FixupStoreExecuteRemove
from supervisor.store.repository import Repository
async def test_fixup(coresys: CoreSys): async def test_fixup(coresys: CoreSys, repository: Repository):
"""Test fixup.""" """Test fixup."""
store_execute_remove = FixupStoreExecuteRemove(coresys) store_execute_remove = FixupStoreExecuteRemove(coresys)
assert store_execute_remove.auto is False 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=repository.slug
) )
coresys.resolution.issues = Issue( coresys.resolution.issues = Issue(
IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference="test" IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference=repository.slug
) )
mock_repositorie = AsyncMock() with patch.object(type(repository), "remove") as remove_repo:
mock_repositorie.slug = "test" await store_execute_remove()
coresys.store.repositories["test"] = mock_repositorie assert remove_repo.called
await store_execute_remove() assert coresys.store.save_data.called
assert mock_repositorie.remove.called
assert coresys.config.save_data.called
assert len(coresys.resolution.suggestions) == 0 assert len(coresys.resolution.suggestions) == 0
assert len(coresys.resolution.issues) == 0 assert len(coresys.resolution.issues) == 0
assert "test" not in coresys.store.repositories assert repository.slug not in coresys.store.repositories

View File

@ -23,7 +23,7 @@ async def test_add_valid_repository(
coresys: CoreSys, store_manager: StoreManager, use_update: bool coresys: CoreSys, store_manager: StoreManager, use_update: bool
): ):
"""Test add custom repository.""" """Test add custom repository."""
current = coresys.config.addons_repositories current = coresys.store.repository_urls
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"},
@ -35,7 +35,7 @@ async def test_add_valid_repository(
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.store.repository_urls
@pytest.mark.parametrize("use_update", [True, False]) @pytest.mark.parametrize("use_update", [True, False])
@ -43,7 +43,7 @@ async def test_add_invalid_repository(
coresys: CoreSys, store_manager: StoreManager, use_update: bool coresys: CoreSys, store_manager: StoreManager, use_update: bool
): ):
"""Test add invalid custom repository.""" """Test add invalid custom repository."""
current = coresys.config.addons_repositories current = coresys.store.repository_urls
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="",
@ -59,7 +59,7 @@ async def test_add_invalid_repository(
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.store.repository_urls
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@ -68,7 +68,7 @@ async def test_error_on_invalid_repository(
coresys: CoreSys, store_manager: StoreManager, use_update coresys: CoreSys, store_manager: StoreManager, use_update
): ):
"""Test invalid repository not added.""" """Test invalid repository not added."""
current = coresys.config.addons_repositories current = coresys.store.repository_urls
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="",
@ -78,7 +78,7 @@ async def test_error_on_invalid_repository(
else: else:
await store_manager.add_repository("http://example.com") await store_manager.add_repository("http://example.com")
assert "http://example.com" not in coresys.config.addons_repositories assert "http://example.com" not in coresys.store.repository_urls
assert len(coresys.resolution.suggestions) == 0 assert len(coresys.resolution.suggestions) == 0
with pytest.raises(StoreNotFound): with pytest.raises(StoreNotFound):
store_manager.get_from_url("http://example.com") store_manager.get_from_url("http://example.com")
@ -89,7 +89,7 @@ async def test_add_invalid_repository_file(
coresys: CoreSys, store_manager: StoreManager, use_update: bool coresys: CoreSys, store_manager: StoreManager, use_update: bool
): ):
"""Test add invalid custom repository file.""" """Test add invalid custom repository file."""
current = coresys.config.addons_repositories current = coresys.store.repository_urls
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"}),
@ -105,7 +105,7 @@ async def test_add_invalid_repository_file(
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.store.repository_urls
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@ -126,7 +126,7 @@ async def test_add_repository_with_git_error(
suggestion_type: SuggestionType, suggestion_type: SuggestionType,
): ):
"""Test repo added with issue on git error.""" """Test repo added with issue on git error."""
current = coresys.config.addons_repositories current = coresys.store.repository_urls
with patch("supervisor.store.repository.Repository.load", side_effect=git_error): with patch("supervisor.store.repository.Repository.load", side_effect=git_error):
if use_update: if use_update:
await store_manager.update_repositories( await store_manager.update_repositories(
@ -137,7 +137,7 @@ async def test_add_repository_with_git_error(
"http://example.com", add_with_errors=True "http://example.com", add_with_errors=True
) )
assert "http://example.com" in coresys.config.addons_repositories assert "http://example.com" in coresys.store.repository_urls
assert coresys.resolution.suggestions[-1].type == suggestion_type assert coresys.resolution.suggestions[-1].type == suggestion_type
assert isinstance(store_manager.get_from_url("http://example.com"), Repository) assert isinstance(store_manager.get_from_url("http://example.com"), Repository)
@ -158,7 +158,7 @@ async def test_error_on_repository_with_git_error(
git_error: StoreGitError, git_error: StoreGitError,
): ):
"""Test repo not added on git error.""" """Test repo not added on git error."""
current = coresys.config.addons_repositories current = coresys.store.repository_urls
with patch( with patch(
"supervisor.store.repository.Repository.load", side_effect=git_error "supervisor.store.repository.Repository.load", side_effect=git_error
), pytest.raises(StoreError): ), pytest.raises(StoreError):
@ -167,7 +167,7 @@ async def test_error_on_repository_with_git_error(
else: else:
await store_manager.add_repository("http://example.com") await store_manager.add_repository("http://example.com")
assert "http://example.com" not in coresys.config.addons_repositories assert "http://example.com" not in coresys.store.repository_urls
assert len(coresys.resolution.suggestions) == 0 assert len(coresys.resolution.suggestions) == 0
with pytest.raises(StoreNotFound): with pytest.raises(StoreNotFound):
store_manager.get_from_url("http://example.com") store_manager.get_from_url("http://example.com")
@ -182,6 +182,8 @@ async def test_preinstall_valid_repository(
await store_manager.update_repositories(BUILTIN_REPOSITORIES) await store_manager.update_repositories(BUILTIN_REPOSITORIES)
assert store_manager.get("core").validate() assert store_manager.get("core").validate()
assert store_manager.get("local").validate() assert store_manager.get("local").validate()
assert store_manager.get("a0d7b954").validate()
assert store_manager.get("5c53de3b").validate()
@pytest.mark.parametrize("use_update", [True, False]) @pytest.mark.parametrize("use_update", [True, False])
@ -192,7 +194,7 @@ async def test_remove_repository(
use_update: bool, use_update: bool,
): ):
"""Test removing a custom repository.""" """Test removing a custom repository."""
assert repository.url in coresys.config.addons_repositories assert repository.url in coresys.store.repository_urls
assert repository.slug in coresys.store.repositories assert repository.slug in coresys.store.repositories
if use_update: if use_update:
@ -200,7 +202,7 @@ async def test_remove_repository(
else: else:
await store_manager.remove_repository(repository) await store_manager.remove_repository(repository)
assert repository.url not in coresys.config.addons_repositories assert repository.url not in coresys.store.repository_urls
assert repository.slug not in coresys.addons.store assert repository.slug not in coresys.addons.store
assert repository.slug not in coresys.store.repositories assert repository.slug not in coresys.store.repositories
@ -233,15 +235,16 @@ async def test_remove_used_repository(
async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManager): async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManager):
"""Test partial error on update does partial save and errors.""" """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.validate", return_value=True):
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(current) await store_manager.update_repositories([])
store_manager.data.update.assert_called_once() store_manager.data.update.assert_called_once()
store_manager.data.update.reset_mock() store_manager.data.update.reset_mock()
current = coresys.store.repository_urls
initial = len(current)
with patch( with patch(
"supervisor.store.repository.Repository.load", "supervisor.store.repository.Repository.load",
side_effect=[None, StoreGitError()], side_effect=[None, StoreGitError()],
@ -250,7 +253,7 @@ async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManage
current + ["http://example.com", "http://example2.com"] current + ["http://example.com", "http://example2.com"]
) )
assert len(coresys.config.addons_repositories) == initial + 1 assert len(coresys.store.repository_urls) == initial + 1
store_manager.data.update.assert_called_once() store_manager.data.update.assert_called_once()
@ -258,7 +261,7 @@ async def test_error_adding_duplicate(
coresys: CoreSys, store_manager: StoreManager, repository: Repository coresys: CoreSys, store_manager: StoreManager, repository: Repository
): ):
"""Test adding a duplicate repository causes an error.""" """Test adding a duplicate repository causes an error."""
assert repository.url in coresys.config.addons_repositories assert repository.url in coresys.store.repository_urls
with patch( with patch(
"supervisor.store.repository.Repository.validate", return_value=True "supervisor.store.repository.Repository.validate", return_value=True
), patch( ), patch(
@ -267,3 +270,20 @@ async def test_error_adding_duplicate(
StoreError StoreError
): ):
await store_manager.add_repository(repository.url) await store_manager.add_repository(repository.url)
async def test_add_with_update_repositories(
coresys: CoreSys, store_manager: StoreManager, repository: Repository
):
"""Test adding repositories to existing ones using update."""
assert repository.url in coresys.store.repository_urls
assert "http://example.com" not in coresys.store.repository_urls
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"supervisor.utils.common.read_yaml_file",
return_value={"name": "Awesome repository"},
), patch("pathlib.Path.exists", return_value=True):
await store_manager.update_repositories(["http://example.com"], replace=False)
assert repository.url in coresys.store.repository_urls
assert "http://example.com" in coresys.store.repository_urls

View File

@ -0,0 +1,98 @@
"""Test store manager."""
from unittest.mock import patch
from supervisor.const import ATTR_ADDONS_CUSTOM_LIST
from supervisor.coresys import CoreSys
from supervisor.store import StoreManager
from supervisor.store.repository import Repository
async def test_default_load(coresys: CoreSys):
"""Test default load from config."""
store_manager = StoreManager(coresys)
with patch(
"supervisor.store.repository.Repository.load", return_value=None
), patch.object(
type(coresys.config), "addons_repositories", return_value=[]
), patch(
"pathlib.Path.exists", return_value=True
):
await store_manager.load()
assert len(store_manager.all) == 4
assert isinstance(store_manager.get("core"), Repository)
assert isinstance(store_manager.get("local"), Repository)
assert len(store_manager.repository_urls) == 2
assert (
"https://github.com/hassio-addons/repository" in store_manager.repository_urls
)
assert (
"https://github.com/esphome/home-assistant-addon"
in store_manager.repository_urls
)
async def test_load_with_custom_repository(coresys: CoreSys):
"""Test load from config with custom repository."""
with patch(
"supervisor.utils.common.read_json_or_yaml_file",
return_value={"repositories": ["http://example.com"]},
), patch("pathlib.Path.is_file", return_value=True):
store_manager = StoreManager(coresys)
with patch(
"supervisor.store.repository.Repository.load", return_value=None
), patch.object(
type(coresys.config), "addons_repositories", return_value=[]
), patch(
"supervisor.store.repository.Repository.validate", return_value=True
), patch(
"pathlib.Path.exists", return_value=True
):
await store_manager.load()
assert len(store_manager.all) == 5
assert isinstance(store_manager.get("core"), Repository)
assert isinstance(store_manager.get("local"), Repository)
assert len(store_manager.repository_urls) == 3
assert (
"https://github.com/hassio-addons/repository" in store_manager.repository_urls
)
assert (
"https://github.com/esphome/home-assistant-addon"
in store_manager.repository_urls
)
assert "http://example.com" in store_manager.repository_urls
async def test_load_from_core_config(coresys: CoreSys):
"""Test custom repositories loaded from core config when present."""
store_manager = StoreManager(coresys)
# pylint: disable=protected-access
coresys.config._data[ATTR_ADDONS_CUSTOM_LIST] = ["http://example.com"]
assert coresys.config.addons_repositories == ["http://example.com"]
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"supervisor.store.repository.Repository.validate", return_value=True
), patch("pathlib.Path.exists", return_value=True):
await store_manager.load()
assert len(store_manager.all) == 5
assert isinstance(store_manager.get("core"), Repository)
assert isinstance(store_manager.get("local"), Repository)
assert len(store_manager.repository_urls) == 3
assert (
"https://github.com/hassio-addons/repository" in store_manager.repository_urls
)
assert (
"https://github.com/esphome/home-assistant-addon"
in store_manager.repository_urls
)
assert "http://example.com" in store_manager.repository_urls
assert coresys.config.addons_repositories == []

View File

@ -0,0 +1,58 @@
"""Test schema validation."""
from typing import Any
import pytest
from voluptuous import Invalid
from supervisor.const import ATTR_REPOSITORIES
from supervisor.store.validate import SCHEMA_STORE_FILE, repositories
@pytest.mark.parametrize(
"config",
[
{},
{ATTR_REPOSITORIES: []},
{ATTR_REPOSITORIES: ["https://github.com/esphome/home-assistant-addon"]},
],
)
async def test_default_config(config: dict[Any]):
"""Test built-ins included by default."""
conf = SCHEMA_STORE_FILE(config)
assert ATTR_REPOSITORIES in conf
assert "core" in conf[ATTR_REPOSITORIES]
assert "local" in conf[ATTR_REPOSITORIES]
assert "https://github.com/hassio-addons/repository" in conf[ATTR_REPOSITORIES]
assert 1 == len(
[
repo
for repo in conf[ATTR_REPOSITORIES]
if repo == "https://github.com/esphome/home-assistant-addon"
]
)
@pytest.mark.parametrize(
"repo_list,valid",
[
([], True),
(["core", "local"], True),
(["https://github.com/hassio-addons/repository"], True),
(["not_a_url"], False),
(["https://fail.com/duplicate", "https://fail.com/duplicate"], False),
],
)
async def test_repository_validate(repo_list: list[str], valid: bool):
"""Test repository list validate."""
if valid:
processed = repositories(repo_list)
assert len(processed) == 4
assert set(repositories(repo_list)) == {
"core",
"local",
"https://github.com/hassio-addons/repository",
"https://github.com/esphome/home-assistant-addon",
}
else:
with pytest.raises(Invalid):
repositories(repo_list)