mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-08-16 18:03:20 +01:00
* Offer reset for corrupt store repositories on load When a saved git store repository loads but fails validation (for example because the local clone lost its repository configuration file), the only suggestion offered was to remove the repository. Removal is refused when an installed add-on still uses that repository, leaving the user with no way to recover a corrupt local copy. Offer a reset alongside removal so the repository can re-clone and self-heal. The reset fixup runs automatically, matching how the pull path already handles corruption. This branch is only reached for repositories that validated before, so a now-invalid copy is most likely local corruption that re-cloning fixes. * Re-validate repository after reset A reset only recovers a corrupt local copy of a repository. If the freshly cloned repository still doesn't validate, the problem is upstream (for example the repository configuration was removed). In that case the reset fixup would previously consider the reset successful and dismiss the issue, hiding a persistent problem and re-cloning on every run. Re-validate the repository after a reset and raise when it is still invalid, so the issue stays surfaced to the user instead of being silently dismissed. * Report invalid repository after reset as a known error When a manual repository reset re-clones successfully but the repository still fails validation, the failure mode is known: it isn't a valid add-on repository. Raising StoreRepositoryUnknownError reported this as an unknown error (HTTP 500), which is misleading for users triggering a reset through the API. Raise StoreInvalidAppRepo instead, which carries a clear message and maps to a 400. It is still a StoreError, so the reset fixup keeps the issue surfaced as before. * Stop auto-retrying reset when repository stays invalid When a reset re-clones a repository successfully but it still fails validation, the problem is upstream and retrying won't help. The reset fixup runs automatically on every hourly resolution healthcheck as long as a reset suggestion exists, which would re-clone such a repository every hour with no chance of recovery. Drop the reset suggestion in that case so the auto-retry stops, while leaving the issue and its remove suggestion in place so the user stays informed and can still act. * Update supervisor/store/repository.py * Adapt reset-suggestion dismissal to Suggestion-typed process_fixup Main changed process_fixup to receive the applied Suggestion object instead of a reference string (#6916). The branch predates that change and still used the removed reference name, which the rebase merged cleanly but left as an undefined variable. Since the caller passes the exact suggestion being applied, dismiss it directly instead of scanning all_suggestions for a matching reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Stefan Agner <stefan@agner.ch> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
193 lines
6.9 KiB
Python
193 lines
6.9 KiB
Python
"""Test evaluation base."""
|
|
|
|
# pylint: disable=import-error,protected-access
|
|
import errno
|
|
from pathlib import Path
|
|
from unittest.mock import PropertyMock, patch
|
|
|
|
import pytest
|
|
|
|
from supervisor.config import CoreConfig
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.exceptions import StoreGitCloneError
|
|
from supervisor.resolution.const import (
|
|
ContextType,
|
|
IssueType,
|
|
SuggestionType,
|
|
UnhealthyReason,
|
|
)
|
|
from supervisor.resolution.data import Issue, Suggestion
|
|
from supervisor.resolution.fixups.store_execute_reset import FixupStoreExecuteReset
|
|
from supervisor.store.git import GitRepo
|
|
from supervisor.store.repository import Repository, RepositoryGit
|
|
|
|
|
|
@pytest.fixture(name="mock_addons_git", autouse=True)
|
|
async def fixture_mock_apps_git(tmp_supervisor_data: Path) -> None:
|
|
"""Mock apps git path."""
|
|
with patch.object(
|
|
CoreConfig,
|
|
"path_apps_git",
|
|
new=PropertyMock(return_value=tmp_supervisor_data / "apps" / "git"),
|
|
):
|
|
yield
|
|
|
|
|
|
def add_store_reset_suggestion(coresys: CoreSys) -> None:
|
|
"""Add suggestion for tests."""
|
|
coresys.resolution.add_suggestion(
|
|
Suggestion(
|
|
SuggestionType.EXECUTE_RESET, ContextType.STORE, reference="94cfad5a"
|
|
)
|
|
)
|
|
coresys.resolution.add_issue(
|
|
Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference="94cfad5a")
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("supervisor_internet")
|
|
async def test_fixup(coresys: CoreSys):
|
|
"""Test fixup."""
|
|
store_execute_reset = FixupStoreExecuteReset(coresys)
|
|
test_repo = coresys.config.path_apps_git / "94cfad5a"
|
|
|
|
assert store_execute_reset.auto
|
|
|
|
add_store_reset_suggestion(coresys)
|
|
test_repo.mkdir(parents=True)
|
|
good_marker = test_repo / ".git"
|
|
(corrupt_marker := (test_repo / "corrupt")).touch()
|
|
assert test_repo.exists()
|
|
assert not good_marker.exists()
|
|
assert corrupt_marker.exists()
|
|
|
|
async def mock_clone(obj: GitRepo, path: Path | None = None):
|
|
"""Mock of clone method."""
|
|
path = path or obj.path
|
|
await coresys.run_in_executor((path / ".git").mkdir)
|
|
|
|
coresys.store.repositories["94cfad5a"] = Repository.create(
|
|
coresys, "https://github.com/home-assistant/addons-example"
|
|
)
|
|
with (
|
|
patch.object(GitRepo, "load"),
|
|
patch.object(GitRepo, "_clone", new=mock_clone),
|
|
patch.object(RepositoryGit, "validate", return_value=True),
|
|
patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))),
|
|
):
|
|
await store_execute_reset()
|
|
|
|
assert test_repo.exists()
|
|
assert good_marker.exists()
|
|
assert not corrupt_marker.exists()
|
|
assert len(coresys.resolution.suggestions) == 0
|
|
assert len(coresys.resolution.issues) == 0
|
|
assert len(list(coresys.config.path_tmp.iterdir())) == 0
|
|
|
|
|
|
@pytest.mark.usefixtures("supervisor_internet")
|
|
async def test_fixup_still_invalid_after_reset(coresys: CoreSys):
|
|
"""Test reset suggestion is dropped but issue kept when repo stays invalid."""
|
|
store_execute_reset = FixupStoreExecuteReset(coresys)
|
|
test_repo = coresys.config.path_apps_git / "94cfad5a"
|
|
|
|
add_store_reset_suggestion(coresys)
|
|
# A remove suggestion is offered alongside reset for corrupt repositories
|
|
coresys.resolution.add_suggestion(
|
|
Suggestion(
|
|
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference="94cfad5a"
|
|
)
|
|
)
|
|
test_repo.mkdir(parents=True)
|
|
|
|
async def mock_clone(obj: GitRepo, path: Path | None = None):
|
|
"""Mock of clone method."""
|
|
path = path or obj.path
|
|
await coresys.run_in_executor((path / ".git").mkdir)
|
|
|
|
coresys.store.repositories["94cfad5a"] = Repository.create(
|
|
coresys, "https://github.com/home-assistant/addons-example"
|
|
)
|
|
with (
|
|
patch.object(GitRepo, "load"),
|
|
patch.object(GitRepo, "_clone", new=mock_clone),
|
|
# Repository re-clones fine but its content is still not valid
|
|
patch.object(RepositoryGit, "validate", return_value=False),
|
|
patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))),
|
|
):
|
|
await store_execute_reset()
|
|
|
|
# Retrying the reset won't help, so its suggestion is dropped to stop the
|
|
# hourly auto-retry. The issue and the remove suggestion must remain.
|
|
suggestion_types = {
|
|
suggestion.type for suggestion in coresys.resolution.suggestions
|
|
}
|
|
assert SuggestionType.EXECUTE_RESET not in suggestion_types
|
|
assert SuggestionType.EXECUTE_REMOVE in suggestion_types
|
|
assert len(coresys.resolution.issues) == 1
|
|
assert len(list(coresys.config.path_tmp.iterdir())) == 0
|
|
|
|
|
|
@pytest.mark.usefixtures("supervisor_internet")
|
|
async def test_fixup_clone_fail(coresys: CoreSys):
|
|
"""Test fixup does not delete cache when clone fails."""
|
|
store_execute_reset = FixupStoreExecuteReset(coresys)
|
|
test_repo = coresys.config.path_apps_git / "94cfad5a"
|
|
|
|
add_store_reset_suggestion(coresys)
|
|
test_repo.mkdir(parents=True)
|
|
(corrupt_marker := (test_repo / "corrupt")).touch()
|
|
assert test_repo.exists()
|
|
assert corrupt_marker.exists()
|
|
|
|
coresys.store.repositories["94cfad5a"] = Repository.create(
|
|
coresys, "https://github.com/home-assistant/addons-example"
|
|
)
|
|
with (
|
|
patch.object(GitRepo, "load"),
|
|
patch.object(GitRepo, "_clone", side_effect=StoreGitCloneError),
|
|
patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))),
|
|
):
|
|
await store_execute_reset()
|
|
|
|
assert test_repo.exists()
|
|
assert corrupt_marker.exists()
|
|
assert len(coresys.resolution.suggestions) == 1
|
|
assert len(coresys.resolution.issues) == 1
|
|
assert len(list(coresys.config.path_tmp.iterdir())) == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error_num", "unhealthy"), [(errno.EBUSY, False), (errno.EBADMSG, True)]
|
|
)
|
|
@pytest.mark.usefixtures("supervisor_internet")
|
|
async def test_fixup_move_fail(coresys: CoreSys, error_num: int, unhealthy: bool):
|
|
"""Test fixup cleans up clone on move fail.
|
|
|
|
This scenario shouldn't really happen unless something is pretty wrong with the system.
|
|
It will leave the user in a bind without the git cache but at least we try to clean up tmp.
|
|
"""
|
|
store_execute_reset = FixupStoreExecuteReset(coresys)
|
|
test_repo = coresys.config.path_apps_git / "94cfad5a"
|
|
|
|
add_store_reset_suggestion(coresys)
|
|
test_repo.mkdir(parents=True)
|
|
coresys.store.repositories["94cfad5a"] = Repository.create(
|
|
coresys, "https://github.com/home-assistant/addons-example"
|
|
)
|
|
with (
|
|
patch.object(GitRepo, "load"),
|
|
patch.object(GitRepo, "_clone"),
|
|
patch("supervisor.store.git.Path.rename", side_effect=(err := OSError())),
|
|
patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))),
|
|
):
|
|
err.errno = error_num
|
|
await store_execute_reset()
|
|
|
|
assert len(coresys.resolution.suggestions) == 1
|
|
assert len(coresys.resolution.issues) == 1
|
|
assert len(list(coresys.config.path_tmp.iterdir())) == 0
|
|
assert (
|
|
UnhealthyReason.OSERROR_BAD_MESSAGE in coresys.resolution.unhealthy
|
|
) is unhealthy
|