diff --git a/aiohasupervisor/models/__init__.py b/aiohasupervisor/models/__init__.py index a281737..c888d05 100644 --- a/aiohasupervisor/models/__init__.py +++ b/aiohasupervisor/models/__init__.py @@ -23,6 +23,19 @@ StoreInfo, SupervisorRole, ) +from aiohasupervisor.models.resolution import ( + Check, + CheckOptions, + CheckType, + ContextType, + Issue, + IssueType, + ResolutionInfo, + Suggestion, + SuggestionType, + UnhealthyReason, + UnsupportedReason, +) from aiohasupervisor.models.root import ( AvailableUpdate, HostFeature, @@ -62,4 +75,15 @@ "StoreInfo", "StoreAddonUpdate", "StoreAddRepository", + "Check", + "CheckOptions", + "CheckType", + "ContextType", + "Issue", + "IssueType", + "ResolutionInfo", + "Suggestion", + "SuggestionType", + "UnhealthyReason", + "UnsupportedReason", ] diff --git a/aiohasupervisor/models/resolution.py b/aiohasupervisor/models/resolution.py new file mode 100644 index 0000000..59b51b0 --- /dev/null +++ b/aiohasupervisor/models/resolution.py @@ -0,0 +1,210 @@ +"""Models for resolution center APIs.""" + +from dataclasses import dataclass +from enum import StrEnum +from uuid import UUID + +from .base import Options, ResponseData + +# --- ENUMS ---- + + +class SuggestionType(StrEnum): + """SuggestionType type. + + This is an incomplete list. Supervisor regularly adds new types of suggestions as + they are discovered. Therefore when returning an suggestion, it may have a type that + is not in this list parsed as strings on older versions of the client. + """ + + ADOPT_DATA_DISK = "adopt_data_disk" + CLEAR_FULL_BACKUP = "clear_full_backup" + CREATE_FULL_BACKUP = "create_full_backup" + EXECUTE_INTEGRITY = "execute_integrity" + EXECUTE_REBOOT = "execute_reboot" + EXECUTE_REBUILD = "execute_rebuild" + EXECUTE_RELOAD = "execute_reload" + EXECUTE_REMOVE = "execute_remove" + EXECUTE_REPAIR = "execute_repair" + EXECUTE_RESET = "execute_reset" + EXECUTE_STOP = "execute_stop" + EXECUTE_UPDATE = "execute_update" + REGISTRY_LOGIN = "registry_login" + RENAME_DATA_DISK = "rename_data_disk" + + +class IssueType(StrEnum): + """IssueType type. + + This is an incomplete list. Supervisor regularly adds new types of issues as they + are discovered. Therefore when returning an issue, it may have a type that is not + in this list parsed as strings on older versions of the client. + """ + + CORRUPT_DOCKER = "corrupt_docker" + CORRUPT_REPOSITORY = "corrupt_repository" + CORRUPT_FILESYSTEM = "corrupt_filesystem" + DETACHED_ADDON_MISSING = "detached_addon_missing" + DETACHED_ADDON_REMOVED = "detached_addon_removed" + DISABLED_DATA_DISK = "disabled_data_disk" + DNS_LOOP = "dns_loop" + DNS_SERVER_FAILED = "dns_server_failed" + DNS_SERVER_IPV6_ERROR = "dns_server_ipv6_error" + DOCKER_CONFIG = "docker_config" + DOCKER_RATELIMIT = "docker_ratelimit" + FATAL_ERROR = "fatal_error" + FREE_SPACE = "free_space" + IPV4_CONNECTION_PROBLEM = "ipv4_connection_problem" + MISSING_IMAGE = "missing_image" + MOUNT_FAILED = "mount_failed" + MULTIPLE_DATA_DISKS = "multiple_data_disks" + NO_CURRENT_BACKUP = "no_current_backup" + PWNED = "pwned" + REBOOT_REQUIRED = "reboot_required" + SECURITY = "security" + TRUST = "trust" + UPDATE_FAILED = "update_failed" + UPDATE_ROLLBACK = "update_rollback" + + +class UnsupportedReason(StrEnum): + """UnsupportedReason type. + + This is an incomplete list. Supervisor regularly adds new unsupported + reasons as they are discovered. Therefore when returning a list of unsupported + reasons, some may not be in this list parsed as strings on older versions of the + client. + """ + + APPARMOR = "apparmor" + CGROUP_VERSION = "cgroup_version" + CONNECTIVITY_CHECK = "connectivity_check" + CONTENT_TRUST = "content_trust" + DBUS = "dbus" + DNS_SERVER = "dns_server" + DOCKER_CONFIGURATION = "docker_configuration" + DOCKER_VERSION = "docker_version" + JOB_CONDITIONS = "job_conditions" + LXC = "lxc" + NETWORK_MANAGER = "network_manager" + OS = "os" + OS_AGENT = "os_agent" + PRIVILEGED = "privileged" + RESTART_POLICY = "restart_policy" + SOFTWARE = "software" + SOURCE_MODS = "source_mods" + SUPERVISOR_VERSION = "supervisor_version" + SYSTEMD = "systemd" + SYSTEMD_JOURNAL = "systemd_journal" + SYSTEMD_RESOLVED = "systemd_resolved" + VIRTUALIZATION_IMAGE = "virtualization_image" + + +class UnhealthyReason(StrEnum): + """UnhealthyReason type. + + This is an incomplete list. Supervisor regularly adds new unhealthy + reasons as they are discovered. Therefore when returning a list of unhealthy + reasons, some may not be in this list parsed as strings on older versions of the + client. + """ + + DOCKER = "docker" + OSERROR_BAD_MESSAGE = "oserror_bad_message" + PRIVILEGED = "privileged" + SUPERVISOR = "supervisor" + SETUP = "setup" + UNTRUSTED = "untrusted" + + +class ContextType(StrEnum): + """ContextType type.""" + + ADDON = "addon" + CORE = "core" + DNS_SERVER = "dns_server" + MOUNT = "mount" + OS = "os" + PLUGIN = "plugin" + SUPERVISOR = "supervisor" + STORE = "store" + SYSTEM = "system" + + +class CheckType(StrEnum): + """CheckType type. + + This is an incomplete list. Supervisor regularly adds new checks as they are + discovered. Therefore when returning a list of checks, some may have a type that is + not in this list parsed as strings on older versions of the client. + """ + + ADDON_PWNED = "addon_pwned" + BACKUPS = "backups" + CORE_SECURITY = "core_security" + DETACHED_ADDON_MISSING = "detached_addon_missing" + DETACHED_ADDON_REMOVED = "detached_addon_removed" + DISABLED_DATA_DISK = "disabled_data_disk" + DNS_SERVER_IPV6 = "dns_server_ipv6" + DNS_SERVER = "dns_server" + DOCKER_CONFIG = "docker_config" + FREE_SPACE = "free_space" + MULTIPLE_DATA_DISKS = "multiple_data_disks" + NETWORK_INTERFACE_IPV4 = "network_interface_ipv4" + SUPERVISOR_TRUST = "supervisor_trust" + + +# --- OBJECTS ---- + + +@dataclass(frozen=True, slots=True) +class Suggestion(ResponseData): + """Suggestion model.""" + + type: SuggestionType | str + context: ContextType + reference: str | None + uuid: UUID + auto: bool + + +@dataclass(frozen=True, slots=True) +class Issue(ResponseData): + """Issue model.""" + + type: IssueType | str + context: ContextType + reference: str | None + uuid: UUID + + +@dataclass(frozen=True, slots=True) +class Check(ResponseData): + """Check model.""" + + enabled: bool + slug: CheckType | str + + +@dataclass(frozen=True, slots=True) +class SuggestionsList(ResponseData): + """SuggestionsList model.""" + + suggestions: list[Suggestion] + + +@dataclass(frozen=True, slots=True) +class ResolutionInfo(SuggestionsList, ResponseData): + """ResolutionInfo model.""" + + unsupported: list[UnsupportedReason | str] + unhealthy: list[UnhealthyReason | str] + issues: list[Issue] + checks: list[Check] + + +@dataclass(frozen=True, slots=True) +class CheckOptions(Options): + """CheckOptions model.""" + + enabled: bool | None = None diff --git a/aiohasupervisor/resolution.py b/aiohasupervisor/resolution.py new file mode 100644 index 0000000..572b1e2 --- /dev/null +++ b/aiohasupervisor/resolution.py @@ -0,0 +1,54 @@ +"""Resolution center client for supervisor.""" + +from uuid import UUID + +from .client import _SupervisorComponentClient +from .models.resolution import ( + CheckOptions, + CheckType, + ResolutionInfo, + Suggestion, + SuggestionsList, +) + + +class ResolutionClient(_SupervisorComponentClient): + """Handles resolution center access in supervisor.""" + + async def info(self) -> ResolutionInfo: + """Get resolution center info.""" + result = await self._client.get("resolution/info") + return ResolutionInfo.from_dict(result.data) + + async def check_options( + self, check: CheckType | str, options: CheckOptions + ) -> None: + """Set options for a check.""" + await self._client.post( + f"resolution/check/{check}/options", json=options.to_dict() + ) + + async def run_check(self, check: CheckType | str) -> None: + """Run a check.""" + await self._client.post(f"resolution/check/{check}/run") + + async def apply_suggestion(self, suggestion: UUID) -> None: + """Apply a suggestion.""" + await self._client.post(f"resolution/suggestion/{suggestion.hex}") + + async def dismiss_suggestion(self, suggestion: UUID) -> None: + """Dismiss a suggestion.""" + await self._client.delete(f"resolution/suggestion/{suggestion.hex}") + + async def dismiss_issue(self, issue: UUID) -> None: + """Dismiss an issue.""" + await self._client.delete(f"resolution/issue/{issue.hex}") + + async def suggestions_for_issue(self, issue: UUID) -> list[Suggestion]: + """Get suggestions for issue.""" + result = await self._client.get(f"resolution/issue/{issue.hex}/suggestions") + return SuggestionsList.from_dict(result.data).suggestions + + async def healthcheck(self) -> None: + """Run a healthcheck.""" + await self._client.post("resolution/healthcheck") diff --git a/aiohasupervisor/root.py b/aiohasupervisor/root.py index 5e147b9..2b9b187 100644 --- a/aiohasupervisor/root.py +++ b/aiohasupervisor/root.py @@ -7,6 +7,7 @@ from .addons import AddonsClient from .client import _SupervisorClient from .models.root import AvailableUpdate, AvailableUpdates, RootInfo +from .resolution import ResolutionClient from .store import StoreClient @@ -23,6 +24,7 @@ def __init__( """Initialize client.""" self._client = _SupervisorClient(api_host, token, request_timeout, session) self._addons = AddonsClient(self._client) + self._resolution = ResolutionClient(self._client) self._store = StoreClient(self._client) @property @@ -30,6 +32,11 @@ def addons(self) -> AddonsClient: """Get addons component client.""" return self._addons + @property + def resolution(self) -> ResolutionClient: + """Get resolution center component client.""" + return self._resolution + @property def store(self) -> StoreClient: """Get store component client.""" diff --git a/tests/fixtures/resolution_info.json b/tests/fixtures/resolution_info.json new file mode 100644 index 0000000..879d11f --- /dev/null +++ b/tests/fixtures/resolution_info.json @@ -0,0 +1,39 @@ +{ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": ["supervisor"], + "suggestions": [ + { + "type": "create_full_backup", + "context": "system", + "reference": null, + "uuid": "f87d3556f02c4004a47111c072c76fac", + "auto": false + } + ], + "issues": [ + { + "type": "no_current_backup", + "context": "system", + "reference": null, + "uuid": "7f0eac2b61c9456dab6970507a276c36" + } + ], + "checks": [ + { "enabled": true, "slug": "dns_server_ipv6" }, + { "enabled": true, "slug": "disabled_data_disk" }, + { "enabled": true, "slug": "detached_addon_missing" }, + { "enabled": true, "slug": "multiple_data_disks" }, + { "enabled": true, "slug": "backups" }, + { "enabled": true, "slug": "supervisor_trust" }, + { "enabled": true, "slug": "network_interface_ipv4" }, + { "enabled": true, "slug": "dns_server" }, + { "enabled": true, "slug": "free_space" }, + { "enabled": true, "slug": "detached_addon_removed" }, + { "enabled": true, "slug": "docker_config" }, + { "enabled": true, "slug": "addon_pwned" }, + { "enabled": true, "slug": "core_security" } + ] + } +} diff --git a/tests/fixtures/resolution_suggestions_for_issue.json b/tests/fixtures/resolution_suggestions_for_issue.json new file mode 100644 index 0000000..04a80c8 --- /dev/null +++ b/tests/fixtures/resolution_suggestions_for_issue.json @@ -0,0 +1,14 @@ +{ + "result": "ok", + "data": { + "suggestions": [ + { + "type": "create_full_backup", + "context": "system", + "reference": null, + "uuid": "f87d3556f02c4004a47111c072c76fac", + "auto": false + } + ] + } +} diff --git a/tests/test_resolution.py b/tests/test_resolution.py new file mode 100644 index 0000000..1ac72e7 --- /dev/null +++ b/tests/test_resolution.py @@ -0,0 +1,127 @@ +"""Test resolution center supervisor client.""" + +from uuid import uuid4 + +from aioresponses import aioresponses +from yarl import URL + +from aiohasupervisor import SupervisorClient +from aiohasupervisor.models import CheckOptions + +from . import load_fixture +from .const import SUPERVISOR_URL + + +async def test_resolution_info( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test resolution center info API.""" + responses.get( + f"{SUPERVISOR_URL}/resolution/info", + status=200, + body=load_fixture("resolution_info.json"), + ) + info = await supervisor_client.resolution.info() + assert info.checks[4].enabled is True + assert info.checks[4].slug == "backups" + assert info.issues[0].context == "system" + assert info.issues[0].type == "no_current_backup" + assert info.issues[0].reference is None + assert info.issues[0].uuid.hex == "7f0eac2b61c9456dab6970507a276c36" + assert info.suggestions[0].auto is False + assert info.suggestions[0].context == "system" + assert info.suggestions[0].type == "create_full_backup" + assert info.suggestions[0].reference is None + assert info.suggestions[0].uuid.hex == "f87d3556f02c4004a47111c072c76fac" + assert info.unhealthy == ["supervisor"] + assert info.unsupported == [] + + +async def test_resolution_check_options( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test resolution center check options API.""" + responses.post(f"{SUPERVISOR_URL}/resolution/check/backups/options", status=200) + assert ( + await supervisor_client.resolution.check_options( + "backups", CheckOptions(enabled=False) + ) + is None + ) + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/resolution/check/backups/options")) + } + + +async def test_resolution_run_check( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test resolution center run check API.""" + responses.post(f"{SUPERVISOR_URL}/resolution/check/backups/run", status=200) + assert await supervisor_client.resolution.run_check("backups") is None + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/resolution/check/backups/run")) + } + + +async def test_resolution_apply_suggestion( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test resolution center apply suggestion API.""" + uuid = uuid4() + responses.post(f"{SUPERVISOR_URL}/resolution/suggestion/{uuid.hex}", status=200) + assert await supervisor_client.resolution.apply_suggestion(uuid) is None + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/resolution/suggestion/{uuid.hex}")) + } + + +async def test_resolution_dismiss_suggestion( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test resolution center dismiss suggestion API.""" + uuid = uuid4() + responses.delete(f"{SUPERVISOR_URL}/resolution/suggestion/{uuid.hex}", status=200) + assert await supervisor_client.resolution.dismiss_suggestion(uuid) is None + assert responses.requests.keys() == { + ("DELETE", URL(f"{SUPERVISOR_URL}/resolution/suggestion/{uuid.hex}")) + } + + +async def test_resolution_dismiss_issue( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test resolution center dismiss issue API.""" + uuid = uuid4() + responses.delete(f"{SUPERVISOR_URL}/resolution/issue/{uuid.hex}", status=200) + assert await supervisor_client.resolution.dismiss_issue(uuid) is None + assert responses.requests.keys() == { + ("DELETE", URL(f"{SUPERVISOR_URL}/resolution/issue/{uuid.hex}")) + } + + +async def test_resolution_suggestions_for_issue( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test resolution center suggestions for issue API.""" + uuid = uuid4() + responses.get( + f"{SUPERVISOR_URL}/resolution/issue/{uuid.hex}/suggestions", + status=200, + body=load_fixture("resolution_suggestions_for_issue.json"), + ) + result = await supervisor_client.resolution.suggestions_for_issue(uuid) + assert result[0].auto is False + assert result[0].context == "system" + assert result[0].type == "create_full_backup" + + +async def test_resolution_healthcheck( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test resolution center healthcheck API.""" + responses.post(f"{SUPERVISOR_URL}/resolution/healthcheck", status=200) + assert await supervisor_client.resolution.healthcheck() is None + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/resolution/healthcheck")) + }