diff --git a/supervisor/api/resolution.py b/supervisor/api/resolution.py index d34d752ed..e46d46800 100644 --- a/supervisor/api/resolution.py +++ b/supervisor/api/resolution.py @@ -44,7 +44,7 @@ class APIResoulution(CoreSysAttributes): suggestion = self.sys_resolution.get_suggestion( request.match_info.get("suggestion") ) - await self.sys_resolution.dismiss_suggestion(suggestion) + self.sys_resolution.dismiss_suggestion(suggestion) except ResolutionNotFound: raise APIError("The supplied UUID is not a valid suggestion") from None @@ -53,6 +53,6 @@ class APIResoulution(CoreSysAttributes): """Dismiss issue.""" try: issue = self.sys_resolution.get_issue(request.match_info.get("issue")) - await self.sys_resolution.dismiss_issue(issue) + 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 0a5707061..10dd24b6a 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -222,6 +222,7 @@ class Core(CoreSysAttributes): # Upate Host/Deivce information self.sys_create_task(self.sys_host.reload()) self.sys_create_task(self.sys_updater.reload()) + self.sys_create_task(self.sys_resolution.fixup.run_autofix()) self.state = CoreState.RUNNING _LOGGER.info("Supervisor is up and running") diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index c09b83eb8..e18e385cc 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -295,6 +295,10 @@ class ResolutionNotFound(ResolutionError): """Raise if suggestion/issue was not found.""" +class ResolutionFixupError(HassioError): + """Rasie if a fixup fails.""" + + # Store diff --git a/supervisor/resolution/__init__.py b/supervisor/resolution/__init__.py index b957675b6..cb1f85874 100644 --- a/supervisor/resolution/__init__.py +++ b/supervisor/resolution/__init__.py @@ -1,9 +1,11 @@ """Supervisor resolution center.""" +from datetime import time import logging from typing import List, Optional from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ResolutionError, ResolutionNotFound +from .check import ResolutionCheck from .const import ( SCHEDULED_HEALTHCHECK, ContextType, @@ -14,7 +16,7 @@ from .const import ( ) from .data import Issue, Suggestion from .evaluate import ResolutionEvaluation -from .free_space import ResolutionStorage +from .fixup import ResolutionFixup from .notify import ResolutionNotify _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -27,8 +29,9 @@ class ResolutionManager(CoreSysAttributes): """Initialize Resolution manager.""" self.coresys: CoreSys = coresys self._evaluate = ResolutionEvaluation(coresys) + self._check = ResolutionCheck(coresys) + self._fixup = ResolutionFixup(coresys) self._notify = ResolutionNotify(coresys) - self._storage = ResolutionStorage(coresys) self._suggestions: List[Suggestion] = [] self._issues: List[Issue] = [] @@ -41,9 +44,14 @@ class ResolutionManager(CoreSysAttributes): return self._evaluate @property - def storage(self) -> ResolutionStorage: - """Return the ResolutionStorage class.""" - return self._storage + def check(self) -> ResolutionCheck: + """Return the ResolutionCheck class.""" + return self._check + + @property + def fixup(self) -> ResolutionFixup: + """Return the ResolutionFixup class.""" + return self._fixup @property def notify(self) -> ResolutionNotify: @@ -133,11 +141,11 @@ class ResolutionManager(CoreSysAttributes): # Schedule the healthcheck self.sys_scheduler.register_task(self.healthcheck, SCHEDULED_HEALTHCHECK) + self.sys_scheduler.register_task(self.fixup.run_autofix, time(hour=2)) async def healthcheck(self): """Scheduled task to check for known issues.""" - # Check free space - self.sys_run_in_executor(self.storage.check_free_space) + await self.check.check_system() # Create notification for any known issues await self.notify.issue_notifications() @@ -148,30 +156,24 @@ class ResolutionManager(CoreSysAttributes): _LOGGER.warning("Suggestion %s is not valid", suggestion.uuid) raise ResolutionError() - if suggestion.type == SuggestionType.CLEAR_FULL_SNAPSHOT: - self.storage.clean_full_snapshots() - - elif suggestion.type == SuggestionType.CREATE_FULL_SNAPSHOT: - await self.sys_snapshots.do_snapshot_full() - - self._suggestions.remove(suggestion) + await self.fixup.apply_fixup(suggestion) await self.healthcheck() - async def dismiss_suggestion(self, suggestion: Suggestion) -> None: + def dismiss_suggestion(self, suggestion: Suggestion) -> None: """Dismiss suggested action.""" if suggestion not in self._suggestions: _LOGGER.warning("The UUID %s is not valid suggestion", suggestion.uuid) raise ResolutionError() self._suggestions.remove(suggestion) - async def dismiss_issue(self, issue: Issue) -> None: + 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) - async def dismiss_unsupported(self, reason: Issue) -> None: + def dismiss_unsupported(self, reason: Issue) -> None: """Dismiss a reason for unsupported.""" if reason not in self._unsupported: _LOGGER.warning("The reason %s is not active", reason) diff --git a/supervisor/resolution/check.py b/supervisor/resolution/check.py new file mode 100644 index 000000000..9196de8a6 --- /dev/null +++ b/supervisor/resolution/check.py @@ -0,0 +1,38 @@ +"""Helpers to checks the system.""" +import logging +from typing import List + +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import HassioError +from .checks.base import CheckBase +from .checks.free_space import CheckFreeSpace + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class ResolutionCheck(CoreSysAttributes): + """Checks class for resolution.""" + + def __init__(self, coresys: CoreSys) -> None: + """Initialize the checks class.""" + self.coresys = coresys + + self._free_space = CheckFreeSpace(coresys) + + @property + def all_tests(self) -> List[CheckBase]: + """Return all list of all checks.""" + return [self._free_space] + + async def check_system(self) -> None: + """Check the system.""" + _LOGGER.info("Starting system checks with state %s", self.sys_core.state) + + for test in self.all_tests: + try: + await test() + except HassioError as err: + _LOGGER.warning("Error during processing %s: %s", test.issue, err) + self.sys_capture_exception(err) + + _LOGGER.info("System checks complete") diff --git a/supervisor/resolution/checks/__init__.py b/supervisor/resolution/checks/__init__.py new file mode 100644 index 000000000..58504b209 --- /dev/null +++ b/supervisor/resolution/checks/__init__.py @@ -0,0 +1 @@ +"""Initialize system.""" diff --git a/supervisor/resolution/checks/base.py b/supervisor/resolution/checks/base.py new file mode 100644 index 000000000..0f8c6430a --- /dev/null +++ b/supervisor/resolution/checks/base.py @@ -0,0 +1,51 @@ +"""Baseclass for system checks.""" +from abc import ABC, abstractmethod, abstractproperty +import logging +from typing import List + +from ...const import CoreState +from ...coresys import CoreSys, CoreSysAttributes +from ..const import ContextType, IssueType + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class CheckBase(ABC, CoreSysAttributes): + """Baseclass for check.""" + + def __init__(self, coresys: CoreSys) -> None: + """Initialize the checks class.""" + self.coresys = coresys + + async def __call__(self) -> None: + """Execute the evaluation.""" + if self.sys_core.state not in self.states: + return + + # Don't need run if issue exists + for issue in self.sys_resolution.issues: + if issue.type != self.issue or issue.context != self.context: + continue + return + + _LOGGER.debug("Run check for %s/%s", self.issue, self.context) + await self.run_check() + + @abstractmethod + async def run_check(self): + """Run check.""" + + @property + @abstractproperty + def issue(self) -> IssueType: + """Return a IssueType enum.""" + + @property + @abstractproperty + def context(self) -> ContextType: + """Return a ContextType enum.""" + + @property + def states(self) -> List[CoreState]: + """Return a list of valid states when this check can run.""" + return [] diff --git a/supervisor/resolution/free_space.py b/supervisor/resolution/checks/free_space.py similarity index 56% rename from supervisor/resolution/free_space.py rename to supervisor/resolution/checks/free_space.py index 3ea523cab..f4c108f83 100644 --- a/supervisor/resolution/free_space.py +++ b/supervisor/resolution/checks/free_space.py @@ -2,29 +2,25 @@ import logging from typing import List -from ..const import SNAPSHOT_FULL -from ..coresys import CoreSys, CoreSysAttributes -from .const import ( +from ...const import SNAPSHOT_FULL, CoreState +from ..const import ( MINIMUM_FREE_SPACE_THRESHOLD, MINIMUM_FULL_SNAPSHOTS, ContextType, IssueType, SuggestionType, ) -from .data import Suggestion +from ..data import Suggestion +from .base import CheckBase _LOGGER: logging.Logger = logging.getLogger(__name__) -class ResolutionStorage(CoreSysAttributes): - """Storage class for resolution.""" +class CheckFreeSpace(CheckBase): + """Storage class for check.""" - def __init__(self, coresys: CoreSys): - """Initialize the storage class.""" - self.coresys = coresys - - def check_free_space(self) -> None: - """Check free space.""" + async def run_check(self): + """Run check.""" 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! @@ -50,15 +46,17 @@ class ResolutionStorage(CoreSysAttributes): IssueType.FREE_SPACE, ContextType.SYSTEM, suggestions=suggestions ) - def clean_full_snapshots(self): - """Clean out all old full snapshots, but keep the most recent.""" - full_snapshots = [ - x for x in self.sys_snapshots.list_snapshots if x.sys_type == SNAPSHOT_FULL - ] + @property + def issue(self) -> IssueType: + """Return a IssueType enum.""" + return IssueType.FREE_SPACE - if len(full_snapshots) < MINIMUM_FULL_SNAPSHOTS: - return + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.SYSTEM - _LOGGER.info("Starting removal of old full snapshots") - for snapshot in sorted(full_snapshots, key=lambda x: x.date)[:-1]: - self.sys_snapshots.remove(snapshot) + @property + def states(self) -> List[CoreState]: + """Return a list of valid states when this check can run.""" + return [CoreState.RUNNING, CoreState.STARTUP] diff --git a/supervisor/resolution/evaluate.py b/supervisor/resolution/evaluate.py index 65753f657..c7cbb60e4 100644 --- a/supervisor/resolution/evaluate.py +++ b/supervisor/resolution/evaluate.py @@ -1,8 +1,12 @@ """Helpers to evaluate the system.""" import logging +from typing import List + +from supervisor.exceptions import HassioError from ..coresys import CoreSys, CoreSysAttributes from .const import UnhealthyReason, UnsupportedReason +from .evaluations.base import EvaluateBase from .evaluations.container import EvaluateContainer from .evaluations.dbus import EvaluateDbus from .evaluations.docker_configuration import EvaluateDockerConfiguration @@ -42,19 +46,34 @@ class ResolutionEvaluation(CoreSysAttributes): self._systemd = EvaluateSystemd(coresys) self._job_conditions = EvaluateJobConditions(coresys) + @property + def all_evalutions(self) -> List[EvaluateBase]: + """Return list of all evaluations.""" + return [ + self._container, + self._dbus, + self._docker_configuration, + self._docker_version, + self._lxc, + self._network_manager, + self._operating_system, + self._privileged, + self._systemd, + self._job_conditions, + ] + async def evaluate_system(self) -> None: """Evaluate the system.""" _LOGGER.info("Starting system evaluation with state %s", self.sys_core.state) - await self._container() - await self._dbus() - await self._docker_configuration() - await self._docker_version() - await self._lxc() - await self._network_manager() - await self._operating_system() - await self._privileged() - await self._systemd() - await self._job_conditions() + + for evaluation in self.all_evalutions: + try: + await evaluation() + except HassioError as err: + _LOGGER.warning( + "Error during processing %s: %s", evaluation.reason, err + ) + self.sys_capture_exception(err) if any(reason in self.sys_resolution.unsupported for reason in UNHEALTHY): self.sys_resolution.unhealthy = UnhealthyReason.DOCKER diff --git a/supervisor/resolution/evaluations/base.py b/supervisor/resolution/evaluations/base.py index cd8ccc2e0..30d14714d 100644 --- a/supervisor/resolution/evaluations/base.py +++ b/supervisor/resolution/evaluations/base.py @@ -1,4 +1,5 @@ """Baseclass for system evaluations.""" +from abc import ABC, abstractmethod, abstractproperty import logging from typing import List @@ -9,7 +10,7 @@ from ..const import UnsupportedReason _LOGGER: logging.Logger = logging.getLogger(__name__) -class EvaluateBase(CoreSysAttributes): +class EvaluateBase(ABC, CoreSysAttributes): """Baseclass for evaluation.""" def __init__(self, coresys: CoreSys) -> None: @@ -31,21 +32,21 @@ class EvaluateBase(CoreSysAttributes): else: if self.reason in self.sys_resolution.unsupported: _LOGGER.info("Clearing %s as reason for unsupported", self.reason) - await self.sys_resolution.dismiss_unsupported(self.reason) + self.sys_resolution.dismiss_unsupported(self.reason) + @abstractmethod async def evaluate(self): """Run evaluation.""" - raise NotImplementedError @property + @abstractproperty def reason(self) -> UnsupportedReason: """Return a UnsupportedReason enum.""" - raise NotImplementedError @property + @abstractproperty def on_failure(self) -> str: """Return a string that is printed when self.evaluate is False.""" - raise NotImplementedError @property def states(self) -> List[CoreState]: diff --git a/supervisor/resolution/fixup.py b/supervisor/resolution/fixup.py new file mode 100644 index 000000000..696e4adb7 --- /dev/null +++ b/supervisor/resolution/fixup.py @@ -0,0 +1,51 @@ +"""Helpers to fixup the system.""" +import logging +from typing import List + +from supervisor.exceptions import HassioError +from supervisor.resolution.data import Suggestion + +from ..coresys import CoreSys, CoreSysAttributes +from .fixups.base import FixupBase +from .fixups.clear_full_snapshot import FixupClearFullSnapshot +from .fixups.create_full_snapshot import FixupCreateFullSnapshot + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class ResolutionFixup(CoreSysAttributes): + """Suggestion class for resolution.""" + + def __init__(self, coresys: CoreSys) -> None: + """Initialize the suggestion class.""" + self.coresys = coresys + + self._create_full_snapshot = FixupCreateFullSnapshot(coresys) + self._clear_full_snapshot = FixupClearFullSnapshot(coresys) + + @property + def all_fixes(self) -> List[FixupBase]: + """Return a list of all fixups.""" + return [self._create_full_snapshot, self._clear_full_snapshot] + + async def run_autofix(self) -> None: + """Run all startup fixes.""" + _LOGGER.info("Starting system autofix at state %s", self.sys_core.state) + + for fix in self.all_fixes: + if not fix.auto: + continue + try: + await fix() + except HassioError as err: + _LOGGER.warning("Error during processing %s: %s", fix.suggestion, err) + self.sys_capture_exception(err) + + _LOGGER.info("System autofix complete") + + 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 + await fix() diff --git a/supervisor/resolution/fixups/__init__.py b/supervisor/resolution/fixups/__init__.py new file mode 100644 index 000000000..58504b209 --- /dev/null +++ b/supervisor/resolution/fixups/__init__.py @@ -0,0 +1 @@ +"""Initialize system.""" diff --git a/supervisor/resolution/fixups/base.py b/supervisor/resolution/fixups/base.py new file mode 100644 index 000000000..d1b188f5f --- /dev/null +++ b/supervisor/resolution/fixups/base.py @@ -0,0 +1,75 @@ +"""Baseclass for system fixup.""" +from abc import ABC, abstractmethod, abstractproperty +from contextlib import suppress +import logging +from typing import Optional + +from ...coresys import CoreSys, CoreSysAttributes +from ...exceptions import ResolutionError, ResolutionFixupError +from ..const import ContextType, IssueType, SuggestionType +from ..data import Issue, Suggestion + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class FixupBase(ABC, CoreSysAttributes): + """Baseclass for fixup.""" + + def __init__(self, coresys: CoreSys) -> None: + """Initialize the fixup class.""" + self.coresys = coresys + + async def __call__(self) -> None: + """Execute the evaluation.""" + # Get suggestion to fix + fixing_suggestion: Optional[Suggestion] = 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 + if fixing_suggestion is None: + return + + # Process fixup + _LOGGER.debug("Run fixup for %s/%s", self.suggestion, self.context) + try: + await self.process_fixup(reference=fixing_suggestion.reference) + except ResolutionFixupError: + return + + self.sys_resolution.dismiss_suggestion(fixing_suggestion) + + if self.issue is None: + return + + with suppress(ResolutionError): + self.sys_resolution.dismiss_issue( + Issue(self.issue, self.context, fixing_suggestion.reference) + ) + + @abstractmethod + async def process_fixup(self, reference: Optional[str] = None) -> None: + """Run processing of fixup.""" + + @property + @abstractproperty + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + + @property + @abstractproperty + def context(self) -> ContextType: + """Return a ContextType enum.""" + + @property + def issue(self) -> Optional[IssueType]: + """Return a IssueType enum.""" + return None + + @property + def auto(self) -> bool: + """Return if a fixup can be apply as auto fix.""" + return False diff --git a/supervisor/resolution/fixups/clear_full_snapshot.py b/supervisor/resolution/fixups/clear_full_snapshot.py new file mode 100644 index 000000000..add696000 --- /dev/null +++ b/supervisor/resolution/fixups/clear_full_snapshot.py @@ -0,0 +1,41 @@ +"""Helpers to check and fix issues with free space.""" +import logging +from typing import Optional + +from ...const import SNAPSHOT_FULL +from ..const import MINIMUM_FULL_SNAPSHOTS, ContextType, IssueType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class FixupClearFullSnapshot(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: Optional[str] = None) -> None: + """Initialize the fixup class.""" + full_snapshots = [ + x for x in self.sys_snapshots.list_snapshots if x.sys_type == SNAPSHOT_FULL + ] + + if len(full_snapshots) < MINIMUM_FULL_SNAPSHOTS: + return + + _LOGGER.info("Starting removal of old full snapshots") + for snapshot in sorted(full_snapshots, key=lambda x: x.date)[:-1]: + self.sys_snapshots.remove(snapshot) + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.CLEAR_FULL_SNAPSHOT + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.SYSTEM + + @property + def issue(self) -> IssueType: + """Return a IssueType enum.""" + return IssueType.FREE_SPACE diff --git a/supervisor/resolution/fixups/create_full_snapshot.py b/supervisor/resolution/fixups/create_full_snapshot.py new file mode 100644 index 000000000..fb1a47b83 --- /dev/null +++ b/supervisor/resolution/fixups/create_full_snapshot.py @@ -0,0 +1,27 @@ +"""Helpers to check and fix issues with free space.""" +import logging +from typing import Optional + +from ..const import ContextType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class FixupCreateFullSnapshot(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: Optional[str] = None) -> None: + """Initialize the fixup class.""" + _LOGGER.info("Create a full snapshot as backup") + await self.sys_snapshots.do_snapshot_full() + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.CREATE_FULL_SNAPSHOT + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.SYSTEM diff --git a/supervisor/store/git.py b/supervisor/store/git.py index 0c113a7fb..40e24ca26 100644 --- a/supervisor/store/git.py +++ b/supervisor/store/git.py @@ -11,6 +11,7 @@ import git from ..const import ATTR_BRANCH, ATTR_URL, URL_HASSIO_ADDONS from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import StoreGitError +from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType, SuggestionType from ..validate import RE_REPOSITORY from .utils import get_hash_from_repository @@ -81,6 +82,7 @@ class GitRepo(CoreSysAttributes): ) raise StoreGitError() from err + @Job(conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM]) async def clone(self) -> None: """Clone git add-on repository.""" async with self.lock: @@ -117,6 +119,7 @@ class GitRepo(CoreSysAttributes): ) raise StoreGitError() from err + @Job(conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM]) async def pull(self): """Pull Git add-on repo.""" if self.lock.locked(): diff --git a/tests/resolution/check/test_check.py b/tests/resolution/check/test_check.py new file mode 100644 index 000000000..5141e7ccf --- /dev/null +++ b/tests/resolution/check/test_check.py @@ -0,0 +1,39 @@ +"""Test check.""" +# pylint: disable=import-error +from unittest.mock import patch + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.resolution.const import IssueType + + +async def test_check_setup(coresys: CoreSys): + """Test check for setup.""" + coresys.core.state = CoreState.SETUP + with patch( + "supervisor.resolution.checks.free_space.CheckFreeSpace.run_check", + return_value=False, + ) as free_space: + await coresys.resolution.check.check_system() + free_space.assert_not_called() + + +async def test_check_running(coresys: CoreSys): + """Test check for setup.""" + coresys.core.state = CoreState.RUNNING + with patch( + "supervisor.resolution.checks.free_space.CheckFreeSpace.run_check", + return_value=False, + ) as free_space: + await coresys.resolution.check.check_system() + free_space.assert_called_once() + + +async def test_if_check_make_issue(coresys: CoreSys): + """Test check for setup.""" + coresys.core.state = CoreState.RUNNING + + with patch("shutil.disk_usage", return_value=(1, 1, 1)): + await coresys.resolution.check.check_system() + + assert coresys.resolution.issues[-1].type == IssueType.FREE_SPACE diff --git a/tests/resolution/check/test_check_free_space.py b/tests/resolution/check/test_check_free_space.py new file mode 100644 index 000000000..690e8b279 --- /dev/null +++ b/tests/resolution/check/test_check_free_space.py @@ -0,0 +1,51 @@ +"""Test evaluation base.""" +# pylint: disable=import-error,protected-access +from unittest.mock import patch + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.resolution.checks.free_space import CheckFreeSpace +from supervisor.resolution.const import IssueType + + +async def test_check(coresys: CoreSys): + """Test check.""" + free_space = CheckFreeSpace(coresys) + coresys.core.state = CoreState.RUNNING + + assert len(coresys.resolution.issues) == 0 + + with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0 ** 3))): + await free_space.run_check() + + assert len(coresys.resolution.issues) == 0 + + with patch("shutil.disk_usage", return_value=(1, 1, 1)): + await free_space.run_check() + + assert coresys.resolution.issues[-1].type == IssueType.FREE_SPACE + + +async def test_did_run(coresys: CoreSys): + """Test that the check ran as expected.""" + free_space = CheckFreeSpace(coresys) + should_run = free_space.states + should_not_run = [state for state in CoreState if state not in should_run] + assert len(should_run) != 0 + assert len(should_not_run) != 0 + + with patch( + "supervisor.resolution.checks.free_space.CheckFreeSpace.run_check", + return_value=None, + ) as check: + for state in should_run: + coresys.core.state = state + await free_space() + check.assert_called_once() + check.reset_mock() + + for state in should_not_run: + coresys.core.state = state + await free_space() + check.assert_not_called() + check.reset_mock() diff --git a/tests/resolution/evaluation/test_evaluate_base.py b/tests/resolution/evaluation/test_evaluate_base.py deleted file mode 100644 index 37e6031cb..000000000 --- a/tests/resolution/evaluation/test_evaluate_base.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Test evaluation base.""" -# pylint: disable=import-error -import pytest - -from supervisor.coresys import CoreSys -from supervisor.resolution.evaluations.base import EvaluateBase - - -async def test_evaluation_base(coresys: CoreSys): - """Test evaluation base.""" - base = EvaluateBase(coresys) - assert not base.states - - with pytest.raises(NotImplementedError): - await base.evaluate() - - with pytest.raises(NotImplementedError): - assert not base.on_failure - - with pytest.raises(NotImplementedError): - assert not base.reason diff --git a/tests/resolution/fixup/test_clear_full_snapshot.py b/tests/resolution/fixup/test_clear_full_snapshot.py new file mode 100644 index 000000000..a3bc323f0 --- /dev/null +++ b/tests/resolution/fixup/test_clear_full_snapshot.py @@ -0,0 +1,64 @@ +"""Test evaluation base.""" +# pylint: disable=import-error,protected-access +from pathlib import Path + +from supervisor.const import ( + ATTR_DATE, + ATTR_SLUG, + ATTR_TYPE, + SNAPSHOT_FULL, + SNAPSHOT_PARTIAL, +) +from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, SuggestionType +from supervisor.resolution.data import Suggestion +from supervisor.resolution.fixups.clear_full_snapshot import FixupClearFullSnapshot +from supervisor.snapshots.snapshot import Snapshot +from supervisor.utils.dt import utcnow +from supervisor.utils.tar import SecureTarFile + + +async def test_fixup(coresys: CoreSys, tmp_path): + """Test fixup.""" + clear_full_snapshot = FixupClearFullSnapshot(coresys) + + assert not clear_full_snapshot.auto + + coresys.resolution.suggestions = Suggestion( + SuggestionType.CLEAR_FULL_SNAPSHOT, ContextType.SYSTEM + ) + + for slug in ["sn1", "sn2", "sn3", "sn4", "sn5"]: + temp_tar = Path(tmp_path, f"{slug}.tar") + with SecureTarFile(temp_tar, "w"): + pass + snapshot = Snapshot(coresys, temp_tar) + snapshot._data = { # pylint: disable=protected-access + ATTR_SLUG: slug, + ATTR_DATE: utcnow().isoformat(), + ATTR_TYPE: SNAPSHOT_PARTIAL + if "1" in slug or "5" in slug + else SNAPSHOT_FULL, + } + coresys.snapshots.snapshots_obj[snapshot.slug] = snapshot + + newest_full_snapshot = coresys.snapshots.snapshots_obj["sn4"] + + assert newest_full_snapshot in coresys.snapshots.list_snapshots + assert ( + len( + [x for x in coresys.snapshots.list_snapshots if x.sys_type == SNAPSHOT_FULL] + ) + == 3 + ) + + await clear_full_snapshot() + assert newest_full_snapshot in coresys.snapshots.list_snapshots + assert ( + len( + [x for x in coresys.snapshots.list_snapshots if x.sys_type == SNAPSHOT_FULL] + ) + == 1 + ) + + assert len(coresys.resolution.suggestions) == 0 diff --git a/tests/resolution/fixup/test_create_full_snapshot.py b/tests/resolution/fixup/test_create_full_snapshot.py new file mode 100644 index 000000000..e5b5728f8 --- /dev/null +++ b/tests/resolution/fixup/test_create_full_snapshot.py @@ -0,0 +1,27 @@ +"""Test evaluation base.""" +# pylint: disable=import-error,protected-access +from unittest.mock import AsyncMock + +from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, SuggestionType +from supervisor.resolution.data import Suggestion +from supervisor.resolution.fixups.create_full_snapshot import FixupCreateFullSnapshot + + +async def test_fixup(coresys: CoreSys): + """Test fixup.""" + create_full_snapshot = FixupCreateFullSnapshot(coresys) + + assert not create_full_snapshot.auto + + coresys.resolution.suggestions = Suggestion( + SuggestionType.CREATE_FULL_SNAPSHOT, ContextType.SYSTEM + ) + + mock_snapshots = AsyncMock() + coresys.snapshots.do_snapshot_full = mock_snapshots + + await create_full_snapshot() + + mock_snapshots.assert_called() + assert len(coresys.resolution.suggestions) == 0 diff --git a/tests/resolution/fixup/test_fixup.py b/tests/resolution/fixup/test_fixup.py new file mode 100644 index 000000000..02f2b9069 --- /dev/null +++ b/tests/resolution/fixup/test_fixup.py @@ -0,0 +1,35 @@ +"""Test check.""" +# pylint: disable=import-error, protected-access +from unittest.mock import AsyncMock, patch + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, SuggestionType +from supervisor.resolution.data import Suggestion + + +async def test_check_autofix(coresys: CoreSys): + """Test check for setup.""" + coresys.core.state = CoreState.RUNNING + + coresys.resolution.fixup._create_full_snapshot.process_fixup = AsyncMock() + + with patch( + "supervisor.resolution.fixups.create_full_snapshot.FixupCreateFullSnapshot.auto", + return_value=True, + ): + await coresys.resolution.fixup.run_autofix() + + coresys.resolution.fixup._create_full_snapshot.process_fixup.assert_not_called() + + coresys.resolution.suggestions = Suggestion( + SuggestionType.CREATE_FULL_SNAPSHOT, ContextType.SYSTEM + ) + with patch( + "supervisor.resolution.fixups.create_full_snapshot.FixupCreateFullSnapshot.auto", + return_value=True, + ): + await coresys.resolution.fixup.run_autofix() + + coresys.resolution.fixup._create_full_snapshot.process_fixup.assert_called_once() + assert len(coresys.resolution.suggestions) == 0 diff --git a/tests/resolution/test_resolution_manager.py b/tests/resolution/test_resolution_manager.py index d48a01c0c..40056360e 100644 --- a/tests/resolution/test_resolution_manager.py +++ b/tests/resolution/test_resolution_manager.py @@ -1,16 +1,8 @@ """Tests for resolution manager.""" -from pathlib import Path from unittest.mock import AsyncMock import pytest -from supervisor.const import ( - ATTR_DATE, - ATTR_SLUG, - ATTR_TYPE, - SNAPSHOT_FULL, - SNAPSHOT_PARTIAL, -) from supervisor.coresys import CoreSys from supervisor.exceptions import ResolutionError from supervisor.resolution.const import ( @@ -21,9 +13,6 @@ from supervisor.resolution.const import ( 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 def test_properies_unsupported(coresys: CoreSys): @@ -44,42 +33,6 @@ def test_properies_unhealthy(coresys: CoreSys): assert not coresys.core.healthy -async def test_clear_snapshots(coresys: CoreSys, tmp_path): - """Test snapshot cleanup.""" - for slug in ["sn1", "sn2", "sn3", "sn4", "sn5"]: - temp_tar = Path(tmp_path, f"{slug}.tar") - with SecureTarFile(temp_tar, "w"): - pass - snapshot = Snapshot(coresys, temp_tar) - snapshot._data = { # pylint: disable=protected-access - ATTR_SLUG: slug, - ATTR_DATE: utcnow().isoformat(), - ATTR_TYPE: SNAPSHOT_PARTIAL - if "1" in slug or "5" in slug - else SNAPSHOT_FULL, - } - coresys.snapshots.snapshots_obj[snapshot.slug] = snapshot - - newest_full_snapshot = coresys.snapshots.snapshots_obj["sn4"] - - assert newest_full_snapshot in coresys.snapshots.list_snapshots - assert ( - len( - [x for x in coresys.snapshots.list_snapshots if x.sys_type == SNAPSHOT_FULL] - ) - == 3 - ) - - coresys.resolution.storage.clean_full_snapshots() - assert newest_full_snapshot in coresys.snapshots.list_snapshots - assert ( - len( - [x for x in coresys.snapshots.list_snapshots if x.sys_type == SNAPSHOT_FULL] - ) - == 1 - ) - - @pytest.mark.asyncio async def test_resolution_dismiss_suggestion(coresys: CoreSys): """Test resolution manager suggestion apply api.""" @@ -88,11 +41,11 @@ async def test_resolution_dismiss_suggestion(coresys: CoreSys): ) assert SuggestionType.CLEAR_FULL_SNAPSHOT == coresys.resolution.suggestions[-1].type - await coresys.resolution.dismiss_suggestion(clear_snapshot) + coresys.resolution.dismiss_suggestion(clear_snapshot) assert clear_snapshot not in coresys.resolution.suggestions with pytest.raises(ResolutionError): - await coresys.resolution.dismiss_suggestion(clear_snapshot) + coresys.resolution.dismiss_suggestion(clear_snapshot) @pytest.mark.asyncio @@ -131,11 +84,11 @@ async def test_resolution_dismiss_issue(coresys: CoreSys): ) assert IssueType.UPDATE_FAILED == coresys.resolution.issues[-1].type - await coresys.resolution.dismiss_issue(updated_failed) + coresys.resolution.dismiss_issue(updated_failed) assert updated_failed not in coresys.resolution.issues with pytest.raises(ResolutionError): - await coresys.resolution.dismiss_issue(updated_failed) + coresys.resolution.dismiss_issue(updated_failed) @pytest.mark.asyncio @@ -161,8 +114,8 @@ async def test_resolution_dismiss_unsupported(coresys: CoreSys): """Test resolution manager dismiss unsupported reason.""" coresys.resolution.unsupported = UnsupportedReason.CONTAINER - await coresys.resolution.dismiss_unsupported(UnsupportedReason.CONTAINER) + coresys.resolution.dismiss_unsupported(UnsupportedReason.CONTAINER) assert UnsupportedReason.CONTAINER not in coresys.resolution.unsupported with pytest.raises(ResolutionError): - await coresys.resolution.dismiss_unsupported(UnsupportedReason.CONTAINER) + coresys.resolution.dismiss_unsupported(UnsupportedReason.CONTAINER)