Add issues/suggestion to resolution center / start with diskspace (#2125)

Co-authored-by: Pascal Vizeli <pvizeli@syshack.ch>
This commit is contained in:
Joakim Sørensen 2020-10-14 17:14:25 +02:00 committed by GitHub
parent d599c3ad76
commit 02e72726a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 375 additions and 27 deletions

View File

@ -197,7 +197,16 @@ class RestAPI(CoreSysAttributes):
api_resolution = APIResoulution() api_resolution = APIResoulution()
api_resolution.coresys = self.coresys api_resolution.coresys = self.coresys
self.webapp.add_routes([web.get("/resolution", api_resolution.base)]) self.webapp.add_routes(
[
web.get("/resolution", api_resolution.base),
web.post("/resolution/{suggestion}", api_resolution.apply_suggestion),
web.post(
"/resolution/{suggestion}/dismiss",
api_resolution.dismiss_suggestion,
),
]
)
def _register_auth(self) -> None: def _register_auth(self) -> None:
"""Register auth functions.""" """Register auth functions."""

View File

@ -3,8 +3,10 @@ from typing import Any, Dict
from aiohttp import web from aiohttp import web
from ..const import ATTR_UNSUPPORTED from ..const import ATTR_ISSUES, ATTR_SUGGESTIONS, ATTR_UNSUPPORTED
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from ..resolution.const import Suggestion
from .utils import api_process from .utils import api_process
@ -14,4 +16,26 @@ class APIResoulution(CoreSysAttributes):
@api_process @api_process
async def base(self, request: web.Request) -> Dict[str, Any]: async def base(self, request: web.Request) -> Dict[str, Any]:
"""Return network information.""" """Return network information."""
return {ATTR_UNSUPPORTED: self.sys_resolution.unsupported} return {
ATTR_UNSUPPORTED: self.sys_resolution.unsupported,
ATTR_SUGGESTIONS: self.sys_resolution.suggestions,
ATTR_ISSUES: 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"))
await self.sys_resolution.apply_suggestion(suggestion)
except ValueError:
raise APIError("Suggestion is not valid") from None
@api_process
async def dismiss_suggestion(self, request: web.Request) -> None:
"""Dismiss suggestion."""
try:
suggestion = Suggestion(request.match_info.get("suggestion"))
await self.sys_resolution.dismiss_suggestion(suggestion)
except ValueError:
raise APIError("Suggestion is not valid") from None

View File

@ -162,6 +162,7 @@ ATTR_HOST_NETWORK = "host_network"
ATTR_HOST_PID = "host_pid" ATTR_HOST_PID = "host_pid"
ATTR_HOSTNAME = "hostname" ATTR_HOSTNAME = "hostname"
ATTR_ICON = "icon" ATTR_ICON = "icon"
ATTR_ISSUES = "issues"
ATTR_ID = "id" ATTR_ID = "id"
ATTR_IMAGE = "image" ATTR_IMAGE = "image"
ATTR_IMAGES = "images" ATTR_IMAGES = "images"
@ -249,6 +250,7 @@ ATTR_STATE = "state"
ATTR_STATIC = "static" ATTR_STATIC = "static"
ATTR_STDIN = "stdin" ATTR_STDIN = "stdin"
ATTR_STORAGE = "storage" ATTR_STORAGE = "storage"
ATTR_SUGGESTIONS = "suggestions"
ATTR_SUPERVISOR = "supervisor" ATTR_SUPERVISOR = "supervisor"
ATTR_SUPPORTED = "supported" ATTR_SUPPORTED = "supported"
ATTR_SUPPORTED_ARCH = "supported_arch" ATTR_SUPPORTED_ARCH = "supported_arch"
@ -429,17 +431,3 @@ class HostFeature(str, Enum):
REBOOT = "reboot" REBOOT = "reboot"
SERVICES = "services" SERVICES = "services"
SHUTDOWN = "shutdown" SHUTDOWN = "shutdown"
class UnsupportedReason(str, Enum):
"""Reasons for unsupported status."""
CONTAINER = "container"
DBUS = "dbus"
DOCKER_CONFIGURATION = "docker_configuration"
DOCKER_VERSION = "docker_version"
LXC = "lxc"
NETWORK_MANAGER = "network_manager"
OS = "os"
PRIVILEGED = "privileged"
SYSTEMD = "systemd"

View File

@ -13,7 +13,6 @@ from .const import (
AddonStartup, AddonStartup,
CoreState, CoreState,
HostFeature, HostFeature,
UnsupportedReason,
) )
from .coresys import CoreSys, CoreSysAttributes from .coresys import CoreSys, CoreSysAttributes
from .exceptions import ( from .exceptions import (
@ -23,6 +22,7 @@ from .exceptions import (
HomeAssistantError, HomeAssistantError,
SupervisorUpdateError, SupervisorUpdateError,
) )
from .resolution.const import UnsupportedReason
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -159,6 +159,9 @@ class Core(CoreSysAttributes):
# Load ingress # Load ingress
await self.sys_ingress.load() await self.sys_ingress.load()
# Load Resoulution
await self.sys_resolution.load()
# Check supported OS # Check supported OS
if not self.sys_hassos.available: if not self.sys_hassos.available:
if self.sys_host.info.operating_system not in SUPERVISED_SUPPORTED_OS: if self.sys_host.info.operating_system not in SUPERVISED_SUPPORTED_OS:

View File

@ -12,6 +12,7 @@ from ..exceptions import (
MulticastError, MulticastError,
ObserverError, ObserverError,
) )
from ..resolution.const import MINIMUM_FREE_SPACE_THRESHOLD, IssueType
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -127,6 +128,11 @@ class Tasks(CoreSysAttributes):
) )
continue continue
if self.sys_host.info.free_space > MINIMUM_FREE_SPACE_THRESHOLD:
_LOGGER.warning("Not enough free space, pausing add-on updates")
self.sys_resolution.issues = IssueType.FREE_SPACE
return
# Run Add-on update sequential # Run Add-on update sequential
# avoid issue on slow IO # avoid issue on slow IO
_LOGGER.info("Add-on auto update process %s", addon.slug) _LOGGER.info("Add-on auto update process %s", addon.slug)
@ -145,6 +151,11 @@ class Tasks(CoreSysAttributes):
_LOGGER.warning("Ignore Supervisor update on dev channel!") _LOGGER.warning("Ignore Supervisor update on dev channel!")
return return
if self.sys_host.info.free_space > MINIMUM_FREE_SPACE_THRESHOLD:
_LOGGER.warning("Not enough free space, pausing supervisor update")
self.sys_resolution.issues = IssueType.FREE_SPACE
return
_LOGGER.info("Found new Supervisor version") _LOGGER.info("Found new Supervisor version")
await self.sys_supervisor.update() await self.sys_supervisor.update()

