mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-03 23:36:30 +00:00
452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""Tests for resolution manager."""
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.exceptions import ResolutionError
|
|
from supervisor.resolution.const import (
|
|
ContextType,
|
|
IssueType,
|
|
SuggestionType,
|
|
UnhealthyReason,
|
|
UnsupportedReason,
|
|
)
|
|
from supervisor.resolution.data import Issue, Suggestion
|
|
|
|
|
|
def test_properies_unsupported(coresys: CoreSys):
|
|
"""Test resolution manager properties unsupported."""
|
|
assert coresys.core.supported
|
|
|
|
coresys.resolution.add_unsupported_reason(UnsupportedReason.OS)
|
|
assert not coresys.core.supported
|
|
|
|
|
|
def test_properies_unhealthy(coresys: CoreSys):
|
|
"""Test resolution manager properties unhealthy."""
|
|
assert coresys.core.healthy
|
|
|
|
coresys.resolution.add_unhealthy_reason(UnhealthyReason.SUPERVISOR)
|
|
assert not coresys.core.healthy
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolution_dismiss_suggestion(coresys: CoreSys):
|
|
"""Test resolution manager suggestion apply api."""
|
|
coresys.resolution.add_suggestion(
|
|
clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
|
|
)
|
|
|
|
assert coresys.resolution.suggestions[-1].type == SuggestionType.CLEAR_FULL_BACKUP
|
|
coresys.resolution.dismiss_suggestion(clear_backup)
|
|
assert clear_backup not in coresys.resolution.suggestions
|
|
|
|
with pytest.raises(ResolutionError):
|
|
coresys.resolution.dismiss_suggestion(clear_backup)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolution_apply_suggestion(coresys: CoreSys):
|
|
"""Test resolution manager suggestion apply api."""
|
|
coresys.resolution.add_suggestion(
|
|
clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
|
|
)
|
|
coresys.resolution.add_suggestion(
|
|
create_backup := Suggestion(
|
|
SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM
|
|
)
|
|
)
|
|
|
|
mock_backups = AsyncMock()
|
|
mock_health = AsyncMock()
|
|
coresys.backups.do_backup_full = mock_backups
|
|
coresys.resolution.healthcheck = mock_health
|
|
|
|
await coresys.resolution.apply_suggestion(clear_backup)
|
|
await coresys.resolution.apply_suggestion(create_backup)
|
|
|
|
assert mock_backups.called
|
|
assert mock_health.called
|
|
|
|
assert clear_backup not in coresys.resolution.suggestions
|
|
assert create_backup not in coresys.resolution.suggestions
|
|
|
|
with pytest.raises(ResolutionError):
|
|
await coresys.resolution.apply_suggestion(clear_backup)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolution_dismiss_issue(coresys: CoreSys):
|
|
"""Test resolution manager issue apply api."""
|
|
coresys.resolution.add_issue(
|
|
updated_failed := Issue(IssueType.UPDATE_FAILED, ContextType.SYSTEM)
|
|
)
|
|
|
|
assert coresys.resolution.issues[-1].type == IssueType.UPDATE_FAILED
|
|
coresys.resolution.dismiss_issue(updated_failed)
|
|
assert updated_failed not in coresys.resolution.issues
|
|
|
|
with pytest.raises(ResolutionError):
|
|
coresys.resolution.dismiss_issue(updated_failed)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolution_create_issue_suggestion(coresys: CoreSys):
|
|
"""Test resolution manager issue and suggestion."""
|
|
coresys.resolution.create_issue(
|
|
IssueType.UPDATE_ROLLBACK,
|
|
ContextType.CORE,
|
|
"slug",
|
|
[SuggestionType.EXECUTE_REPAIR],
|
|
)
|
|
|
|
assert coresys.resolution.issues[-1].type == IssueType.UPDATE_ROLLBACK
|
|
assert coresys.resolution.issues[-1].context == ContextType.CORE
|
|
assert coresys.resolution.issues[-1].reference == "slug"
|
|
|
|
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REPAIR
|
|
assert coresys.resolution.suggestions[-1].context == ContextType.CORE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolution_dismiss_unsupported(coresys: CoreSys):
|
|
"""Test resolution manager dismiss unsupported reason."""
|
|
coresys.resolution.add_unsupported_reason(UnsupportedReason.SOFTWARE)
|
|
|
|
coresys.resolution.dismiss_unsupported(UnsupportedReason.SOFTWARE)
|
|
assert UnsupportedReason.SOFTWARE not in coresys.resolution.unsupported
|
|
|
|
with pytest.raises(ResolutionError):
|
|
coresys.resolution.dismiss_unsupported(UnsupportedReason.SOFTWARE)
|
|
|
|
|
|
async def test_suggestions_for_issue(coresys: CoreSys):
|
|
"""Test getting suggestions that fix an issue."""
|
|
coresys.resolution.add_issue(
|
|
corrupt_repo := Issue(
|
|
IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo"
|
|
)
|
|
)
|
|
|
|
# Unrelated suggestions don't appear
|
|
coresys.resolution.add_suggestion(
|
|
Suggestion(SuggestionType.EXECUTE_RESET, ContextType.SUPERVISOR)
|
|
)
|
|
coresys.resolution.add_suggestion(
|
|
Suggestion(SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "other_repo")
|
|
)
|
|
|
|
assert coresys.resolution.suggestions_for_issue(corrupt_repo) == set()
|
|
|
|
# Related suggestions do
|
|
coresys.resolution.add_suggestion(
|
|
execute_remove := Suggestion(
|
|
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo"
|
|
)
|
|
)
|
|
coresys.resolution.add_suggestion(
|
|
execute_reset := Suggestion(
|
|
SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo"
|
|
)
|
|
)
|
|
|
|
assert coresys.resolution.suggestions_for_issue(corrupt_repo) == {
|
|
execute_reset,
|
|
execute_remove,
|
|
}
|
|
|
|
|
|
async def test_issues_for_suggestion(coresys: CoreSys):
|
|
"""Test getting issues fixed by a suggestion."""
|
|
coresys.resolution.add_suggestion(
|
|
execute_reset := Suggestion(
|
|
SuggestionType.EXECUTE_RESET, ContextType.STORE, "test_repo"
|
|
)
|
|
)
|
|
|
|
# Unrelated issues don't appear
|
|
coresys.resolution.add_issue(Issue(IssueType.FATAL_ERROR, ContextType.CORE))
|
|
coresys.resolution.add_issue(
|
|
Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "other_repo")
|
|
)
|
|
|
|
assert coresys.resolution.issues_for_suggestion(execute_reset) == set()
|
|
|
|
# Related issues do
|
|
coresys.resolution.add_issue(
|
|
fatal_error := Issue(IssueType.FATAL_ERROR, ContextType.STORE, "test_repo")
|
|
)
|
|
coresys.resolution.add_issue(
|
|
corrupt_repo := Issue(
|
|
IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "test_repo"
|
|
)
|
|
)
|
|
|
|
assert coresys.resolution.issues_for_suggestion(execute_reset) == {
|
|
fatal_error,
|
|
corrupt_repo,
|
|
}
|
|
|
|
|
|
def _supervisor_event_message(event: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Make mock supervisor event message for ha websocket."""
|
|
return {
|
|
"type": "supervisor/event",
|
|
"data": {
|
|
"event": event,
|
|
"data": data,
|
|
},
|
|
}
|
|
|
|
|
|
async def test_events_on_issue_changes(
|
|
coresys: CoreSys, supervisor_internet, ha_ws_client: AsyncMock
|
|
):
|
|
"""Test events fired when an issue changes."""
|
|
# Creating an issue with a suggestion should fire exactly one issue changed event
|
|
assert coresys.resolution.issues == []
|
|
assert coresys.resolution.suggestions == []
|
|
coresys.resolution.create_issue(
|
|
IssueType.CORRUPT_REPOSITORY,
|
|
ContextType.STORE,
|
|
"test_repo",
|
|
[SuggestionType.EXECUTE_RESET],
|
|
)
|
|
await asyncio.sleep(0)
|
|
|
|
assert len(coresys.resolution.issues) == 1
|
|
assert len(coresys.resolution.suggestions) == 1
|
|
issue = coresys.resolution.issues[0]
|
|
suggestion = coresys.resolution.suggestions[0]
|
|
issue_expected = {
|
|
"type": "corrupt_repository",
|
|
"context": "store",
|
|
"reference": "test_repo",
|
|
"uuid": issue.uuid,
|
|
}
|
|
suggestion_expected = {
|
|
"type": "execute_reset",
|
|
"context": "store",
|
|
"reference": "test_repo",
|
|
"uuid": suggestion.uuid,
|
|
}
|
|
assert _supervisor_event_message(
|
|
"issue_changed", issue_expected | {"suggestions": [suggestion_expected]}
|
|
) in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
|
|
|
|
# Adding a suggestion that fixes the issue changes it
|
|
ha_ws_client.async_send_command.reset_mock()
|
|
coresys.resolution.add_suggestion(
|
|
execute_remove := Suggestion(
|
|
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "test_repo"
|
|
)
|
|
)
|
|
await asyncio.sleep(0)
|
|
messages = [
|
|
call.args[0]
|
|
for call in ha_ws_client.async_send_command.call_args_list
|
|
if call.args[0].get("data", {}).get("event") == "issue_changed"
|
|
]
|
|
assert len(messages) == 1
|
|
sent_data = messages[0]
|
|
assert sent_data["type"] == "supervisor/event"
|
|
assert sent_data["data"]["event"] == "issue_changed"
|
|
assert sent_data["data"]["data"].items() >= issue_expected.items()
|
|
assert len(sent_data["data"]["data"]["suggestions"]) == 2
|
|
assert suggestion_expected in sent_data["data"]["data"]["suggestions"]
|
|
assert {
|
|
"type": "execute_remove",
|
|
"context": "store",
|
|
"reference": "test_repo",
|
|
"uuid": execute_remove.uuid,
|
|
} in sent_data["data"]["data"]["suggestions"]
|
|
|
|
# Removing a suggestion that fixes the issue changes it again
|
|
ha_ws_client.async_send_command.reset_mock()
|
|
coresys.resolution.dismiss_suggestion(execute_remove)
|
|
await asyncio.sleep(0)
|
|
assert _supervisor_event_message(
|
|
"issue_changed", issue_expected | {"suggestions": [suggestion_expected]}
|
|
) in [call.args[0] for call in ha_ws_client.async_send_command.call_args_list]
|
|
|
|
# Applying a suggestion should only fire an issue removed event
|
|
ha_ws_client.async_send_command.reset_mock()
|
|
with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))):
|
|
await coresys.resolution.apply_suggestion(suggestion)
|
|
|
|
await asyncio.sleep(0)
|
|
assert _supervisor_event_message("issue_removed", issue_expected) in [
|
|
call.args[0] for call in ha_ws_client.async_send_command.call_args_list
|
|
]
|
|
|
|
|
|
async def test_resolution_apply_suggestion_multiple_copies(coresys: CoreSys):
|
|
"""Test resolution manager applies correct suggestion when has multiple that differ by reference."""
|
|
coresys.resolution.add_suggestion(
|
|
remove_store_1 := Suggestion(
|
|
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_1"
|
|
)
|
|
)
|
|
coresys.resolution.add_suggestion(
|
|
remove_store_2 := Suggestion(
|
|
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_2"
|
|
)
|
|
)
|
|
coresys.resolution.add_suggestion(
|
|
remove_store_3 := Suggestion(
|
|
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_3"
|
|
)
|
|
)
|
|
|
|
await coresys.resolution.apply_suggestion(remove_store_2)
|
|
|
|
assert remove_store_1 in coresys.resolution.suggestions
|
|
assert remove_store_2 not in coresys.resolution.suggestions
|
|
assert remove_store_3 in coresys.resolution.suggestions
|
|
|
|
|
|
async def test_events_on_unsupported_changed(coresys: CoreSys):
|
|
"""Test events fired when unsupported changes."""
|
|
with patch.object(
|
|
type(coresys.homeassistant.websocket), "async_send_message"
|
|
) as send_message:
|
|
# Marking system as unsupported tells HA
|
|
assert coresys.resolution.unsupported == []
|
|
coresys.resolution.add_unsupported_reason(UnsupportedReason.CONNECTIVITY_CHECK)
|
|
await asyncio.sleep(0)
|
|
assert coresys.resolution.unsupported == [UnsupportedReason.CONNECTIVITY_CHECK]
|
|
send_message.assert_called_once_with(
|
|
_supervisor_event_message(
|
|
"supported_changed",
|
|
{"supported": False, "unsupported_reasons": ["connectivity_check"]},
|
|
)
|
|
)
|
|
|
|
# Adding the same reason again does nothing
|
|
send_message.reset_mock()
|
|
coresys.resolution.add_unsupported_reason(UnsupportedReason.CONNECTIVITY_CHECK)
|
|
await asyncio.sleep(0)
|
|
assert coresys.resolution.unsupported == [UnsupportedReason.CONNECTIVITY_CHECK]
|
|
send_message.assert_not_called()
|
|
|
|
# Adding and removing additional reasons tells HA unsupported reasons changed
|
|
coresys.resolution.add_unsupported_reason(UnsupportedReason.JOB_CONDITIONS)
|
|
await asyncio.sleep(0)
|
|
assert coresys.resolution.unsupported == [
|
|
UnsupportedReason.CONNECTIVITY_CHECK,
|
|
UnsupportedReason.JOB_CONDITIONS,
|
|
]
|
|
send_message.assert_called_once_with(
|
|
_supervisor_event_message(
|
|
"supported_changed",
|
|
{
|
|
"supported": False,
|
|
"unsupported_reasons": ["connectivity_check", "job_conditions"],
|
|
},
|
|
)
|
|
)
|
|
|
|
send_message.reset_mock()
|
|
coresys.resolution.dismiss_unsupported(UnsupportedReason.CONNECTIVITY_CHECK)
|
|
await asyncio.sleep(0)
|
|
assert coresys.resolution.unsupported == [UnsupportedReason.JOB_CONDITIONS]
|
|
send_message.assert_called_once_with(
|
|
_supervisor_event_message(
|
|
"supported_changed",
|
|
{"supported": False, "unsupported_reasons": ["job_conditions"]},
|
|
)
|
|
)
|
|
|
|
# Dismissing all unsupported reasons tells HA its supported again
|
|
send_message.reset_mock()
|
|
coresys.resolution.dismiss_unsupported(UnsupportedReason.JOB_CONDITIONS)
|
|
await asyncio.sleep(0)
|
|
assert coresys.resolution.unsupported == []
|
|
send_message.assert_called_once_with(
|
|
_supervisor_event_message(
|
|
"supported_changed", {"supported": True, "unsupported_reasons": None}
|
|
)
|
|
)
|
|
|
|
|
|
async def test_events_on_unhealthy_changed(coresys: CoreSys):
|
|
"""Test events fired when unhealthy changes."""
|
|
with patch.object(
|
|
type(coresys.homeassistant.websocket), "async_send_message"
|
|
) as send_message:
|
|
# Marking system as unhealthy tells HA
|
|
assert coresys.resolution.unhealthy == []
|
|
coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER)
|
|
await asyncio.sleep(0)
|
|
assert coresys.resolution.unhealthy == [UnhealthyReason.DOCKER]
|
|
send_message.assert_called_once_with(
|
|
_supervisor_event_message(
|
|
"health_changed",
|
|
{"healthy": False, "unhealthy_reasons": ["docker"]},
|
|
)
|
|
)
|
|
|
|
# Adding the same reason again does nothing
|
|
send_message.reset_mock()
|
|
coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER)
|
|
await asyncio.sleep(0)
|
|
assert coresys.resolution.unhealthy == [UnhealthyReason.DOCKER]
|
|
send_message.assert_not_called()
|
|
|
|
# Adding an additional reason tells HA unhealthy reasons changed
|
|
coresys.resolution.add_unhealthy_reason(UnhealthyReason.UNTRUSTED)
|
|
await asyncio.sleep(0)
|
|
assert coresys.resolution.unhealthy == [
|
|
UnhealthyReason.DOCKER,
|
|
UnhealthyReason.UNTRUSTED,
|
|
]
|
|
send_message.assert_called_once_with(
|
|
_supervisor_event_message(
|
|
"health_changed",
|
|
{"healthy": False, "unhealthy_reasons": ["docker", "untrusted"]},
|
|
)
|
|
)
|
|
|
|
|
|
async def test_dismiss_issue_removes_orphaned_suggestions(coresys: CoreSys):
|
|
"""Test dismissing an issue also removes any suggestions which have been orphaned."""
|
|
with patch.object(
|
|
type(coresys.homeassistant.websocket), "async_send_message"
|
|
) as send_message:
|
|
coresys.resolution.create_issue(
|
|
IssueType.MOUNT_FAILED,
|
|
ContextType.MOUNT,
|
|
"test",
|
|
[SuggestionType.EXECUTE_RELOAD, SuggestionType.EXECUTE_REMOVE],
|
|
)
|
|
await asyncio.sleep(0)
|
|
assert len(coresys.resolution.issues) == 1
|
|
assert len(coresys.resolution.suggestions) == 2
|
|
send_message.assert_called_once()
|
|
send_message.reset_mock()
|
|
|
|
issue = coresys.resolution.issues[0]
|
|
coresys.resolution.dismiss_issue(issue)
|
|
await asyncio.sleep(0)
|
|
|
|
# The issue and both suggestions should be dismissed as they are now orphaned
|
|
assert coresys.resolution.issues == []
|
|
assert coresys.resolution.suggestions == []
|
|
|
|
# Only one message should fire to tell HA the issue was removed
|
|
send_message.assert_called_once_with(
|
|
_supervisor_event_message(
|
|
"issue_removed",
|
|
{
|
|
"type": "mount_failed",
|
|
"context": "mount",
|
|
"reference": "test",
|
|
"uuid": issue.uuid,
|
|
},
|
|
)
|
|
)
|