Compare commits

...

4 Commits

Author SHA1 Message Date
Mike Degatano
4bc19ae240 Fix absolute import 2025-12-20 14:01:51 +00:00
Mike Degatano
2c4c76daa5 Fix inheritance for error 2025-12-20 14:01:51 +00:00
Mike Degatano
20ad0a86ba Add API to force repository repair 2025-12-20 14:01:50 +00:00
Mike Degatano
7e39226f42 Remove cosign from container (#6442) 2025-12-20 08:56:36 -05:00
7 changed files with 138 additions and 13 deletions

View File

@@ -7,9 +7,6 @@ ENV \
CRYPTOGRAPHY_OPENSSL_NO_LEGACY=1 \
UV_SYSTEM_PYTHON=true
ARG \
COSIGN_VERSION
# Install base
WORKDIR /usr/src
RUN \
@@ -25,8 +22,6 @@ RUN \
openssl \
yaml \
\
&& curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/cosign \
&& pip3 install uv==0.9.18
# Install requirements

View File

@@ -5,8 +5,6 @@ build_from:
cosign:
base_identity: https://github.com/home-assistant/docker-base/.*
identity: https://github.com/home-assistant/supervisor/.*
args:
COSIGN_VERSION: 2.5.3
labels:
io.hass.type: supervisor
org.opencontainers.image.title: Home Assistant Supervisor

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

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

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(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

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."""
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,