View File

@ -1,8 +1,14 @@
"""Supervisor resolution center.""" """Supervisor resolution center."""
import logging
from typing import List from typing import List
from ..const import UnsupportedReason
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..resolution.const import UnsupportedReason
from .const import SCHEDULED_HEALTHCHECK, IssueType, Suggestion
from .free_space import ResolutionStorage
from .notify import ResolutionNotify
_LOGGER: logging.Logger = logging.getLogger(__name__)
class ResolutionManager(CoreSysAttributes): class ResolutionManager(CoreSysAttributes):
@ -11,8 +17,45 @@ class ResolutionManager(CoreSysAttributes):
def __init__(self, coresys: CoreSys): def __init__(self, coresys: CoreSys):
"""Initialize Resolution manager.""" """Initialize Resolution manager."""
self.coresys: CoreSys = coresys 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._unsupported: List[UnsupportedReason] = [] self._unsupported: List[UnsupportedReason] = []
@property
def storage(self) -> ResolutionStorage:
"""Return the ResolutionStorage class."""
return self._storage
@property
def notify(self) -> ResolutionNotify:
"""Return the ResolutionNotify class."""
return self._notify
@property
def issues(self) -> List[IssueType]:
"""Return a list of issues."""
return self._issues
@issues.setter
def issues(self, issue: IssueType) -> None:
"""Add issues."""
if issue not in self._issues:
self._issues.append(issue)
@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]
@suggestions.setter
def suggestions(self, suggestion: Suggestion) -> None:
"""Add suggestion."""
if suggestion not in self._suggestions:
self._suggestions.append(suggestion)
@property @property
def unsupported(self) -> List[UnsupportedReason]: def unsupported(self) -> List[UnsupportedReason]:
"""Return a list of unsupported reasons.""" """Return a list of unsupported reasons."""
@ -21,4 +64,45 @@ class ResolutionManager(CoreSysAttributes):
@unsupported.setter @unsupported.setter
def unsupported(self, reason: UnsupportedReason) -> None: def unsupported(self, reason: UnsupportedReason) -> None:
"""Add a reason for unsupported.""" """Add a reason for unsupported."""
if reason not in self._unsupported:
self._unsupported.append(reason) self._unsupported.append(reason)
async def load(self):
"""Load the resoulution manager."""
# Initial healthcheck when the manager is loaded
await self.healthcheck()
# Schedule the healthcheck
self.sys_scheduler.register_task(self.healthcheck, SCHEDULED_HEALTHCHECK)
async def healthcheck(self):
"""Scheduled task to check for known issues."""
# Check free space
self.sys_run_in_executor(self.storage.check_free_space)
# Create notification for any known issues
await self.notify.issue_notifications()
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 == Suggestion.CLEAR_FULL_SNAPSHOT:
self.storage.clean_full_snapshots()
elif suggestion == Suggestion.CREATE_FULL_SNAPSHOT:
await self.sys_snapshots.do_snapshot_full()
self._suggestions.remove(suggestion)
await self.healthcheck()
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._dismissed_suggestions:
self._dismissed_suggestions.append(suggestion)

