From d5f9fcfdc7541adeb736fadd143c3c53d7fed148 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 25 Aug 2022 05:42:31 -0400 Subject: [PATCH] Fire events on issue changes (#3818) * Fire events on issue changes * API includes if suggestion has autofix --- supervisor/api/__init__.py | 4 + supervisor/api/resolution.py | 32 +++++- supervisor/const.py | 3 + supervisor/resolution/fixup.py | 22 ++++- supervisor/resolution/fixups/base.py | 34 ++++--- supervisor/resolution/module.py | 44 +++++++-- tests/api/test_resolution.py | 27 +++++ tests/resolution/test_resolution_manager.py | 103 +++++++++++++++++++- 8 files changed, 244 insertions(+), 25 deletions(-) diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 57df58769..a4f1a675b 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -277,6 +277,10 @@ class RestAPI(CoreSysAttributes): "/resolution/issue/{issue}", api_resolution.dismiss_issue, ), + web.get( + "/resolution/issue/{issue}/suggestions", + api_resolution.suggestions_for_issue, + ), web.post("/resolution/healthcheck", api_resolution.healthcheck), ] ) diff --git a/supervisor/api/resolution.py b/supervisor/api/resolution.py index 5785e851d..d9b62f494 100644 --- a/supervisor/api/resolution.py +++ b/supervisor/api/resolution.py @@ -7,6 +7,7 @@ import attr import voluptuous as vol from ..const import ( + ATTR_AUTO, ATTR_CHECKS, ATTR_ENABLED, ATTR_ISSUES, @@ -17,6 +18,7 @@ from ..const import ( ) from ..coresys import CoreSysAttributes from ..exceptions import APIError, ResolutionNotFound +from ..resolution.data import Suggestion from .utils import api_process, api_validate SCHEMA_CHECK_OPTIONS = vol.Schema({vol.Optional(ATTR_ENABLED): bool}) @@ -25,14 +27,26 @@ SCHEMA_CHECK_OPTIONS = vol.Schema({vol.Optional(ATTR_ENABLED): bool}) class APIResoulution(CoreSysAttributes): """Handle REST API for resoulution.""" + def _generate_suggestion_information(self, suggestion: Suggestion): + """Generate suggestion information for response.""" + resp = attr.asdict(suggestion) + resp[ATTR_AUTO] = bool( + [ + fix + for fix in self.sys_resolution.fixup.fixes_for_suggestion(suggestion) + if fix.auto + ] + ) + return resp + @api_process async def info(self, request: web.Request) -> dict[str, Any]: - """Return network information.""" + """Return resolution information.""" return { ATTR_UNSUPPORTED: self.sys_resolution.unsupported, ATTR_UNHEALTHY: self.sys_resolution.unhealthy, ATTR_SUGGESTIONS: [ - attr.asdict(suggestion) + self._generate_suggestion_information(suggestion) for suggestion in self.sys_resolution.suggestions ], ATTR_ISSUES: [attr.asdict(issue) for issue in self.sys_resolution.issues], @@ -64,6 +78,20 @@ class APIResoulution(CoreSysAttributes): except ResolutionNotFound: raise APIError("The supplied UUID is not a valid suggestion") from None + @api_process + async def suggestions_for_issue(self, request: web.Request) -> dict[str, Any]: + """Return suggestions that fix an issue.""" + try: + issue = self.sys_resolution.get_issue(request.match_info.get("issue")) + return { + ATTR_SUGGESTIONS: [ + self._generate_suggestion_information(suggestion) + for suggestion in self.sys_resolution.suggestions_for_issue(issue) + ] + } + except ResolutionNotFound: + raise APIError("The supplied UUID is not a valid issue") from None + @api_process async def dismiss_issue(self, request: web.Request) -> None: """Dismiss issue.""" diff --git a/supervisor/const.py b/supervisor/const.py index c8537f69b..12a7c14f6 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -97,6 +97,7 @@ ATTR_AUDIO_INPUT = "audio_input" ATTR_AUDIO_OUTPUT = "audio_output" ATTR_AUTH = "auth" ATTR_AUTH_API = "auth_api" +ATTR_AUTO = "auto" ATTR_AUTO_UPDATE = "auto_update" ATTR_AVAILABLE = "available" ATTR_BACKUP = "backup" @@ -441,6 +442,8 @@ class BusEvent(str, Enum): HARDWARE_NEW_DEVICE = "hardware_new_device" HARDWARE_REMOVE_DEVICE = "hardware_remove_device" + ISSUE_CHANGED = "issue_changed" + ISSUE_REMOVED = "issue_removed" DOCKER_CONTAINER_STATE_CHANGE = "docker_container_state_change" diff --git a/supervisor/resolution/fixup.py b/supervisor/resolution/fixup.py index 0a20df24e..062f8bb3d 100644 --- a/supervisor/resolution/fixup.py +++ b/supervisor/resolution/fixup.py @@ -5,7 +5,7 @@ import logging from ..coresys import CoreSys, CoreSysAttributes from ..jobs.const import JobCondition from ..jobs.decorator import Job -from .data import Suggestion +from .data import Issue, Suggestion from .fixups.base import FixupBase from .validate import get_valid_modules @@ -51,9 +51,23 @@ class ResolutionFixup(CoreSysAttributes): _LOGGER.info("System autofix complete") + def fixes_for_suggestion(self, suggestion: Suggestion) -> list[FixupBase]: + """Get fixups to run if the suggestion is applied.""" + return [ + fix + for fix in self.all_fixes + if fix.suggestion == suggestion.type and fix.context == suggestion.context + ] + + def fixes_for_issue(self, issue: Issue) -> list[FixupBase]: + """Get fixups that would fix the issue if run.""" + return [ + fix + for fix in self.all_fixes + if issue.type in fix.issues and issue.context == fix.context + ] + async def apply_fixup(self, suggestion: Suggestion) -> None: """Apply a fixup for a suggestion.""" - for fix in self.all_fixes: - if fix.suggestion != suggestion.type or fix.context != suggestion.context: - continue + for fix in self.fixes_for_suggestion(suggestion): await fix() diff --git a/supervisor/resolution/fixups/base.py b/supervisor/resolution/fixups/base.py index 5d8b412a2..6e30cb16d 100644 --- a/supervisor/resolution/fixups/base.py +++ b/supervisor/resolution/fixups/base.py @@ -20,12 +20,7 @@ class FixupBase(ABC, CoreSysAttributes): async def __call__(self) -> None: """Execute the evaluation.""" # Get suggestion to fix - fixing_suggestion: Suggestion | None = None - for suggestion in self.sys_resolution.suggestions: - if suggestion.type != self.suggestion or suggestion.context != self.context: - continue - fixing_suggestion = suggestion - break + fixing_suggestion: Suggestion | None = next(iter(self.all_suggestions), None) # No suggestion if fixing_suggestion is None: @@ -38,15 +33,12 @@ class FixupBase(ABC, CoreSysAttributes): except ResolutionFixupError: return - self.sys_resolution.dismiss_suggestion(fixing_suggestion) - # Cleanup issue - for issue_type in self.issues: - issue = Issue(issue_type, self.context, fixing_suggestion.reference) - if issue not in self.sys_resolution.issues: - continue + for issue in self.sys_resolution.issues_for_suggestion(fixing_suggestion): self.sys_resolution.dismiss_issue(issue) + self.sys_resolution.dismiss_suggestion(fixing_suggestion) + @abstractmethod async def process_fixup(self, reference: str | None = None) -> None: """Run processing of fixup.""" @@ -71,6 +63,24 @@ class FixupBase(ABC, CoreSysAttributes): """Return if a fixup can be apply as auto fix.""" return False + @property + def all_suggestions(self) -> list[Suggestion]: + """List of all suggestions which when applied run this fixup.""" + return [ + suggestion + for suggestion in self.sys_resolution.suggestions + if suggestion.type == self.suggestion and suggestion.context == self.context + ] + + @property + def all_issues(self) -> list[Issue]: + """List of all issues which could be fixed by this fixup.""" + return [ + issue + for issue in self.sys_resolution.issues + if issue.type in self.issues and issue.context == self.context + ] + @property def slug(self) -> str: """Return the check slug.""" diff --git a/supervisor/resolution/module.py b/supervisor/resolution/module.py index d361675b9..dcf4334a9 100644 --- a/supervisor/resolution/module.py +++ b/supervisor/resolution/module.py @@ -2,6 +2,7 @@ import logging from typing import Any +from ..const import BusEvent from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ResolutionError, ResolutionNotFound from ..utils.common import FileConfiguration @@ -82,6 +83,9 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): ) self._issues.append(issue) + # Event on issue creation + self.sys_bus.fire_event(BusEvent.ISSUE_CHANGED, issue) + @property def suggestions(self) -> list[Suggestion]: """Return a list of suggestions that can handled.""" @@ -92,6 +96,7 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): """Add suggestion.""" if suggestion in self._suggestions: return + _LOGGER.info( "Create new suggestion %s - %s / %s", suggestion.type, @@ -100,6 +105,10 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): ) self._suggestions.append(suggestion) + # Event on suggestion added to issue + for issue in self.issues_for_suggestion(suggestion): + self.sys_bus.fire_event(BusEvent.ISSUE_CHANGED, issue) + @property def unsupported(self) -> list[UnsupportedReason]: """Return a list of unsupported reasons.""" @@ -146,13 +155,11 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): suggestions: list[SuggestionType] | None = None, ) -> None: """Create issues and suggestion.""" - self.issues = Issue(issue, context, reference) - if not suggestions: - return + if suggestions: + for suggestion in suggestions: + self.suggestions = Suggestion(suggestion, context, reference) - # Add suggestions - for suggestion in suggestions: - self.suggestions = Suggestion(suggestion, context, reference) + self.issues = Issue(issue, context, reference) async def load(self): """Load the resoulution manager.""" @@ -191,6 +198,10 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): ) self._suggestions.remove(suggestion) + # Event on suggestion removed from issues + for issue in self.issues_for_suggestion(suggestion): + self.sys_bus.fire_event(BusEvent.ISSUE_CHANGED, issue) + def dismiss_issue(self, issue: Issue) -> None: """Dismiss suggested action.""" if issue not in self._issues: @@ -199,8 +210,29 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): ) self._issues.remove(issue) + # Event on issue removal + self.sys_bus.fire_event(BusEvent.ISSUE_REMOVED, issue) + def dismiss_unsupported(self, reason: Issue) -> None: """Dismiss a reason for unsupported.""" if reason not in self._unsupported: raise ResolutionError(f"The reason {reason} is not active", _LOGGER.warning) self._unsupported.remove(reason) + + def suggestions_for_issue(self, issue: Issue) -> set[Suggestion]: + """Get suggestions that fix an issue.""" + return { + suggestion + for fix in self.fixup.fixes_for_issue(issue) + for suggestion in fix.all_suggestions + if suggestion.reference == issue.reference + } + + def issues_for_suggestion(self, suggestion: Suggestion) -> set[Issue]: + """Get issues fixed by a suggestion.""" + return { + issue + for fix in self.fixup.fixes_for_suggestion(suggestion) + for issue in fix.all_issues + if issue.reference == suggestion.reference + } diff --git a/tests/api/test_resolution.py b/tests/api/test_resolution.py index cd841b77f..516266e4d 100644 --- a/tests/api/test_resolution.py +++ b/tests/api/test_resolution.py @@ -130,3 +130,30 @@ async def test_api_resolution_check_run(coresys: CoreSys, api_client): await api_client.post(f"/resolution/check/{free_space.slug}/run") assert free_space.run_check.called + + +async def test_api_resolution_suggestions_for_issue(coresys: CoreSys, api_client): + """Test getting suggestions that fix an issue.""" + coresys.resolution.issues = corrupt_repo = Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "repo_1" + ) + + resp = await api_client.get(f"/resolution/issue/{corrupt_repo.uuid}/suggestions") + result = await resp.json() + + assert result["data"]["suggestions"] == [] + + coresys.resolution.suggestions = execute_reset = Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.STORE, "repo_1" + ) + coresys.resolution.suggestions = execute_remove = Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_1" + ) + + resp = await api_client.get(f"/resolution/issue/{corrupt_repo.uuid}/suggestions") + result = await resp.json() + + assert result["data"]["suggestions"][0]["uuid"] == execute_reset.uuid + assert result["data"]["suggestions"][0]["auto"] is True + assert result["data"]["suggestions"][1]["uuid"] == execute_remove.uuid + assert result["data"]["suggestions"][1]["auto"] is False diff --git a/tests/resolution/test_resolution_manager.py b/tests/resolution/test_resolution_manager.py index 328b227e7..b134c0497 100644 --- a/tests/resolution/test_resolution_manager.py +++ b/tests/resolution/test_resolution_manager.py @@ -1,8 +1,9 @@ """Tests for resolution manager.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest +from supervisor.const import BusEvent from supervisor.coresys import CoreSys from supervisor.exceptions import ResolutionError from supervisor.resolution.const import ( @@ -119,3 +120,103 @@ async def test_resolution_dismiss_unsupported(coresys: CoreSys): with pytest.raises(ResolutionError): coresys.resolution.dismiss_unsupported(UnsupportedReason.SOFTWARE) + + +async def test_suggestions_for_issue(coresys: CoreSys): + """Test getting suggestions that fix an issue.""" + coresys.resolution.issues = corrupt_repo = Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo" + ) + + # Unrelated suggestions don't appear + coresys.resolution.suggestions = Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.SUPERVISOR + ) + coresys.resolution.suggestions = Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "other_repo" + ) + + assert coresys.resolution.suggestions_for_issue(corrupt_repo) == set() + + # Related suggestions do + coresys.resolution.suggestions = execute_remove = Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo" + ) + coresys.resolution.suggestions = execute_reset = Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo" + ) + + assert coresys.resolution.suggestions_for_issue(corrupt_repo) == { + execute_reset, + execute_remove, + } + + +async def test_issues_for_suggestion(coresys: CoreSys): + """Test getting issues fixed by a suggestion.""" + coresys.resolution.suggestions = execute_reset = Suggestion( + SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo" + ) + + # Unrelated issues don't appear + coresys.resolution.issues = Issue(IssueType.FATAL_ERROR, ContextType.CORE) + coresys.resolution.issues = Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "other_repo" + ) + + assert coresys.resolution.issues_for_suggestion(execute_reset) == set() + + # Related issues do + coresys.resolution.issues = fatal_error = Issue( + IssueType.FATAL_ERROR, ContextType.STORE, "test_repo" + ) + coresys.resolution.issues = corrupt_repo = Issue( + IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo" + ) + + assert coresys.resolution.issues_for_suggestion(execute_reset) == { + fatal_error, + corrupt_repo, + } + + +async def test_events_on_issue_changes(coresys: CoreSys): + """Test events fired when an issue changes.""" + coresys.bus.register_event(BusEvent.ISSUE_CHANGED, change_handler := AsyncMock()) + coresys.bus.register_event(BusEvent.ISSUE_REMOVED, remove_handler := AsyncMock()) + + # Creating an issue with a suggestion should fire exactly one event + assert coresys.resolution.issues == [] + assert coresys.resolution.suggestions == [] + coresys.resolution.create_issue( + IssueType.CORRUPT_REPOSITORY, + ContextType.STORE, + "test_repo", + [SuggestionType.EXECUTE_RESET], + ) + + assert len(coresys.resolution.issues) == 1 + assert len(coresys.resolution.suggestions) == 1 + issue = coresys.resolution.issues[0] + suggestion = coresys.resolution.suggestions[0] + change_handler.assert_called_once_with(issue) + + # Adding and removing a suggestion that fixes the issue should fire another + change_handler.reset_mock() + coresys.resolution.suggestions = execute_remove = Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo" + ) + change_handler.assert_called_once_with(issue) + + change_handler.reset_mock() + coresys.resolution.dismiss_suggestion(execute_remove) + change_handler.assert_called_once_with(issue) + remove_handler.assert_not_called() + + # Applying a suggestion should only fire the issue removed event + change_handler.reset_mock() + with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))): + await coresys.resolution.apply_suggestion(suggestion) + + change_handler.assert_not_called() + remove_handler.assert_called_once_with(issue)