From d119e99001e81692b57a6826f4045b2cd2cb631f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 16 Oct 2020 12:22:32 +0200 Subject: [PATCH] Resolution: extend type and context (#2130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Resolution: extend type and context * fix property * add helper * fix api * fix tests * Fix patch * finish tests * Update supervisor/resolution/const.py Co-authored-by: Joakim Sørensen * Update supervisor/resolution/const.py Co-authored-by: Joakim Sørensen * Fix type * fix lint * Update supervisor/api/resolution.py Co-authored-by: Joakim Sørensen * Update supervisor/resolution/__init__.py Co-authored-by: Joakim Sørensen * Update API & add more tests * Update supervisor/api/resolution.py Co-authored-by: Joakim Sørensen * Update supervisor/resolution/__init__.py Co-authored-by: Joakim Sørensen * Update supervisor/resolution/__init__.py Co-authored-by: Joakim Sørensen * fix black * remove azure ci * fix test * fix tests * fix tests * fix tests p2 Co-authored-by: Joakim Sørensen --- azure-pipelines-ci.yml | 52 ------------- supervisor/api/__init__.py | 11 ++- supervisor/api/resolution.py | 36 ++++++--- supervisor/core.py | 5 +- supervisor/exceptions.py | 11 +++ supervisor/homeassistant/core.py | 6 ++ supervisor/resolution/__init__.py | 80 +++++++++++++++----- supervisor/resolution/const.py | 19 ++++- supervisor/resolution/data.py | 27 +++++++ supervisor/resolution/free_space.py | 21 ++++-- supervisor/resolution/notify.py | 10 +-- supervisor/supervisor.py | 4 + tests/api/test_resolution.py | 75 ++++++++++++++----- tests/resolution/test_resolution_manager.py | 82 ++++++++++++++++++++- 14 files changed, 323 insertions(+), 116 deletions(-) delete mode 100644 azure-pipelines-ci.yml create mode 100644 supervisor/resolution/data.py diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml deleted file mode 100644 index 5db93f9e0..000000000 --- a/azure-pipelines-ci.yml +++ /dev/null @@ -1,52 +0,0 @@ -# https://dev.azure.com/home-assistant - -trigger: - batch: true - branches: - include: - - master - - dev -pr: - - dev -variables: - - name: versionHadolint - value: "v1.16.3" - -jobs: - - job: "Tox" - pool: - vmImage: "ubuntu-latest" - steps: - - script: | - sudo apt-get update - sudo apt-get install -y libpulse0 libudev1 - displayName: "Install Host library" - - task: UsePythonVersion@0 - displayName: "Use Python 3.8" - inputs: - versionSpec: "3.8" - - script: pip install tox - displayName: "Install Tox" - - script: tox - displayName: "Run Tox" - - job: "JQ" - pool: - vmImage: "ubuntu-latest" - steps: - - script: sudo apt-get install -y jq - displayName: "Install JQ" - - bash: | - shopt -s globstar - cat **/*.json | jq '.' - displayName: "Run JQ" - - job: "Hadolint" - pool: - vmImage: "ubuntu-latest" - steps: - - script: sudo docker pull hadolint/hadolint:$(versionHadolint) - displayName: "Install Hadolint" - - script: | - sudo docker run --rm -i \ - -v $(pwd)/.hadolint.yaml:/.hadolint.yaml:ro \ - hadolint/hadolint:$(versionHadolint) < Dockerfile - displayName: "Run Hadolint" diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index bbcd575b6..b3562f64b 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -200,11 +200,18 @@ class RestAPI(CoreSysAttributes): self.webapp.add_routes( [ web.get("/resolution", api_resolution.base), - web.post("/resolution/{suggestion}", api_resolution.apply_suggestion), web.post( - "/resolution/{suggestion}/dismiss", + "/resolution/suggestion/{suggestion}", + api_resolution.apply_suggestion, + ), + web.delete( + "/resolution/suggestion/{suggestion}", api_resolution.dismiss_suggestion, ), + web.delete( + "/resolution/issue/{issue}", + api_resolution.dismiss_issue, + ), ] ) diff --git a/supervisor/api/resolution.py b/supervisor/api/resolution.py index 804cf9de0..b2845471c 100644 --- a/supervisor/api/resolution.py +++ b/supervisor/api/resolution.py @@ -2,11 +2,11 @@ from typing import Any, Dict from aiohttp import web +import attr from ..const import ATTR_ISSUES, ATTR_SUGGESTIONS, ATTR_UNSUPPORTED from ..coresys import CoreSysAttributes -from ..exceptions import APIError -from ..resolution.const import Suggestion +from ..exceptions import APIError, ResolutionNotFound from .utils import api_process @@ -18,24 +18,40 @@ class APIResoulution(CoreSysAttributes): """Return network information.""" return { ATTR_UNSUPPORTED: self.sys_resolution.unsupported, - ATTR_SUGGESTIONS: self.sys_resolution.suggestions, - ATTR_ISSUES: self.sys_resolution.issues, + ATTR_SUGGESTIONS: [ + attr.asdict(suggestion) + for suggestion in self.sys_resolution.suggestions + ], + ATTR_ISSUES: [attr.asdict(issue) for issue in self.sys_resolution.issues], } @api_process async def apply_suggestion(self, request: web.Request) -> None: """Apply suggestion.""" try: - suggestion = Suggestion(request.match_info.get("suggestion")) + suggestion = self.sys_resolution.get_suggestion( + request.match_info.get("suggestion") + ) await self.sys_resolution.apply_suggestion(suggestion) - except ValueError: - raise APIError("Suggestion is not valid") from None + except ResolutionNotFound: + raise APIError("The supplied UUID is not a valid suggestion") from None @api_process async def dismiss_suggestion(self, request: web.Request) -> None: """Dismiss suggestion.""" try: - suggestion = Suggestion(request.match_info.get("suggestion")) + suggestion = self.sys_resolution.get_suggestion( + request.match_info.get("suggestion") + ) await self.sys_resolution.dismiss_suggestion(suggestion) - except ValueError: - raise APIError("Suggestion is not valid") from None + except ResolutionNotFound: + raise APIError("The supplied UUID is not a valid suggestion") from None + + @api_process + async def dismiss_issue(self, request: web.Request) -> None: + """Dismiss issue.""" + try: + issue = self.sys_resolution.get_issue(request.match_info.get("issue")) + await self.sys_resolution.dismiss_issue(issue) + except ResolutionNotFound: + raise APIError("The supplied UUID is not a valid issue") from None diff --git a/supervisor/core.py b/supervisor/core.py index db798297c..47f352745 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -22,7 +22,7 @@ from .exceptions import ( HomeAssistantError, SupervisorUpdateError, ) -from .resolution.const import UnsupportedReason +from .resolution.const import ContextType, IssueType, UnsupportedReason _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -107,6 +107,9 @@ class Core(CoreSysAttributes): self.sys_updater.channel, ) elif self.sys_config.version != self.sys_supervisor.version: + self.sys_resolution.create_issue( + IssueType.UPDATE_ROLLBACK, ContextType.SUPERVISOR + ) self.healthy = False _LOGGER.error( "Update '%s' of Supervisor '%s' failed!", diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index 13f72413d..67dd2c8ea 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -270,3 +270,14 @@ class HardwareNotSupportedError(HassioNotSupportedError): class PulseAudioError(HassioError): """Raise if an sound error is happening.""" + + +# Resolution + + +class ResolutionError(HassioError): + """Raise if an error is happning on resoltuion.""" + + +class ResolutionNotFound(ResolutionError): + """Raise if suggestion/issue was not found.""" diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index ab86981ac..d3e7860a3 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -21,6 +21,7 @@ from ..exceptions import ( HomeAssistantError, HomeAssistantUpdateError, ) +from ..resolution.const import ContextType, IssueType from ..utils import convert_to_ascii, process_lock _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -191,6 +192,10 @@ class HomeAssistantCore(CoreSysAttributes): # Update going wrong, revert it if self.error_state and rollback: _LOGGER.critical("HomeAssistant update failed -> rollback!") + self.sys_resolution.create_issue( + IssueType.UPDATE_ROLLBACK, ContextType.CORE + ) + # Make a copy of the current log file if it exsist logfile = self.sys_config.path_homeassistant / "home-assistant.log" if logfile.exists(): @@ -204,6 +209,7 @@ class HomeAssistantCore(CoreSysAttributes): ) await _update(rollback) else: + self.sys_resolution.create_issue(IssueType.UPDATE_FAILED, ContextType.CORE) raise HomeAssistantUpdateError() async def _start(self) -> None: diff --git a/supervisor/resolution/__init__.py b/supervisor/resolution/__init__.py index 94732fb8e..6a057cd87 100644 --- a/supervisor/resolution/__init__.py +++ b/supervisor/resolution/__init__.py @@ -1,10 +1,17 @@ """Supervisor resolution center.""" import logging -from typing import List +from typing import List, Optional from ..coresys import CoreSys, CoreSysAttributes -from ..resolution.const import UnsupportedReason -from .const import SCHEDULED_HEALTHCHECK, IssueType, Suggestion +from ..exceptions import ResolutionError, ResolutionNotFound +from .const import ( + SCHEDULED_HEALTHCHECK, + ContextType, + IssueType, + SuggestionType, + UnsupportedReason, +) +from .data import Issue, Suggestion from .free_space import ResolutionStorage from .notify import ResolutionNotify @@ -19,9 +26,9 @@ class ResolutionManager(CoreSysAttributes): self.coresys: CoreSys = coresys self._notify = ResolutionNotify(coresys) self._storage = ResolutionStorage(coresys) - self._dismissed_suggestions: List[Suggestion] = [] + self._suggestions: List[Suggestion] = [] - self._issues: List[IssueType] = [] + self._issues: List[Issue] = [] self._unsupported: List[UnsupportedReason] = [] @property @@ -35,12 +42,12 @@ class ResolutionManager(CoreSysAttributes): return self._notify @property - def issues(self) -> List[IssueType]: + def issues(self) -> List[Issue]: """Return a list of issues.""" return self._issues @issues.setter - def issues(self, issue: IssueType) -> None: + def issues(self, issue: Issue) -> None: """Add issues.""" if issue not in self._issues: self._issues.append(issue) @@ -48,7 +55,7 @@ class ResolutionManager(CoreSysAttributes): @property def suggestions(self) -> List[Suggestion]: """Return a list of suggestions that can handled.""" - return [x for x in self._suggestions if x not in self._dismissed_suggestions] + return self._suggestions @suggestions.setter def suggestions(self, suggestion: Suggestion) -> None: @@ -67,6 +74,38 @@ class ResolutionManager(CoreSysAttributes): if reason not in self._unsupported: self._unsupported.append(reason) + def get_suggestion(self, uuid: str) -> Suggestion: + """Return suggestion with uuid.""" + for suggestion in self._suggestions: + if suggestion.uuid != uuid: + continue + return suggestion + raise ResolutionNotFound() + + def get_issue(self, uuid: str) -> Issue: + """Return issue with uuid.""" + for issue in self._issues: + if issue.uuid != uuid: + continue + return issue + raise ResolutionNotFound() + + def create_issue( + self, + issue: IssueType, + context: ContextType, + reference: Optional[str] = None, + suggestions: Optional[List[SuggestionType]] = None, + ) -> None: + """Create issues and suggestion.""" + self.issues = Issue(issue, context, reference) + if not suggestions: + return + + # Add suggestions + for suggestion in suggestions: + self.suggestions = Suggestion(suggestion, context, reference) + async def load(self): """Load the resoulution manager.""" # Initial healthcheck when the manager is loaded @@ -85,14 +124,14 @@ class ResolutionManager(CoreSysAttributes): async def apply_suggestion(self, suggestion: Suggestion) -> None: """Apply suggested action.""" - if suggestion not in self.suggestions: - _LOGGER.warning("Suggestion %s is not valid", suggestion) - return + if suggestion not in self._suggestions: + _LOGGER.warning("Suggestion %s is not valid", suggestion.uuid) + raise ResolutionError() - if suggestion == Suggestion.CLEAR_FULL_SNAPSHOT: + if suggestion.type == SuggestionType.CLEAR_FULL_SNAPSHOT: self.storage.clean_full_snapshots() - elif suggestion == Suggestion.CREATE_FULL_SNAPSHOT: + elif suggestion.type == SuggestionType.CREATE_FULL_SNAPSHOT: await self.sys_snapshots.do_snapshot_full() self._suggestions.remove(suggestion) @@ -100,9 +139,14 @@ class ResolutionManager(CoreSysAttributes): async def dismiss_suggestion(self, suggestion: Suggestion) -> None: """Dismiss suggested action.""" - if suggestion not in self.suggestions: - _LOGGER.warning("Suggestion %s is not valid", suggestion) - return + if suggestion not in self._suggestions: + _LOGGER.warning("The UUID %s is not valid suggestion", suggestion.uuid) + raise ResolutionError() + self._suggestions.remove(suggestion) - if suggestion not in self._dismissed_suggestions: - self._dismissed_suggestions.append(suggestion) + async def dismiss_issue(self, issue: Issue) -> None: + """Dismiss suggested action.""" + if issue not in self._issues: + _LOGGER.warning("The UUID %s is not a valid issue", issue.uuid) + raise ResolutionError() + self._issues.remove(issue) diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 00c32707b..eb7893f70 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -7,6 +7,16 @@ MINIMUM_FREE_SPACE_THRESHOLD = 1 MINIMUM_FULL_SNAPSHOTS = 2 +class ContextType(str, Enum): + """Place where somethings was happening.""" + + SYSTEM = "system" + SUPERVISOR = "supervisor" + ADDON = "addon" + CORE = "core" + OS = "os" + + class UnsupportedReason(str, Enum): """Reasons for unsupported status.""" @@ -25,10 +35,15 @@ class IssueType(str, Enum): """Issue type.""" FREE_SPACE = "free_space" + CORRUPT_DOCKER = "corrupt_docker" + MISSING_IMAGE = "missing_image" + UPDATE_FAILED = "update_failed" + UPDATE_ROLLBACK = "update_rollback" -class Suggestion(str, Enum): - """Sugestion.""" +class SuggestionType(str, Enum): + """Sugestion type.""" CLEAR_FULL_SNAPSHOT = "clear_full_snapshot" CREATE_FULL_SNAPSHOT = "create_full_snapshot" + SYSTEM_REPAIR = "system_repair" diff --git a/supervisor/resolution/data.py b/supervisor/resolution/data.py new file mode 100644 index 000000000..84ef9b0d4 --- /dev/null +++ b/supervisor/resolution/data.py @@ -0,0 +1,27 @@ +"""Data objects.""" +from typing import Optional +from uuid import UUID, uuid4 + +import attr + +from .const import ContextType, IssueType, SuggestionType + + +@attr.s(frozen=True, slots=True) +class Issue: + """Represent an Issue.""" + + type: IssueType = attr.ib() + context: ContextType = attr.ib() + reference: Optional[str] = attr.ib(default=None) + uuid: UUID = attr.ib(factory=lambda: uuid4().hex, eq=False, init=False) + + +@attr.s(frozen=True, slots=True) +class Suggestion: + """Represent an Suggestion.""" + + type: SuggestionType = attr.ib() + context: ContextType = attr.ib() + reference: Optional[str] = attr.ib(default=None) + uuid: UUID = attr.ib(factory=lambda: uuid4().hex, eq=False, init=False) diff --git a/supervisor/resolution/free_space.py b/supervisor/resolution/free_space.py index bcff820e0..3ea523cab 100644 --- a/supervisor/resolution/free_space.py +++ b/supervisor/resolution/free_space.py @@ -1,14 +1,17 @@ """Helpers to check and fix issues with free space.""" import logging +from typing import List from ..const import SNAPSHOT_FULL from ..coresys import CoreSys, CoreSysAttributes from .const import ( MINIMUM_FREE_SPACE_THRESHOLD, MINIMUM_FULL_SNAPSHOTS, + ContextType, IssueType, - Suggestion, + SuggestionType, ) +from .data import Suggestion _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -23,10 +26,14 @@ class ResolutionStorage(CoreSysAttributes): def check_free_space(self) -> None: """Check free space.""" if self.sys_host.info.free_space > MINIMUM_FREE_SPACE_THRESHOLD: + if len(self.sys_snapshots.list_snapshots) == 0: + # No snapshots, let's suggest the user to create one! + self.sys_resolution.suggestions = Suggestion( + SuggestionType.CREATE_FULL_SNAPSHOT, ContextType.SYSTEM + ) return - self.sys_resolution.issues = IssueType.FREE_SPACE - + suggestions: List[SuggestionType] = [] if ( len( [ @@ -37,11 +44,11 @@ class ResolutionStorage(CoreSysAttributes): ) >= MINIMUM_FULL_SNAPSHOTS ): - self.sys_resolution.suggestions = Suggestion.CLEAR_FULL_SNAPSHOT + suggestions.append(SuggestionType.CLEAR_FULL_SNAPSHOT) - elif len(self.sys_snapshots.list_snapshots) == 0: - # No snapshots, let's suggest the user to create one! - self.sys_resolution.suggestions = Suggestion.CREATE_FULL_SNAPSHOT + self.sys_resolution.create_issue( + IssueType.FREE_SPACE, ContextType.SYSTEM, suggestions=suggestions + ) def clean_full_snapshots(self): """Clean out all old full snapshots, but keep the most recent.""" diff --git a/supervisor/resolution/notify.py b/supervisor/resolution/notify.py index 7d717796c..18bad4dee 100644 --- a/supervisor/resolution/notify.py +++ b/supervisor/resolution/notify.py @@ -28,11 +28,11 @@ class ResolutionNotify(CoreSysAttributes): ): return - issues = [] + messages = [] for issue in self.sys_resolution.issues: - if issue == IssueType.FREE_SPACE: - issues.append( + if issue.type == IssueType.FREE_SPACE: + messages.append( { "title": "Available space is less than 1GB!", "message": f"Available space is {self.sys_host.info.free_space}GB, see https://www.home-assistant.io/more-info/free-space for more information.", @@ -40,12 +40,12 @@ class ResolutionNotify(CoreSysAttributes): } ) - for issue in issues: + for message in messages: try: async with self.sys_homeassistant.api.make_request( "post", "api/services/persistent_notification/create", - json=issue, + json=message, ) as resp: if resp.status in (200, 201): _LOGGER.debug("Sucessfully created persistent_notification") diff --git a/supervisor/supervisor.py b/supervisor/supervisor.py index 84d4803b6..5597a815e 100644 --- a/supervisor/supervisor.py +++ b/supervisor/supervisor.py @@ -19,6 +19,7 @@ from .exceptions import ( SupervisorError, SupervisorUpdateError, ) +from .resolution.const import ContextType, IssueType _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -117,6 +118,9 @@ class Supervisor(CoreSysAttributes): ) except DockerError as err: _LOGGER.error("Update of Supervisor failed!") + self.sys_resolution.create_issue( + IssueType.UPDATE_FAILED, ContextType.SUPERVISOR + ) raise SupervisorUpdateError() from err else: self.sys_config.version = version diff --git a/tests/api/test_resolution.py b/tests/api/test_resolution.py index 3b2c2cff8..9713300ec 100644 --- a/tests/api/test_resolution.py +++ b/tests/api/test_resolution.py @@ -1,47 +1,86 @@ """Test Resolution API.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock import pytest from supervisor.const import ATTR_ISSUES, ATTR_SUGGESTIONS, ATTR_UNSUPPORTED from supervisor.coresys import CoreSys -from supervisor.resolution.const import IssueType, Suggestion, UnsupportedReason +from supervisor.exceptions import ResolutionError +from supervisor.resolution.const import ( + ContextType, + IssueType, + SuggestionType, + UnsupportedReason, +) +from supervisor.resolution.data import Issue, Suggestion @pytest.mark.asyncio async def test_api_resolution_base(coresys: CoreSys, api_client): """Test resolution manager api.""" coresys.resolution.unsupported = UnsupportedReason.OS - coresys.resolution.suggestions = Suggestion.CLEAR_FULL_SNAPSHOT - coresys.resolution.issues = IssueType.FREE_SPACE + coresys.resolution.suggestions = Suggestion( + SuggestionType.CLEAR_FULL_SNAPSHOT, ContextType.SYSTEM + ) + coresys.resolution.create_issue(IssueType.FREE_SPACE, ContextType.SYSTEM) + resp = await api_client.get("/resolution") result = await resp.json() assert UnsupportedReason.OS in result["data"][ATTR_UNSUPPORTED] - assert Suggestion.CLEAR_FULL_SNAPSHOT in result["data"][ATTR_SUGGESTIONS] - assert IssueType.FREE_SPACE in result["data"][ATTR_ISSUES] + assert ( + SuggestionType.CLEAR_FULL_SNAPSHOT + == result["data"][ATTR_SUGGESTIONS][-1]["type"] + ) + assert IssueType.FREE_SPACE == result["data"][ATTR_ISSUES][-1]["type"] @pytest.mark.asyncio async def test_api_resolution_dismiss_suggestion(coresys: CoreSys, api_client): """Test resolution manager suggestion apply api.""" - coresys.resolution.suggestions = Suggestion.CLEAR_FULL_SNAPSHOT + coresys.resolution.suggestions = clear_snapshot = Suggestion( + SuggestionType.CLEAR_FULL_SNAPSHOT, ContextType.SYSTEM + ) - assert Suggestion.CLEAR_FULL_SNAPSHOT in coresys.resolution.suggestions - await coresys.resolution.dismiss_suggestion(Suggestion.CLEAR_FULL_SNAPSHOT) - assert Suggestion.CLEAR_FULL_SNAPSHOT not in coresys.resolution.suggestions + assert SuggestionType.CLEAR_FULL_SNAPSHOT == coresys.resolution.suggestions[-1].type + await api_client.delete(f"/resolution/suggestion/{clear_snapshot.uuid}") + assert clear_snapshot not in coresys.resolution.suggestions @pytest.mark.asyncio async def test_api_resolution_apply_suggestion(coresys: CoreSys, api_client): """Test resolution manager suggestion apply api.""" - coresys.resolution.suggestions = Suggestion.CLEAR_FULL_SNAPSHOT - coresys.resolution.suggestions = Suggestion.CREATE_FULL_SNAPSHOT + coresys.resolution.suggestions = clear_snapshot = Suggestion( + SuggestionType.CLEAR_FULL_SNAPSHOT, ContextType.SYSTEM + ) + coresys.resolution.suggestions = create_snapshot = Suggestion( + SuggestionType.CREATE_FULL_SNAPSHOT, ContextType.SYSTEM + ) - with patch("supervisor.snapshots.SnapshotManager", return_value=MagicMock()): - await coresys.resolution.apply_suggestion(Suggestion.CLEAR_FULL_SNAPSHOT) - await coresys.resolution.apply_suggestion(Suggestion.CREATE_FULL_SNAPSHOT) + mock_snapshots = AsyncMock() + mock_health = AsyncMock() + coresys.snapshots.do_snapshot_full = mock_snapshots + coresys.resolution.healthcheck = mock_health - assert Suggestion.CLEAR_FULL_SNAPSHOT not in coresys.resolution.suggestions - assert Suggestion.CREATE_FULL_SNAPSHOT not in coresys.resolution.suggestions + await api_client.post(f"/resolution/suggestion/{clear_snapshot.uuid}") + await api_client.post(f"/resolution/suggestion/{create_snapshot.uuid}") - await coresys.resolution.apply_suggestion(Suggestion.CLEAR_FULL_SNAPSHOT) + assert clear_snapshot not in coresys.resolution.suggestions + assert create_snapshot not in coresys.resolution.suggestions + + assert mock_snapshots.called + assert mock_health.called + + with pytest.raises(ResolutionError): + await coresys.resolution.apply_suggestion(clear_snapshot) + + +@pytest.mark.asyncio +async def test_api_resolution_dismiss_issue(coresys: CoreSys, api_client): + """Test resolution manager issue apply api.""" + coresys.resolution.issues = updated_failed = Issue( + IssueType.UPDATE_FAILED, ContextType.SYSTEM + ) + + assert IssueType.UPDATE_FAILED == coresys.resolution.issues[-1].type + await api_client.delete(f"/resolution/issue/{updated_failed.uuid}") + assert updated_failed not in coresys.resolution.issues diff --git a/tests/resolution/test_resolution_manager.py b/tests/resolution/test_resolution_manager.py index 00541f98d..dfd535d1a 100644 --- a/tests/resolution/test_resolution_manager.py +++ b/tests/resolution/test_resolution_manager.py @@ -1,5 +1,8 @@ """Tests for resolution manager.""" from pathlib import Path +from unittest.mock import AsyncMock + +import pytest from supervisor.const import ( ATTR_DATE, @@ -9,7 +12,14 @@ from supervisor.const import ( SNAPSHOT_PARTIAL, ) from supervisor.coresys import CoreSys -from supervisor.resolution.const import UnsupportedReason +from supervisor.exceptions import ResolutionError +from supervisor.resolution.const import ( + ContextType, + IssueType, + SuggestionType, + UnsupportedReason, +) +from supervisor.resolution.data import Issue, Suggestion from supervisor.snapshots.snapshot import Snapshot from supervisor.utils.dt import utcnow from supervisor.utils.tar import SecureTarFile @@ -58,3 +68,73 @@ async def test_clear_snapshots(coresys: CoreSys, tmp_path): ) == 1 ) + + +@pytest.mark.asyncio +async def test_resolution_dismiss_suggestion(coresys: CoreSys): + """Test resolution manager suggestion apply api.""" + coresys.resolution.suggestions = clear_snapshot = Suggestion( + SuggestionType.CLEAR_FULL_SNAPSHOT, ContextType.SYSTEM + ) + + assert SuggestionType.CLEAR_FULL_SNAPSHOT == coresys.resolution.suggestions[-1].type + await coresys.resolution.dismiss_suggestion(clear_snapshot) + assert clear_snapshot not in coresys.resolution.suggestions + + +@pytest.mark.asyncio +async def test_resolution_apply_suggestion(coresys: CoreSys): + """Test resolution manager suggestion apply api.""" + coresys.resolution.suggestions = clear_snapshot = Suggestion( + SuggestionType.CLEAR_FULL_SNAPSHOT, ContextType.SYSTEM + ) + coresys.resolution.suggestions = create_snapshot = Suggestion( + SuggestionType.CREATE_FULL_SNAPSHOT, ContextType.SYSTEM + ) + + mock_snapshots = AsyncMock() + mock_health = AsyncMock() + coresys.snapshots.do_snapshot_full = mock_snapshots + coresys.resolution.healthcheck = mock_health + + await coresys.resolution.apply_suggestion(clear_snapshot) + await coresys.resolution.apply_suggestion(create_snapshot) + + assert mock_snapshots.called + assert mock_health.called + + assert clear_snapshot not in coresys.resolution.suggestions + assert create_snapshot not in coresys.resolution.suggestions + + with pytest.raises(ResolutionError): + await coresys.resolution.apply_suggestion(clear_snapshot) + + +@pytest.mark.asyncio +async def test_resolution_dismiss_issue(coresys: CoreSys): + """Test resolution manager issue apply api.""" + coresys.resolution.issues = updated_failed = Issue( + IssueType.UPDATE_FAILED, ContextType.SYSTEM + ) + + assert IssueType.UPDATE_FAILED == coresys.resolution.issues[-1].type + await coresys.resolution.dismiss_issue(updated_failed) + assert updated_failed not in coresys.resolution.issues + + +@pytest.mark.asyncio +async def test_resolution_create_issue_suggestion(coresys: CoreSys): + """Test resolution manager issue and suggestion.""" + coresys.resolution.create_issue( + IssueType.UPDATE_ROLLBACK, + ContextType.CORE, + "slug", + [SuggestionType.SYSTEM_REPAIR], + ) + + assert IssueType.UPDATE_ROLLBACK == coresys.resolution.issues[-1].type + assert ContextType.CORE == coresys.resolution.issues[-1].context + assert coresys.resolution.issues[-1].reference == "slug" + + assert SuggestionType.SYSTEM_REPAIR == coresys.resolution.suggestions[-1].type + assert ContextType.CORE == coresys.resolution.suggestions[-1].context