View File

@ -0,0 +1,34 @@
"""Constants for the resoulution manager."""
from enum import Enum
SCHEDULED_HEALTHCHECK = 3600
MINIMUM_FREE_SPACE_THRESHOLD = 1
MINIMUM_FULL_SNAPSHOTS = 2
class UnsupportedReason(str, Enum):
"""Reasons for unsupported status."""
CONTAINER = "container"
DBUS = "dbus"
DOCKER_CONFIGURATION = "docker_configuration"
DOCKER_VERSION = "docker_version"
LXC = "lxc"
NETWORK_MANAGER = "network_manager"
OS = "os"
PRIVILEGED = "privileged"
SYSTEMD = "systemd"
class IssueType(str, Enum):
"""Issue type."""
FREE_SPACE = "free_space"
class Suggestion(str, Enum):
"""Sugestion."""
CLEAR_FULL_SNAPSHOT = "clear_full_snapshot"
CREATE_FULL_SNAPSHOT = "create_full_snapshot"

View File

@ -0,0 +1,57 @@
"""Helpers to check and fix issues with free space."""
import logging
from ..const import SNAPSHOT_FULL
from ..coresys import CoreSys, CoreSysAttributes
from .const import (
MINIMUM_FREE_SPACE_THRESHOLD,
MINIMUM_FULL_SNAPSHOTS,
IssueType,
Suggestion,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
class ResolutionStorage(CoreSysAttributes):
"""Storage class for resolution."""
def __init__(self, coresys: CoreSys):
"""Initialize the storage class."""
self.coresys = coresys
def check_free_space(self) -> None:
"""Check free space."""
if self.sys_host.info.free_space > MINIMUM_FREE_SPACE_THRESHOLD:
return
self.sys_resolution.issues = IssueType.FREE_SPACE
if (
len(
[
x
for x in self.sys_snapshots.list_snapshots
if x.sys_type == SNAPSHOT_FULL
]
)
>= MINIMUM_FULL_SNAPSHOTS
):
self.sys_resolution.suggestions = Suggestion.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
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
]
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)

View File

