1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2025-12-20 10:28:45 +00:00

Persistent notifications to repairs and fix free_space check (#6179)

* Persistent notifications to repairs and fix free_space check

* Fix tests mocking too little free space
This commit is contained in:
Mike Degatano
2025-09-16 11:22:59 -04:00
committed by GitHub
parent 857dae7736
commit 01911a44cd
7 changed files with 30 additions and 106 deletions

View File

@@ -1,15 +1,8 @@
"""Helpers to check and fix issues with free space.""" """Helpers to check and fix issues with free space."""
from ...backups.const import BackupType
from ...const import CoreState from ...const import CoreState
from ...coresys import CoreSys from ...coresys import CoreSys
from ..const import ( from ..const import MINIMUM_FREE_SPACE_THRESHOLD, ContextType, IssueType
MINIMUM_FREE_SPACE_THRESHOLD,
MINIMUM_FULL_BACKUPS,
ContextType,
IssueType,
SuggestionType,
)
from .base import CheckBase from .base import CheckBase
@@ -23,31 +16,12 @@ class CheckFreeSpace(CheckBase):
async def run_check(self) -> None: async def run_check(self) -> None:
"""Run check if not affected by issue.""" """Run check if not affected by issue."""
if await self.sys_host.info.free_space() > MINIMUM_FREE_SPACE_THRESHOLD: if await self.approve_check():
return self.sys_resolution.create_issue(IssueType.FREE_SPACE, ContextType.SYSTEM)
suggestions: list[SuggestionType] = []
if (
len(
[
backup
for backup in self.sys_backups.list_backups
if backup.sys_type == BackupType.FULL
]
)
> MINIMUM_FULL_BACKUPS
):
suggestions.append(SuggestionType.CLEAR_FULL_BACKUP)
self.sys_resolution.create_issue(
IssueType.FREE_SPACE, ContextType.SYSTEM, suggestions=suggestions
)
async def approve_check(self, reference: str | None = None) -> bool: async def approve_check(self, reference: str | None = None) -> bool:
"""Approve check if it is affected by issue.""" """Approve check if it is affected by issue."""
if await self.sys_host.info.free_space() > MINIMUM_FREE_SPACE_THRESHOLD: return await self.sys_host.info.free_space() <= MINIMUM_FREE_SPACE_THRESHOLD
return False
return True
@property @property
def issue(self) -> IssueType: def issue(self) -> IssueType:

View File

@@ -9,7 +9,7 @@ FILE_CONFIG_RESOLUTION = Path(SUPERVISOR_DATA, "resolution.json")
SCHEDULED_HEALTHCHECK = 3600 SCHEDULED_HEALTHCHECK = 3600
MINIMUM_FREE_SPACE_THRESHOLD = 1 MINIMUM_FREE_SPACE_THRESHOLD = 2
MINIMUM_FULL_BACKUPS = 2 MINIMUM_FULL_BACKUPS = 2
DNS_CHECK_HOST = "_checkdns.home-assistant.io" DNS_CHECK_HOST = "_checkdns.home-assistant.io"

View File

@@ -10,9 +10,16 @@ from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import HomeAssistantAPIError from ..exceptions import HomeAssistantAPIError
from .checks.core_security import SecurityReference from .checks.core_security import SecurityReference
from .const import ContextType, IssueType from .const import ContextType, IssueType
from .data import Issue
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
ISSUE_SECURITY_CUSTOM_COMP_2021_1_5 = Issue(
IssueType.SECURITY,
ContextType.CORE,
reference=SecurityReference.CUSTOM_COMPONENTS_BELOW_2021_1_5,
)
class ResolutionNotify(CoreSysAttributes): class ResolutionNotify(CoreSysAttributes):
"""Notify class for resolution.""" """Notify class for resolution."""
@@ -29,44 +36,17 @@ class ResolutionNotify(CoreSysAttributes):
): ):
return return
messages = [] # This one issue must remain a persistent notification rather then a repair because repairs didn't exist in HA 2021.1.5
if ISSUE_SECURITY_CUSTOM_COMP_2021_1_5 in self.sys_resolution.issues:
for issue in self.sys_resolution.issues:
if issue.type == IssueType.FREE_SPACE:
messages.append(
{
"title": "Available space is less than 1GB!",
"message": f"Available space is {await 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",
}
)
if issue.type == IssueType.SECURITY and issue.context == ContextType.CORE:
if (
issue.reference
== SecurityReference.CUSTOM_COMPONENTS_BELOW_2021_1_5
):
messages.append(
{
"title": "Security notification",
"message": "The Supervisor detected that this version of Home Assistant could be insecure in combination with custom integrations. [Update as soon as possible.](/hassio/dashboard)\n\nFor more information see the [Security alert](https://www.home-assistant.io/latest-security-alert).",
"notification_id": "supervisor_update_home_assistant_2021_1_5",
}
)
if issue.type == IssueType.PWNED and issue.context == ContextType.ADDON:
messages.append(
{
"title": f"Insecure secrets in {issue.reference}",
"message": f"The add-on {issue.reference} uses secrets which are detected as not secure, see https://www.home-assistant.io/more-info/pwned-passwords for more information.",
"notification_id": f"supervisor_issue_pwned_{issue.reference}",
}
)
for message in messages:
try: try:
async with self.sys_homeassistant.api.make_request( async with self.sys_homeassistant.api.make_request(
"post", "post",
"api/services/persistent_notification/create", "api/services/persistent_notification/create",
json=message, json={
"title": "Security notification",
"message": "The Supervisor detected that this version of Home Assistant could be insecure in combination with custom integrations. [Update as soon as possible.](/hassio/dashboard)\n\nFor more information see the [Security alert](https://www.home-assistant.io/latest-security-alert).",
"notification_id": "supervisor_update_home_assistant_2021_1_5",
},
) as resp: ) as resp:
if resp.status in (200, 201): if resp.status in (200, 201):
_LOGGER.debug("Successfully created persistent_notification") _LOGGER.debug("Successfully created persistent_notification")

