diff --git a/aiohasupervisor/backups.py b/aiohasupervisor/backups.py new file mode 100644 index 0000000..4e5e354 --- /dev/null +++ b/aiohasupervisor/backups.py @@ -0,0 +1,101 @@ +"""Backups client for supervisor.""" + +from .client import _SupervisorComponentClient +from .const import ResponseType +from .models.backups import ( + Backup, + BackupComplete, + BackupJob, + BackupList, + BackupsInfo, + BackupsOptions, + FreezeOptions, + FullBackupOptions, + FullRestoreOptions, + NewBackup, + PartialBackupOptions, + PartialRestoreOptions, +) + + +class BackupsClient(_SupervisorComponentClient): + """Handles backups access in Supervisor.""" + + async def list(self) -> list[Backup]: + """List backups.""" + result = await self._client.get("backups") + return BackupList.from_dict(result.data).backups + + async def info(self) -> BackupsInfo: + """Get backups info.""" + result = await self._client.get("backups/info") + return BackupsInfo.from_dict(result.data) + + async def options(self, options: BackupsOptions) -> None: + """Set options for backups.""" + await self._client.post("backups/options", json=options.to_dict()) + + async def reload(self) -> None: + """Reload backups cache.""" + await self._client.post("backups/reload") + + async def freeze(self, options: FreezeOptions | None = None) -> None: + """Start a freeze for external snapshot process.""" + await self._client.post( + "backups/freeze", json=options.to_dict() if options else None + ) + + async def thaw(self) -> None: + """Thaw an active freeze when external snapshot process ends.""" + await self._client.post("backups/thaw") + + async def full_backup(self, options: FullBackupOptions | None = None) -> NewBackup: + """Create a new full backup.""" + result = await self._client.post( + "backups/new/full", + json=options.to_dict() if options else None, + response_type=ResponseType.JSON, + ) + return NewBackup.from_dict(result.data) + + async def partial_backup(self, options: PartialBackupOptions) -> NewBackup: + """Create a new partial backup.""" + result = await self._client.post( + "backups/new/partial", + json=options.to_dict(), + response_type=ResponseType.JSON, + ) + return NewBackup.from_dict(result.data) + + async def backup_info(self, backup: str) -> BackupComplete: + """Get backup details.""" + result = await self._client.get(f"backups/{backup}/info") + return BackupComplete.from_dict(result.data) + + async def remove_backup(self, backup: str) -> None: + """Remove a backup.""" + await self._client.delete(f"backups/{backup}") + + async def full_restore( + self, backup: str, options: FullRestoreOptions | None = None + ) -> BackupJob: + """Start full restore from backup.""" + result = await self._client.post( + f"backups/{backup}/restore/full", + json=options.to_dict() if options else None, + response_type=ResponseType.JSON, + ) + return BackupJob.from_dict(result.data) + + async def partial_restore( + self, backup: str, options: PartialRestoreOptions + ) -> BackupJob: + """Start partial restore from backup.""" + result = await self._client.post( + f"backups/{backup}/restore/partial", + json=options.to_dict(), + response_type=ResponseType.JSON, + ) + return BackupJob.from_dict(result.data) + + # Omitted for now - Upload and download backup diff --git a/aiohasupervisor/models/__init__.py b/aiohasupervisor/models/__init__.py index 396a1b2..bf97016 100644 --- a/aiohasupervisor/models/__init__.py +++ b/aiohasupervisor/models/__init__.py @@ -23,6 +23,23 @@ StoreInfo, SupervisorRole, ) +from aiohasupervisor.models.backups import ( + Backup, + BackupAddon, + BackupComplete, + BackupContent, + BackupJob, + BackupsInfo, + BackupsOptions, + BackupType, + Folder, + FreezeOptions, + FullBackupOptions, + FullRestoreOptions, + NewBackup, + PartialBackupOptions, + PartialRestoreOptions, +) from aiohasupervisor.models.homeassistant import ( HomeAssistantInfo, HomeAssistantOptions, @@ -138,4 +155,19 @@ "GreenOptions", "YellowInfo", "YellowOptions", + "Backup", + "BackupAddon", + "BackupComplete", + "BackupContent", + "BackupJob", + "BackupsInfo", + "BackupsOptions", + "BackupType", + "Folder", + "FreezeOptions", + "FullBackupOptions", + "FullRestoreOptions", + "NewBackup", + "PartialBackupOptions", + "PartialRestoreOptions", ] diff --git a/aiohasupervisor/models/backups.py b/aiohasupervisor/models/backups.py new file mode 100644 index 0000000..51d5640 --- /dev/null +++ b/aiohasupervisor/models/backups.py @@ -0,0 +1,169 @@ +"""Models for Supervisor backups.""" + +from abc import ABC +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from .base import Request, ResponseData + +# --- ENUMS ---- + + +class BackupType(StrEnum): + """BackupType type.""" + + FULL = "full" + PARTIAL = "partial" + + +class Folder(StrEnum): + """Folder type.""" + + SHARE = "share" + ADDONS = "addons/local" + SSL = "ssl" + MEDIA = "media" + + +# --- OBJECTS ---- + + +@dataclass(frozen=True, slots=True) +class BackupContent(ResponseData): + """BackupContent model.""" + + homeassistant: bool + addons: list[str] + folders: list[str] + + +@dataclass(frozen=True) +class BackupBaseFields(ABC): + """BackupBaseFields ABC type.""" + + slug: str + name: str + date: datetime + type: BackupType + size: float + location: str | None + protected: bool + compressed: bool + + +@dataclass(frozen=True, slots=True) +class Backup(BackupBaseFields, ResponseData): + """Backup model.""" + + content: BackupContent + + +@dataclass(frozen=True, slots=True) +class BackupAddon(ResponseData): + """BackupAddon model.""" + + slug: str + name: str + version: str + size: float + + +@dataclass(frozen=True, slots=True) +class BackupComplete(BackupBaseFields, ResponseData): + """BackupComplete model.""" + + supervisor_version: str + homeassistant: str + addons: list[BackupAddon] + repositories: list[str] + folders: list[str] + homeassistant_exclude_database: bool | None + + +@dataclass(frozen=True, slots=True) +class BackupList(ResponseData): + """BackupList model.""" + + backups: list[Backup] + + +@dataclass(frozen=True, slots=True) +class BackupsInfo(BackupList): + """BackupsInfo model.""" + + days_until_stale: int + + +@dataclass(frozen=True, slots=True) +class BackupsOptions(Request): + """BackupsOptions model.""" + + days_until_stale: int + + +@dataclass(frozen=True, slots=True) +class FreezeOptions(Request): + """FreezeOptions model.""" + + timeout: int + + +@dataclass(frozen=True) +class PartialBackupRestoreOptions(ABC): # noqa: B024 + """PartialBackupRestoreOptions ABC type.""" + + addons: set[str] | None = None + folders: set[Folder] | None = None + homeassistant: bool | None = None + + def __post_init__(self) -> None: + """Validate at least one thing to backup/restore is included.""" + if not any((self.addons, self.folders, self.homeassistant)): + raise ValueError( + "At least one of addons, folders, or homeassistant must have a value" + ) + + +@dataclass(frozen=True, slots=True) +class FullBackupOptions(Request): + """FullBackupOptions model.""" + + name: str | None = None + password: str | None = None + compressed: bool | None = None + location: str | None = None + homeassistant_exclude_database: bool | None = None + background: bool | None = None + + +@dataclass(frozen=True, slots=True) +class PartialBackupOptions(FullBackupOptions, PartialBackupRestoreOptions): + """PartialBackupOptions model.""" + + +@dataclass(frozen=True, slots=True) +class BackupJob(ResponseData): + """BackupJob model.""" + + job_id: str + + +@dataclass(frozen=True, slots=True) +class NewBackup(BackupJob): + """NewBackup model.""" + + slug: str | None = None + + +@dataclass(frozen=True, slots=True) +class FullRestoreOptions(Request): + """FullRestoreOptions model.""" + + password: str | None = None + background: bool | None = None + + +@dataclass(frozen=True, slots=True) +class PartialRestoreOptions(FullRestoreOptions, PartialBackupRestoreOptions): + """PartialRestoreOptions model.""" diff --git a/aiohasupervisor/models/base.py b/aiohasupervisor/models/base.py index 00fc677..0cb0d22 100644 --- a/aiohasupervisor/models/base.py +++ b/aiohasupervisor/models/base.py @@ -58,7 +58,7 @@ class Options(ABC, DataClassDictMixin): def __post_init__(self) -> None: """Validate at least one field is present.""" if not self.to_dict(): - raise TypeError("At least one field must have a value") + raise ValueError("At least one field must have a value") class Config(RequestConfig): """Mashumaro config.""" diff --git a/aiohasupervisor/root.py b/aiohasupervisor/root.py index acf8d5b..c905212 100644 --- a/aiohasupervisor/root.py +++ b/aiohasupervisor/root.py @@ -5,6 +5,7 @@ from aiohttp import ClientSession from .addons import AddonsClient +from .backups import BackupsClient from .client import _SupervisorClient from .homeassistant import HomeAssistantClient from .models.root import AvailableUpdate, AvailableUpdates, RootInfo @@ -28,6 +29,7 @@ def __init__( self._client = _SupervisorClient(api_host, token, request_timeout, session) self._addons = AddonsClient(self._client) self._os = OSClient(self._client) + self._backups = BackupsClient(self._client) self._resolution = ResolutionClient(self._client) self._store = StoreClient(self._client) self._supervisor = SupervisorManagementClient(self._client) @@ -48,6 +50,11 @@ def os(self) -> OSClient: """Get OS component client.""" return self._os + @property + def backups(self) -> BackupsClient: + """Get backups component client.""" + return self._backups + @property def resolution(self) -> ResolutionClient: """Get resolution center component client.""" diff --git a/tests/fixtures/backup_background.json b/tests/fixtures/backup_background.json new file mode 100644 index 0000000..3d18934 --- /dev/null +++ b/tests/fixtures/backup_background.json @@ -0,0 +1,4 @@ +{ + "result": "ok", + "data": { "job_id": "dc9dbc16f6ad4de592ffa72c807ca2bf" } +} diff --git a/tests/fixtures/backup_foreground.json b/tests/fixtures/backup_foreground.json new file mode 100644 index 0000000..b417eb3 --- /dev/null +++ b/tests/fixtures/backup_foreground.json @@ -0,0 +1,4 @@ +{ + "result": "ok", + "data": { "job_id": "dc9dbc16f6ad4de592ffa72c807ca2bf", "slug": "9ecf0028" } +} diff --git a/tests/fixtures/backup_info.json b/tests/fixtures/backup_info.json new file mode 100644 index 0000000..7811e8e --- /dev/null +++ b/tests/fixtures/backup_info.json @@ -0,0 +1,32 @@ +{ + "result": "ok", + "data": { + "slug": "69558789", + "type": "partial", + "name": "addon_core_mosquitto_6.4.0", + "date": "2024-05-31T00:00:00.000000+00:00", + "size": 0.01, + "compressed": true, + "protected": false, + "supervisor_version": "2024.05.0", + "homeassistant": null, + "location": null, + "addons": [ + { + "slug": "core_mosquitto", + "name": "Mosquitto broker", + "version": "6.4.0", + "size": 0.0 + } + ], + "repositories": [ + "core", + "local", + "https://github.com/music-assistant/home-assistant-addon", + "https://github.com/esphome/home-assistant-addon", + "https://github.com/hassio-addons/repository" + ], + "folders": [], + "homeassistant_exclude_database": null + } +} diff --git a/tests/fixtures/backup_restore.json b/tests/fixtures/backup_restore.json new file mode 100644 index 0000000..3d18934 --- /dev/null +++ b/tests/fixtures/backup_restore.json @@ -0,0 +1,4 @@ +{ + "result": "ok", + "data": { "job_id": "dc9dbc16f6ad4de592ffa72c807ca2bf" } +} diff --git a/tests/fixtures/backups_info.json b/tests/fixtures/backups_info.json new file mode 100644 index 0000000..ee9bbba --- /dev/null +++ b/tests/fixtures/backups_info.json @@ -0,0 +1,47 @@ +{ + "result": "ok", + "data": { + "backups": [ + { + "slug": "58bc7491", + "name": "Full Backup 2024-04-06 00:05:39", + "date": "2024-04-06T07:05:40.000000+00:00", + "type": "full", + "size": 828.81, + "location": null, + "protected": false, + "compressed": true, + "content": { + "homeassistant": true, + "addons": [ + "core_matter_server", + "core_samba", + "core_ssh", + "a0d7b954_vscode", + "core_configurator", + "core_mosquitto", + "d5369777_music_assistant_beta", + "cebe7a76_hassio_google_drive_backup" + ], + "folders": ["share", "addons/local", "ssl", "media"] + } + }, + { + "slug": "69558789", + "name": "addon_core_mosquitto_6.4.0", + "date": "2024-05-31T20:48:03.838030+00:00", + "type": "partial", + "size": 0.01, + "location": null, + "protected": false, + "compressed": true, + "content": { + "homeassistant": false, + "addons": ["core_mosquitto"], + "folders": [] + } + } + ], + "days_until_stale": 30 + } +} diff --git a/tests/fixtures/backups_list.json b/tests/fixtures/backups_list.json new file mode 100644 index 0000000..4792494 --- /dev/null +++ b/tests/fixtures/backups_list.json @@ -0,0 +1,46 @@ +{ + "result": "ok", + "data": { + "backups": [ + { + "slug": "58bc7491", + "name": "Full Backup 2024-04-06 00:05:39", + "date": "2024-04-06T07:05:40.000000+00:00", + "type": "full", + "size": 828.81, + "location": null, + "protected": false, + "compressed": true, + "content": { + "homeassistant": true, + "addons": [ + "core_matter_server", + "core_samba", + "core_ssh", + "a0d7b954_vscode", + "core_configurator", + "core_mosquitto", + "d5369777_music_assistant_beta", + "cebe7a76_hassio_google_drive_backup" + ], + "folders": ["share", "addons/local", "ssl", "media"] + } + }, + { + "slug": "69558789", + "name": "addon_core_mosquitto_6.4.0", + "date": "2024-05-31T20:48:03.838030+00:00", + "type": "partial", + "size": 0.01, + "location": null, + "protected": false, + "compressed": true, + "content": { + "homeassistant": false, + "addons": ["core_mosquitto"], + "folders": [] + } + } + ] + } +} diff --git a/tests/test_backups.py b/tests/test_backups.py new file mode 100644 index 0000000..78bf93b --- /dev/null +++ b/tests/test_backups.py @@ -0,0 +1,252 @@ +"""Test backups supervisor client.""" + +from datetime import UTC, datetime +from typing import Any + +from aioresponses import CallbackResult, aioresponses +import pytest +from yarl import URL + +from aiohasupervisor import SupervisorClient +from aiohasupervisor.models import ( + BackupsOptions, + Folder, + FreezeOptions, + FullBackupOptions, + PartialBackupOptions, + PartialRestoreOptions, +) + +from . import load_fixture +from .const import SUPERVISOR_URL + + +async def test_backups_list( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups list API.""" + responses.get( + f"{SUPERVISOR_URL}/backups", status=200, body=load_fixture("backups_list.json") + ) + backups = await supervisor_client.backups.list() + assert backups[0].slug == "58bc7491" + assert backups[0].type == "full" + assert backups[0].date == datetime(2024, 4, 6, 7, 5, 40, 0, UTC) + assert backups[0].compressed is True + assert backups[0].content.homeassistant is True + assert backups[0].content.folders == ["share", "addons/local", "ssl", "media"] + assert backups[1].slug == "69558789" + assert backups[1].type == "partial" + + +async def test_backups_info( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups info API.""" + responses.get( + f"{SUPERVISOR_URL}/backups/info", + status=200, + body=load_fixture("backups_info.json"), + ) + info = await supervisor_client.backups.info() + assert info.backups[0].slug == "58bc7491" + assert info.backups[1].slug == "69558789" + assert info.days_until_stale == 30 + + +async def test_backups_options( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups options API.""" + responses.post(f"{SUPERVISOR_URL}/backups/options", status=200) + assert ( + await supervisor_client.backups.options(BackupsOptions(days_until_stale=10)) + is None + ) + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/backups/options")) + } + + +async def test_backups_reload( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups reload API.""" + responses.post(f"{SUPERVISOR_URL}/backups/reload", status=200) + assert await supervisor_client.backups.reload() is None + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/backups/reload")) + } + + +@pytest.mark.parametrize("options", [None, FreezeOptions(timeout=1000)]) +async def test_backups_freeze( + responses: aioresponses, + supervisor_client: SupervisorClient, + options: FreezeOptions | None, +) -> None: + """Test backups freeze API.""" + responses.post(f"{SUPERVISOR_URL}/backups/freeze", status=200) + assert await supervisor_client.backups.freeze(options) is None + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/backups/freeze")) + } + + +async def test_backups_thaw( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups thaw API.""" + responses.post(f"{SUPERVISOR_URL}/backups/thaw", status=200) + assert await supervisor_client.backups.thaw() is None + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/backups/thaw")) + } + + +async def test_partial_backup_options() -> None: + """Test partial backup options.""" + assert PartialBackupOptions(name="good", addons={"a"}) + assert PartialBackupOptions(name="good", folders={Folder.SSL}) + assert PartialBackupOptions(name="good", homeassistant=True) + with pytest.raises( + ValueError, + match="At least one of addons, folders, or homeassistant must have a value", + ): + PartialBackupOptions(name="bad") + + +async def test_partial_restore_options() -> None: + """Test partial restore options.""" + assert PartialRestoreOptions(addons={"a"}) + assert PartialRestoreOptions(folders={Folder.SSL}) + assert PartialRestoreOptions(homeassistant=True) + with pytest.raises( + ValueError, + match="At least one of addons, folders, or homeassistant must have a value", + ): + PartialRestoreOptions(background=True) + + +def backup_callback(url: str, **kwargs: dict[str, Any]) -> CallbackResult: # noqa: ARG001 + """Return response based on whether backup was in background or not.""" + if kwargs["json"] and kwargs["json"]["background"]: + fixture = "backup_background.json" + else: + fixture = "backup_foreground.json" + return CallbackResult(status=200, body=load_fixture(fixture)) + + +@pytest.mark.parametrize( + ("options", "slug"), + [ + (FullBackupOptions(name="Test", background=True), None), + (FullBackupOptions(name="Test", background=False), "9ecf0028"), + (None, "9ecf0028"), + ], +) +async def test_backups_full_backup( + responses: aioresponses, + supervisor_client: SupervisorClient, + options: FullBackupOptions | None, + slug: str | None, +) -> None: + """Test backups full backup API.""" + responses.post( + f"{SUPERVISOR_URL}/backups/new/full", + callback=backup_callback, + ) + result = await supervisor_client.backups.full_backup(options) + assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" + assert result.slug == slug + + +@pytest.mark.parametrize( + ("background", "slug"), + [(True, None), (False, "9ecf0028")], +) +async def test_backups_partial_backup( + responses: aioresponses, + supervisor_client: SupervisorClient, + background: bool, # noqa: FBT001 + slug: str | None, +) -> None: + """Test backups full backup API.""" + responses.post( + f"{SUPERVISOR_URL}/backups/new/partial", + callback=backup_callback, + ) + result = await supervisor_client.backups.partial_backup( + PartialBackupOptions(name="test", background=background, addons={"core_ssh"}) + ) + assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" + assert result.slug == slug + + +async def test_backup_info( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backup info API.""" + responses.get( + f"{SUPERVISOR_URL}/backups/69558789/info", + status=200, + body=load_fixture("backup_info.json"), + ) + result = await supervisor_client.backups.backup_info("69558789") + assert result.slug == "69558789" + assert result.type == "partial" + assert result.date == datetime(2024, 5, 31, 0, 0, 0, 0, UTC) + assert result.size == 0.01 + assert result.compressed is True + assert result.addons[0].slug == "core_mosquitto" + assert result.addons[0].name == "Mosquitto broker" + assert result.addons[0].version == "6.4.0" + assert result.addons[0].size == 0 + assert result.repositories == [ + "core", + "local", + "https://github.com/music-assistant/home-assistant-addon", + "https://github.com/esphome/home-assistant-addon", + "https://github.com/hassio-addons/repository", + ] + assert result.folders == [] + assert result.homeassistant_exclude_database is None + + +async def test_remove_backup( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test remove backup API.""" + responses.delete(f"{SUPERVISOR_URL}/backups/abc123", status=200) + assert await supervisor_client.backups.remove_backup("abc123") is None + assert responses.requests.keys() == { + ("DELETE", URL(f"{SUPERVISOR_URL}/backups/abc123")) + } + + +async def test_full_restore( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test full restore API.""" + responses.post( + f"{SUPERVISOR_URL}/backups/abc123/restore/full", + status=200, + body=load_fixture("backup_restore.json"), + ) + result = await supervisor_client.backups.full_restore("abc123") + assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" + + +async def test_partial_restore( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test partial restore API.""" + responses.post( + f"{SUPERVISOR_URL}/backups/abc123/restore/partial", + status=200, + body=load_fixture("backup_restore.json"), + ) + result = await supervisor_client.backups.partial_restore( + "abc123", PartialRestoreOptions(addons={"core_ssh"}) + ) + assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf"