1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2025-12-24 12:29:08 +00:00

Add API to force repository repair

This commit is contained in:
Mike Degatano
2025-12-18 23:14:54 +00:00
parent 75cf60f0d6
commit 17bbc85a35
5 changed files with 139 additions and 6 deletions

View File

@@ -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,
),
]
)

View File

@@ -7,6 +7,8 @@ from typing import Any, cast
from aiohttp import web
import voluptuous as vol
from supervisor.resolution.const import ContextType, SuggestionType
from ..addons.addon import Addon
from ..addons.manager import AnyAddon
from ..addons.utils import rating_security
@@ -359,3 +361,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

View File

@@ -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(AddonsError, 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

View File

@@ -19,7 +19,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.common import read_json_or_yaml_file
from .const import BuiltinRepository
from .git import GitRepo
@@ -198,8 +204,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):
@@ -238,9 +248,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):

View File

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