View File

@@ -139,10 +139,10 @@ async def test_free_space(coresys: CoreSys):
return True return True
test = TestClass(coresys) test = TestClass(coresys)
with patch("shutil.disk_usage", return_value=(42, 42, (1024.0**3))): with patch("shutil.disk_usage", return_value=(42, 42, (2048.0**3))):
assert await test.execute() assert await test.execute()
with patch("shutil.disk_usage", return_value=(42, 42, (512.0**3))): with patch("shutil.disk_usage", return_value=(42, 42, (1024.0**3))):
assert not await test.execute() assert not await test.execute()
coresys.jobs.ignore_conditions = [JobCondition.FREE_SPACE] coresys.jobs.ignore_conditions = [JobCondition.FREE_SPACE]

View File

@@ -70,7 +70,7 @@ async def test_if_check_cleanup_issue(coresys: CoreSys):
assert free_space in coresys.resolution.issues assert free_space in coresys.resolution.issues
with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))): with patch("shutil.disk_usage", return_value=(42, 42, 3 * (1024.0**3))):
await coresys.resolution.check.check_system() await coresys.resolution.check.check_system()
assert free_space not in coresys.resolution.issues assert free_space not in coresys.resolution.issues

View File

@@ -1,33 +1,12 @@
"""Test check free space fixup.""" """Test check free space fixup."""
# pylint: disable=import-error,protected-access # pylint: disable=import-error,protected-access
from unittest.mock import MagicMock, PropertyMock, patch from unittest.mock import patch
import pytest
from supervisor.backups.const import BackupType
from supervisor.const import CoreState from supervisor.const import CoreState
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.resolution.checks.free_space import CheckFreeSpace from supervisor.resolution.checks.free_space import CheckFreeSpace
from supervisor.resolution.const import IssueType, SuggestionType from supervisor.resolution.const import IssueType
@pytest.fixture(name="suggestion")
async def fixture_suggestion(
coresys: CoreSys, request: pytest.FixtureRequest
) -> SuggestionType | None:
"""Set up test for suggestion."""
if request.param == SuggestionType.CLEAR_FULL_BACKUP:
backup = MagicMock()
backup.sys_type = BackupType.FULL
with patch.object(
type(coresys.backups),
"list_backups",
new=PropertyMock(return_value=[backup, backup, backup]),
):
yield SuggestionType.CLEAR_FULL_BACKUP
else:
yield request.param
async def test_base(coresys: CoreSys): async def test_base(coresys: CoreSys):
@@ -37,19 +16,14 @@ async def test_base(coresys: CoreSys):
assert free_space.enabled assert free_space.enabled
@pytest.mark.parametrize( async def test_check(coresys: CoreSys):
"suggestion",
[None, SuggestionType.CLEAR_FULL_BACKUP],
indirect=True,
)
async def test_check(coresys: CoreSys, suggestion: SuggestionType | None):
"""Test check.""" """Test check."""
free_space = CheckFreeSpace(coresys) free_space = CheckFreeSpace(coresys)
await coresys.core.set_state(CoreState.RUNNING) await coresys.core.set_state(CoreState.RUNNING)
assert len(coresys.resolution.issues) == 0 assert len(coresys.resolution.issues) == 0
with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))): with patch("shutil.disk_usage", return_value=(42, 42, 3 * (1024.0**3))):
await free_space.run_check() await free_space.run_check()
assert len(coresys.resolution.issues) == 0 assert len(coresys.resolution.issues) == 0
@@ -58,10 +32,6 @@ async def test_check(coresys: CoreSys, suggestion: SuggestionType | None):
await free_space.run_check() await free_space.run_check()
assert coresys.resolution.issues[-1].type == IssueType.FREE_SPACE assert coresys.resolution.issues[-1].type == IssueType.FREE_SPACE
if suggestion:
assert coresys.resolution.suggestions[-1].type == suggestion
else:
assert len(coresys.resolution.suggestions) == 0 assert len(coresys.resolution.suggestions) == 0
@@ -73,7 +43,7 @@ async def test_approve(coresys: CoreSys):
with patch("shutil.disk_usage", return_value=(1, 1, 1)): with patch("shutil.disk_usage", return_value=(1, 1, 1)):
assert await free_space.approve_check() assert await free_space.approve_check()
with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))): with patch("shutil.disk_usage", return_value=(42, 42, 3 * (1024.0**3))):
assert not await free_space.approve_check() assert not await free_space.approve_check()

View File

@@ -170,7 +170,7 @@ async def test_update_unavailable_addon(
"version", "version",
new=PropertyMock(return_value=AwesomeVersion("2022.1.1")), new=PropertyMock(return_value=AwesomeVersion("2022.1.1")),
), ),
patch("shutil.disk_usage", return_value=(42, 42, (1024.0**3))), patch("shutil.disk_usage", return_value=(42, 42, (5120.0**3))),
): ):
with pytest.raises(AddonNotSupportedError): with pytest.raises(AddonNotSupportedError):
await coresys.addons.update("local_ssh", backup=True) await coresys.addons.update("local_ssh", backup=True)
@@ -226,7 +226,7 @@ async def test_install_unavailable_addon(
"version", "version",
new=PropertyMock(return_value=AwesomeVersion("2022.1.1")), new=PropertyMock(return_value=AwesomeVersion("2022.1.1")),
), ),
patch("shutil.disk_usage", return_value=(42, 42, (1024.0**3))), patch("shutil.disk_usage", return_value=(42, 42, (5120.0**3))),
pytest.raises(AddonNotSupportedError), pytest.raises(AddonNotSupportedError),
): ):
await coresys.addons.install("local_ssh") await coresys.addons.install("local_ssh")