@ -0,0 +1,55 @@
"""
Helper to notify Core about issues.
This helper creates persistant notification in the Core UI.
In the future we want to remove this in favour of a "center" in the UI.
"""
import logging
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import HomeAssistantAPIError
from .const import IssueType
_LOGGER: logging.Logger = logging.getLogger(__name__)
class ResolutionNotify(CoreSysAttributes):
"""Notify class for resolution."""
def __init__(self, coresys: CoreSys):
"""Initialize the notify class."""
self.coresys = coresys
async def issue_notifications(self):
"""Create persistant notifications about issues."""
if (
not self.sys_resolution.issues
or not self.sys_homeassistant.api.check_api_state()
):
return
issues = []
for issue in self.sys_resolution.issues:
if issue == IssueType.FREE_SPACE:
issues.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.",
"notification_id": "supervisor_issue_free_space",
}
)
for issue in issues:
try:
async with self.sys_homeassistant.api.make_request(
"post",
"api/services/persistent_notification/create",
json=issue,
) as resp:
if resp.status in (200, 201):
_LOGGER.debug("Sucessfully created persistent_notification")
else:
_LOGGER.error("Can't create persistant notification")
except HomeAssistantAPIError:
_LOGGER.error("Can't create persistant notification")

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Set
from ..const import FOLDER_HOMEASSISTANT, SNAPSHOT_FULL, SNAPSHOT_PARTIAL, CoreState from ..const import FOLDER_HOMEASSISTANT, SNAPSHOT_FULL, SNAPSHOT_PARTIAL, CoreState
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
@ -23,7 +24,7 @@ class SnapshotManager(CoreSysAttributes):
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
@property @property
def list_snapshots(self): def list_snapshots(self) -> Set[Snapshot]:
"""Return a list of all snapshot object.""" """Return a list of all snapshot object."""
return set(self.snapshots_obj.values()) return set(self.snapshots_obj.values())
@ -139,8 +140,9 @@ class SnapshotManager(CoreSysAttributes):
_LOGGER.info("Snapshot %s store folders", snapshot.slug) _LOGGER.info("Snapshot %s store folders", snapshot.slug)
await snapshot.store_folders() await snapshot.store_folders()
except Exception: # pylint: disable=broad-except except Exception as excep: # pylint: disable=broad-except
_LOGGER.exception("Snapshot %s error", snapshot.slug) _LOGGER.exception("Snapshot %s error", snapshot.slug)
print(excep)
return None return None
else: else:

View File

@ -1,13 +1,47 @@
"""Test Resolution API.""" """Test Resolution API."""
from unittest.mock import MagicMock, patch
import pytest import pytest
from supervisor.const import ATTR_UNSUPPORTED, UnsupportedReason from supervisor.const import ATTR_ISSUES, ATTR_SUGGESTIONS, ATTR_UNSUPPORTED
from supervisor.coresys import CoreSys
from supervisor.resolution.const import IssueType, Suggestion, UnsupportedReason
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_api_resolution_base(coresys, api_client): async def test_api_resolution_base(coresys: CoreSys, api_client):
"""Test resolution manager api.""" """Test resolution manager api."""
coresys.resolution.unsupported = UnsupportedReason.OS coresys.resolution.unsupported = UnsupportedReason.OS
coresys.resolution.suggestions = Suggestion.CLEAR_FULL_SNAPSHOT
coresys.resolution.issues = IssueType.FREE_SPACE
resp = await api_client.get("/resolution") resp = await api_client.get("/resolution")
result = await resp.json() result = await resp.json()
assert UnsupportedReason.OS in result["data"][ATTR_UNSUPPORTED] 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]
@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
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
@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
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)
assert Suggestion.CLEAR_FULL_SNAPSHOT not in coresys.resolution.suggestions
assert Suggestion.CREATE_FULL_SNAPSHOT not in coresys.resolution.suggestions
await coresys.resolution.apply_suggestion(Suggestion.CLEAR_FULL_SNAPSHOT)

View File

@ -4,9 +4,10 @@ from unittest.mock import patch
import pytest import pytest
from supervisor.const import SUPERVISOR_VERSION, CoreState, UnsupportedReason from supervisor.const import SUPERVISOR_VERSION, CoreState
from supervisor.exceptions import AddonConfigurationError from supervisor.exceptions import AddonConfigurationError
from supervisor.misc.filter import filter_data from supervisor.misc.filter import filter_data
from supervisor.resolution.const import UnsupportedReason
SAMPLE_EVENT = {"sample": "event", "extra": {"Test": "123"}} SAMPLE_EVENT = {"sample": "event", "extra": {"Test": "123"}}

View File

@ -1,8 +1,18 @@
"""Tests for resolution manager.""" """Tests for resolution manager."""
from pathlib import Path
from supervisor.const import (
from supervisor.const import UnsupportedReason ATTR_DATE,
ATTR_SLUG,
ATTR_TYPE,
SNAPSHOT_FULL,
SNAPSHOT_PARTIAL,
)
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.resolution.const import UnsupportedReason
from supervisor.snapshots.snapshot import Snapshot
from supervisor.utils.dt import utcnow
from supervisor.utils.tar import SecureTarFile
def test_properies(coresys: CoreSys): def test_properies(coresys: CoreSys):
@ -12,3 +22,39 @@ def test_properies(coresys: CoreSys):
coresys.resolution.unsupported = UnsupportedReason.OS coresys.resolution.unsupported = UnsupportedReason.OS
assert not coresys.core.supported assert not coresys.core.supported
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
)