Fire events on issue changes (#3818)

* Fire events on issue changes

* API includes if suggestion has autofix
This commit is contained in:
Mike Degatano 2022-08-25 05:42:31 -04:00 committed by GitHub
parent ffa524d3a4
commit d5f9fcfdc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 244 additions and 25 deletions

View File

@ -277,6 +277,10 @@ class RestAPI(CoreSysAttributes):
"/resolution/issue/{issue}", "/resolution/issue/{issue}",
api_resolution.dismiss_issue, api_resolution.dismiss_issue,
), ),
web.get(
"/resolution/issue/{issue}/suggestions",
api_resolution.suggestions_for_issue,
),
web.post("/resolution/healthcheck", api_resolution.healthcheck), web.post("/resolution/healthcheck", api_resolution.healthcheck),
] ]
) )

View File

@ -7,6 +7,7 @@ import attr
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import (
ATTR_AUTO,
ATTR_CHECKS, ATTR_CHECKS,
ATTR_ENABLED, ATTR_ENABLED,
ATTR_ISSUES, ATTR_ISSUES,
@ -17,6 +18,7 @@ from ..const import (
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError, ResolutionNotFound from ..exceptions import APIError, ResolutionNotFound
from ..resolution.data import Suggestion
from .utils import api_process, api_validate from .utils import api_process, api_validate
SCHEMA_CHECK_OPTIONS = vol.Schema({vol.Optional(ATTR_ENABLED): bool}) 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): class APIResoulution(CoreSysAttributes):
"""Handle REST API for resoulution.""" """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 @api_process
async def info(self, request: web.Request) -> dict[str, Any]: async def info(self, request: web.Request) -> dict[str, Any]:
"""Return network information.""" """Return resolution information."""
return { return {
ATTR_UNSUPPORTED: self.sys_resolution.unsupported, ATTR_UNSUPPORTED: self.sys_resolution.unsupported,
ATTR_UNHEALTHY: self.sys_resolution.unhealthy, ATTR_UNHEALTHY: self.sys_resolution.unhealthy,
ATTR_SUGGESTIONS: [ ATTR_SUGGESTIONS: [
attr.asdict(suggestion) self._generate_suggestion_information(suggestion)
for suggestion in self.sys_resolution.suggestions for suggestion in self.sys_resolution.suggestions
], ],
ATTR_ISSUES: [attr.asdict(issue) for issue in self.sys_resolution.issues], ATTR_ISSUES: [attr.asdict(issue) for issue in self.sys_resolution.issues],
@ -64,6 +78,20 @@ class APIResoulution(CoreSysAttributes):
except ResolutionNotFound: except ResolutionNotFound:
raise APIError("The supplied UUID is not a valid suggestion") from None 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 @api_process
async def dismiss_issue(self, request: web.Request) -> None: async def dismiss_issue(self, request: web.Request) -> None:
"""Dismiss issue.""" """Dismiss issue."""

View File

@ -97,6 +97,7 @@ ATTR_AUDIO_INPUT = "audio_input"
ATTR_AUDIO_OUTPUT = "audio_output" ATTR_AUDIO_OUTPUT = "audio_output"
ATTR_AUTH = "auth" ATTR_AUTH = "auth"
ATTR_AUTH_API = "auth_api" ATTR_AUTH_API = "auth_api"
ATTR_AUTO = "auto"
ATTR_AUTO_UPDATE = "auto_update" ATTR_AUTO_UPDATE = "auto_update"
ATTR_AVAILABLE = "available" ATTR_AVAILABLE = "available"
ATTR_BACKUP = "backup" ATTR_BACKUP = "backup"
@ -441,6 +442,8 @@ class BusEvent(str, Enum):
HARDWARE_NEW_DEVICE = "hardware_new_device" HARDWARE_NEW_DEVICE = "hardware_new_device"
HARDWARE_REMOVE_DEVICE = "hardware_remove_device" HARDWARE_REMOVE_DEVICE = "hardware_remove_device"
ISSUE_CHANGED = "issue_changed"
ISSUE_REMOVED = "issue_removed"
DOCKER_CONTAINER_STATE_CHANGE = "docker_container_state_change" DOCKER_CONTAINER_STATE_CHANGE = "docker_container_state_change"

View File

@ -5,7 +5,7 @@ import logging
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..jobs.const import JobCondition from ..jobs.const import JobCondition
from ..jobs.decorator import Job from ..jobs.decorator import Job
from .data import Suggestion from .data import Issue, Suggestion
from .fixups.base import FixupBase from .fixups.base import FixupBase
from .validate import get_valid_modules from .validate import get_valid_modules
@ -51,9 +51,23 @@ class ResolutionFixup(CoreSysAttributes):
_LOGGER.info("System autofix complete") _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: async def apply_fixup(self, suggestion: Suggestion) -> None:
"""Apply a fixup for a suggestion.""" """Apply a fixup for a suggestion."""
for fix in self.all_fixes: for fix in self.fixes_for_suggestion(suggestion):
if fix.suggestion != suggestion.type or fix.context != suggestion.context:
continue
await fix() await fix()

View File

@ -20,12 +20,7 @@ class FixupBase(ABC, CoreSysAttributes):
async def __call__(self) -> None: async def __call__(self) -> None:
"""Execute the evaluation.""" """Execute the evaluation."""
# Get suggestion to fix # Get suggestion to fix
fixing_suggestion: Suggestion | None = None fixing_suggestion: Suggestion | None = next(iter(self.all_suggestions), None)
for suggestion in self.sys_resolution.suggestions:
if suggestion.type != self.suggestion or suggestion.context != self.context:
continue
fixing_suggestion = suggestion
break
# No suggestion # No suggestion
if fixing_suggestion is None: if fixing_suggestion is None:
@ -38,15 +33,12 @@ class FixupBase(ABC, CoreSysAttributes):
except ResolutionFixupError: except ResolutionFixupError:
return return
self.sys_resolution.dismiss_suggestion(fixing_suggestion)
# Cleanup issue # Cleanup issue
for issue_type in self.issues: for issue in self.sys_resolution.issues_for_suggestion(fixing_suggestion):
issue = Issue(issue_type, self.context, fixing_suggestion.reference)
if issue not in self.sys_resolution.issues:
continue
self.sys_resolution.dismiss_issue(issue) self.sys_resolution.dismiss_issue(issue)
self.sys_resolution.dismiss_suggestion(fixing_suggestion)
@abstractmethod @abstractmethod
async def process_fixup(self, reference: str | None = None) -> None: async def process_fixup(self, reference: str | None = None) -> None:
"""Run processing of fixup.""" """Run processing of fixup."""
@ -71,6 +63,24 @@ class FixupBase(ABC, CoreSysAttributes):
"""Return if a fixup can be apply as auto fix.""" """Return if a fixup can be apply as auto fix."""
return False 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 @property
def slug(self) -> str: def slug(self) -> str:
"""Return the check slug.""" """Return the check slug."""

View File

@ -2,6 +2,7 @@
import logging import logging
from typing import Any from typing import Any
from ..const import BusEvent
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ResolutionError, ResolutionNotFound from ..exceptions import ResolutionError, ResolutionNotFound
from ..utils.common import FileConfiguration from ..utils.common import FileConfiguration
@ -82,6 +83,9 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
) )
self._issues.append(issue) self._issues.append(issue)
# Event on issue creation
self.sys_bus.fire_event(BusEvent.ISSUE_CHANGED, issue)
@property @property
def suggestions(self) -> list[Suggestion]: def suggestions(self) -> list[Suggestion]:
"""Return a list of suggestions that can handled.""" """Return a list of suggestions that can handled."""
@ -92,6 +96,7 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
"""Add suggestion.""" """Add suggestion."""
if suggestion in self._suggestions: if suggestion in self._suggestions:
return return
_LOGGER.info( _LOGGER.info(
"Create new suggestion %s - %s / %s", "Create new suggestion %s - %s / %s",
suggestion.type, suggestion.type,
@ -100,6 +105,10 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
) )
self._suggestions.append(suggestion) 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 @property
def unsupported(self) -> list[UnsupportedReason]: def unsupported(self) -> list[UnsupportedReason]:
"""Return a list of unsupported reasons.""" """Return a list of unsupported reasons."""
@ -146,13 +155,11 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
suggestions: list[SuggestionType] | None = None, suggestions: list[SuggestionType] | None = None,
) -> None: ) -> None:
"""Create issues and suggestion.""" """Create issues and suggestion."""
self.issues = Issue(issue, context, reference) if suggestions:
if not suggestions: for suggestion in suggestions:
return self.suggestions = Suggestion(suggestion, context, reference)
# Add suggestions self.issues = Issue(issue, context, reference)
for suggestion in suggestions:
self.suggestions = Suggestion(suggestion, context, reference)
async def load(self): async def load(self):
"""Load the resoulution manager.""" """Load the resoulution manager."""
@ -191,6 +198,10 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
) )
self._suggestions.remove(suggestion) 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: def dismiss_issue(self, issue: Issue) -> None:
"""Dismiss suggested action.""" """Dismiss suggested action."""
if issue not in self._issues: if issue not in self._issues:
@ -199,8 +210,29 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes):
) )
self._issues.remove(issue) self._issues.remove(issue)
# Event on issue removal
self.sys_bus.fire_event(BusEvent.ISSUE_REMOVED, issue)
def dismiss_unsupported(self, reason: Issue) -> None: def dismiss_unsupported(self, reason: Issue) -> None:
"""Dismiss a reason for unsupported.""" """Dismiss a reason for unsupported."""
if reason not in self._unsupported: if reason not in self._unsupported:
raise ResolutionError(f"The reason {reason} is not active", _LOGGER.warning) raise ResolutionError(f"The reason {reason} is not active", _LOGGER.warning)
self._unsupported.remove(reason) 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
}

View File

@ -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") await api_client.post(f"/resolution/check/{free_space.slug}/run")
assert free_space.run_check.called 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

View File

@ -1,8 +1,9 @@
"""Tests for resolution manager.""" """Tests for resolution manager."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, patch
import pytest import pytest
from supervisor.const import BusEvent
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.exceptions import ResolutionError from supervisor.exceptions import ResolutionError
from supervisor.resolution.const import ( from supervisor.resolution.const import (
@ -119,3 +120,103 @@ async def test_resolution_dismiss_unsupported(coresys: CoreSys):
with pytest.raises(ResolutionError): with pytest.raises(ResolutionError):
coresys.resolution.dismiss_unsupported(UnsupportedReason.SOFTWARE) 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)