From 1d1a8cdad35f85631bfbde38de1b03c5aca69464 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 6 Jan 2026 10:01:48 -0500 Subject: [PATCH] Add API to force repository repair (#6439) * Add API to force repository repair * Fix inheritance for error * Fix absolute import --- supervisor/api/__init__.py | 4 ++ supervisor/api/store.py | 18 ++++++++ supervisor/exceptions.py | 24 +++++++++++ supervisor/store/repository.py | 20 ++++++--- tests/api/test_store.py | 78 ++++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 6 deletions(-) diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index f33a5bce8..beb2a3ff1 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -782,6 +782,10 @@ class RestAPI(CoreSysAttributes): web.delete( "/store/repositories/{repository}", api_store.remove_repository ), + web.post( + "/store/repositories/{repository}/repair", + api_store.repositories_repository_repair, + ), ] ) diff --git a/supervisor/api/store.py b/supervisor/api/store.py index a25846826..2767a8d5b 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -54,6 +54,7 @@ from ..const import ( ) from ..coresys import CoreSysAttributes from ..exceptions import APIError, APIForbidden, APINotFound, StoreAddonNotFoundError +from ..resolution.const import ContextType, SuggestionType from ..store.addon import AddonStore from ..store.repository import Repository from ..store.validate import validate_repository @@ -359,3 +360,20 @@ class APIStore(CoreSysAttributes): """Remove repository from the store.""" repository: Repository = self._extract_repository(request) await asyncio.shield(self.sys_store.remove_repository(repository)) + + @api_process + async def repositories_repository_repair(self, request: web.Request) -> None: + """Repair repository.""" + repository: Repository = self._extract_repository(request) + await asyncio.shield(repository.reset()) + + # If we have an execute reset suggestion on this repository, dismiss it and the issue + for suggestion in self.sys_resolution.suggestions: + if ( + suggestion.type == SuggestionType.EXECUTE_RESET + and suggestion.context == ContextType.STORE + and suggestion.reference == repository.slug + ): + for issue in self.sys_resolution.issues_for_suggestion(suggestion): + self.sys_resolution.dismiss_issue(issue) + return diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index 6853d6776..280cbe84b 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -969,6 +969,18 @@ class StoreAddonNotFoundError(StoreError, APINotFound): super().__init__(None, logger) +class StoreRepositoryLocalCannotReset(StoreError, APIError): + """Raise if user requests a reset on the local addon repository.""" + + error_key = "store_repository_local_cannot_reset" + message_template = "Can't reset repository {local_repo} as it is not git based!" + extra_fields = {"local_repo": "local"} + + def __init__(self, logger: Callable[..., None] | None = None) -> None: + """Initialize exception.""" + super().__init__(None, logger) + + class StoreJobError(StoreError, JobException): """Raise on job error with git.""" @@ -977,6 +989,18 @@ class StoreInvalidAddonRepo(StoreError): """Raise on invalid addon repo.""" +class StoreRepositoryUnknownError(StoreError, APIUnknownSupervisorError): + """Raise when unknown error occurs taking an action for a store repository.""" + + error_key = "store_repository_unknown_error" + message_template = "An unknown error occurred with addon repository {repo}" + + def __init__(self, logger: Callable[..., None] | None = None, *, repo: str) -> None: + """Initialize exception.""" + self.extra_fields = {"repo": repo} + super().__init__(logger) + + # Backup diff --git a/supervisor/store/repository.py b/supervisor/store/repository.py index eafe66d7b..96e6c6f44 100644 --- a/supervisor/store/repository.py +++ b/supervisor/store/repository.py @@ -17,7 +17,13 @@ from ..const import ( REPOSITORY_LOCAL, ) from ..coresys import CoreSys, CoreSysAttributes -from ..exceptions import ConfigurationFileError, StoreError +from ..exceptions import ( + ConfigurationFileError, + StoreError, + StoreGitError, + StoreRepositoryLocalCannotReset, + StoreRepositoryUnknownError, +) from ..utils import get_latest_mtime from ..utils.common import read_json_or_yaml_file from .const import BuiltinRepository @@ -197,8 +203,12 @@ class RepositoryGit(Repository, ABC): async def reset(self) -> None: """Reset add-on repository to fix corruption issue with files.""" - await self._git.reset() - await self.load() + try: + await self._git.reset() + await self.load() + except StoreGitError as err: + _LOGGER.error("Can't reset repository %s: %s", self.slug, err) + raise StoreRepositoryUnknownError(repo=self.slug) from err class RepositoryLocal(RepositoryBuiltin): @@ -237,9 +247,7 @@ class RepositoryLocal(RepositoryBuiltin): async def reset(self) -> None: """Raise. Not supported for local repository.""" - raise StoreError( - "Can't reset local repository as it is not git based!", _LOGGER.error - ) + raise StoreRepositoryLocalCannotReset(_LOGGER.error) class RepositoryGitBuiltin(RepositoryBuiltin, RepositoryGit): diff --git a/tests/api/test_store.py b/tests/api/test_store.py index cea281e1a..83a3651f2 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -18,8 +18,11 @@ from supervisor.docker.addon import DockerAddon from supervisor.docker.const import ContainerState from supervisor.docker.interface import DockerInterface from supervisor.docker.monitor import DockerContainerStateEvent +from supervisor.exceptions import StoreGitError from supervisor.homeassistant.const import WSEvent from supervisor.homeassistant.module import HomeAssistant +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion from supervisor.store.addon import AddonStore from supervisor.store.repository import Repository @@ -124,6 +127,81 @@ async def test_api_store_remove_repository( assert test_repository.slug not in coresys.store.repositories +@pytest.mark.parametrize("repo", ["core", "a474bbd1"]) +@pytest.mark.usefixtures("test_repository") +async def test_api_store_repair_repository(api_client: TestClient, repo: str): + """Test POST /store/repositories/{repository}/repair REST API.""" + with patch("supervisor.store.repository.RepositoryGit.reset") as mock_reset: + response = await api_client.post(f"/store/repositories/{repo}/repair") + + assert response.status == 200 + mock_reset.assert_called_once() + + +@pytest.mark.parametrize( + "issue_type", [IssueType.CORRUPT_REPOSITORY, IssueType.FATAL_ERROR] +) +@pytest.mark.usefixtures("test_repository") +async def test_api_store_repair_repository_removes_suggestion( + api_client: TestClient, + coresys: CoreSys, + test_repository: Repository, + issue_type: IssueType, +): + """Test POST /store/repositories/core/repair REST API removes EXECUTE_RESET suggestions.""" + issue = Issue(issue_type, ContextType.STORE, reference=test_repository.slug) + suggestion = Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.STORE, reference=test_repository.slug + ) + coresys.resolution.add_issue(issue, suggestions=[SuggestionType.EXECUTE_RESET]) + with patch("supervisor.store.repository.RepositoryGit.reset") as mock_reset: + response = await api_client.post( + f"/store/repositories/{test_repository.slug}/repair" + ) + + assert response.status == 200 + mock_reset.assert_called_once() + assert issue not in coresys.resolution.issues + assert suggestion not in coresys.resolution.suggestions + + +@pytest.mark.usefixtures("test_repository") +async def test_api_store_repair_repository_local_fail(api_client: TestClient): + """Test POST /store/repositories/local/repair REST API fails.""" + response = await api_client.post("/store/repositories/local/repair") + + assert response.status == 400 + result = await response.json() + assert result["error_key"] == "store_repository_local_cannot_reset" + assert result["extra_fields"] == {"local_repo": "local"} + assert result["message"] == "Can't reset repository local as it is not git based!" + + +async def test_api_store_repair_repository_git_error( + api_client: TestClient, test_repository: Repository +): + """Test POST /store/repositories/{repository}/repair REST API git error.""" + with patch( + "supervisor.store.git.GitRepo.reset", + side_effect=StoreGitError("Git error"), + ): + response = await api_client.post( + f"/store/repositories/{test_repository.slug}/repair" + ) + + assert response.status == 500 + result = await response.json() + assert result["error_key"] == "store_repository_unknown_error" + assert result["extra_fields"] == { + "repo": test_repository.slug, + "logs_command": "ha supervisor logs", + } + assert ( + result["message"] + == f"An unknown error occurred with addon repository {test_repository.slug}. Check supervisor logs for details (check with 'ha supervisor logs')" + ) + + async def test_api_store_update_healthcheck( api_client: TestClient, coresys: CoreSys,