Files
supervisor/tests/resolution/fixup/test_store_execute_reset.py
T
a3e1752815 Offer reset for corrupt store repositories on load (#6958)
* 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>
2026-07-23 15:16:05 +02:00

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