mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-15 21:26:29 +00:00
Extend resolution center (#2297)
* Extend resolution center Signed-off-by: Pascal Vizeli <pvizeli@syshack.ch> * move forward Signed-off-by: Pascal Vizeli <pvizeli@syshack.ch> * Rename it to fixups Signed-off-by: Pascal Vizeli <pvizeli@syshack.ch> * Finish p1 Signed-off-by: Pascal Vizeli <pvizeli@syshack.ch> * Finish p1 - add files Signed-off-by: Pascal Vizeli <pvizeli@syshack.ch> * Finishup * Add more tests * Add test for suggestion * Add more tests * fix tests & isort * address comments * address comments v2 * fix isort * Change reference handling
This commit is contained in:
parent
7cccbc682c
commit
fda1b523ba
@ -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
|
||||
|
@ -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")
|
||||
|
@ -295,6 +295,10 @@ class ResolutionNotFound(ResolutionError):
|
||||
"""Raise if suggestion/issue was not found."""
|
||||
|
||||
|
||||
class ResolutionFixupError(HassioError):
|
||||
"""Rasie if a fixup fails."""
|
||||
|
||||
|
||||
# Store
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
38
supervisor/resolution/check.py
Normal file
38
supervisor/resolution/check.py
Normal file
@ -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")
|
1
supervisor/resolution/checks/__init__.py
Normal file
1
supervisor/resolution/checks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Initialize system."""
|
51
supervisor/resolution/checks/base.py
Normal file
51
supervisor/resolution/checks/base.py
Normal file
@ -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 []
|
@ -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]
|
@ -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
|
||||
|
@ -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]:
|
||||
|
51
supervisor/resolution/fixup.py
Normal file
51
supervisor/resolution/fixup.py
Normal file
@ -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()
|
1
supervisor/resolution/fixups/__init__.py
Normal file
1
supervisor/resolution/fixups/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Initialize system."""
|
75
supervisor/resolution/fixups/base.py
Normal file
75
supervisor/resolution/fixups/base.py
Normal file
@ -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
|
41
supervisor/resolution/fixups/clear_full_snapshot.py
Normal file
41
supervisor/resolution/fixups/clear_full_snapshot.py
Normal file
@ -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
|
27
supervisor/resolution/fixups/create_full_snapshot.py
Normal file
27
supervisor/resolution/fixups/create_full_snapshot.py
Normal file
@ -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
|
@ -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():
|
||||
|
39
tests/resolution/check/test_check.py
Normal file
39
tests/resolution/check/test_check.py
Normal file
@ -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
|
51
tests/resolution/check/test_check_free_space.py
Normal file
51
tests/resolution/check/test_check_free_space.py
Normal file
@ -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()
|
@ -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
|
64
tests/resolution/fixup/test_clear_full_snapshot.py
Normal file
64
tests/resolution/fixup/test_clear_full_snapshot.py
Normal file
@ -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
|
27
tests/resolution/fixup/test_create_full_snapshot.py
Normal file
27
tests/resolution/fixup/test_create_full_snapshot.py
Normal file
@ -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
|
35
tests/resolution/fixup/test_fixup.py
Normal file
35
tests/resolution/fixup/test_fixup.py
Normal file
@ -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
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user