Store update settings in hassio store (#142526)

This commit is contained in:
Erik Montnemery 2025-04-10 11:55:07 +02:00 committed by GitHub
parent 12ae70630f
commit d5476a1da1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 568 additions and 16 deletions

View File

@ -55,7 +55,6 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
from homeassistant.helpers.service_info.hassio import (
HassioServiceInfo as _HassioServiceInfo,
)
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.async_ import create_eager_task
@ -78,6 +77,7 @@ from . import ( # noqa: F401
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401
from .addon_panel import async_setup_addon_panel
from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
@ -91,6 +91,7 @@ from .const import (
ATTR_PASSWORD,
ATTR_SLUG,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DATA_CORE_INFO,
DATA_HOST_INFO,
DATA_INFO,
@ -144,8 +145,6 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant(
"2025.11",
)
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
# If new platforms are added, be sure to import them above
# so we do not make other components that depend on hassio
# wait for the import of the platforms
@ -335,13 +334,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
except SupervisorError:
_LOGGER.warning("Not connected with the supervisor / system too busy!")
store = Store[dict[str, str]](hass, STORAGE_VERSION, STORAGE_KEY)
if (data := await store.async_load()) is None:
data = {}
# Load the store
config_store = HassioConfig(hass)
await config_store.load()
hass.data[DATA_CONFIG_STORE] = config_store
refresh_token = None
if "hassio_user" in data:
user = await hass.auth.async_get_user(data["hassio_user"])
if (hassio_user := config_store.data.hassio_user) is not None:
user = await hass.auth.async_get_user(hassio_user)
if user and user.refresh_tokens:
refresh_token = list(user.refresh_tokens.values())[0]
@ -358,8 +358,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
)
refresh_token = await hass.auth.async_create_refresh_token(user)
data["hassio_user"] = user.id
await store.async_save(data)
config_store.update(hassio_user=user.id)
# This overrides the normal API call that would be forwarded
development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO)

View File

@ -57,7 +57,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT
from .handler import get_supervisor_client
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
@ -729,6 +729,18 @@ async def backup_addon_before_update(
if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon
}
def _delete_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return oldest backups more numerous than copies to delete."""
update_config = hass.data[DATA_CONFIG_STORE].data.update_config
return dict(
sorted(
backups.items(),
key=lambda backup_item: backup_item[1].date,
)[: max(len(backups) - update_config.add_on_backup_retain_copies, 0)]
)
try:
await backup_manager.async_create_backup(
agent_ids=[await _default_agent(client)],
@ -747,7 +759,7 @@ async def backup_addon_before_update(
try:
await backup_manager.async_delete_filtered_backups(
include_filter=addon_update_backup_filter,
delete_filter=lambda backups: backups,
delete_filter=_delete_filter,
)
except BackupManagerError as err:
raise HomeAssistantError(f"Error deleting old backups: {err}") from err

View File

@ -0,0 +1,148 @@
"""Provide persistent configuration for the hassio integration."""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Required, Self, TypedDict
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .const import DOMAIN
STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 1
class HassioConfig:
"""Handle update config."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize update config."""
self.data = HassioConfigData(
hassio_user=None,
update_config=HassioUpdateConfig(),
)
self._hass = hass
self._store = HassioConfigStore(hass, self)
async def load(self) -> None:
"""Load config."""
if not (store_data := await self._store.load()):
return
self.data = HassioConfigData.from_dict(store_data)
@callback
def update(
self,
*,
hassio_user: str | UndefinedType = UNDEFINED,
update_config: HassioUpdateParametersDict | UndefinedType = UNDEFINED,
) -> None:
"""Update config."""
if hassio_user is not UNDEFINED:
self.data.hassio_user = hassio_user
if update_config is not UNDEFINED:
self.data.update_config = replace(self.data.update_config, **update_config)
self._store.save()
@dataclass(kw_only=True)
class HassioConfigData:
"""Represent loaded update config data."""
hassio_user: str | None
update_config: HassioUpdateConfig
@classmethod
def from_dict(cls, data: StoredHassioConfig) -> Self:
"""Initialize update config data from a dict."""
if update_data := data.get("update_config"):
update_config = HassioUpdateConfig(
add_on_backup_before_update=update_data["add_on_backup_before_update"],
add_on_backup_retain_copies=update_data["add_on_backup_retain_copies"],
core_backup_before_update=update_data["core_backup_before_update"],
)
else:
update_config = HassioUpdateConfig()
return cls(
hassio_user=data["hassio_user"],
update_config=update_config,
)
def to_dict(self) -> StoredHassioConfig:
"""Convert update config data to a dict."""
return StoredHassioConfig(
hassio_user=self.hassio_user,
update_config=self.update_config.to_dict(),
)
@dataclass(kw_only=True)
class HassioUpdateConfig:
"""Represent the backup retention configuration."""
add_on_backup_before_update: bool = False
add_on_backup_retain_copies: int = 1
core_backup_before_update: bool = False
def to_dict(self) -> StoredHassioUpdateConfig:
"""Convert backup retention configuration to a dict."""
return StoredHassioUpdateConfig(
add_on_backup_before_update=self.add_on_backup_before_update,
add_on_backup_retain_copies=self.add_on_backup_retain_copies,
core_backup_before_update=self.core_backup_before_update,
)
class HassioUpdateParametersDict(TypedDict, total=False):
"""Represent the parameters for update."""
add_on_backup_before_update: bool
add_on_backup_retain_copies: int
core_backup_before_update: bool
class HassioConfigStore:
"""Store hassio config."""
def __init__(self, hass: HomeAssistant, config: HassioConfig) -> None:
"""Initialize the hassio config store."""
self._hass = hass
self._config = config
self._store: Store[StoredHassioConfig] = Store(
hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
)
async def load(self) -> StoredHassioConfig | None:
"""Load the store."""
return await self._store.async_load()
@callback
def save(self) -> None:
"""Save config."""
self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE)
@callback
def _data_to_save(self) -> StoredHassioConfig:
"""Return data to save."""
return self._config.data.to_dict()
class StoredHassioConfig(TypedDict, total=False):
"""Represent the stored hassio config."""
hassio_user: Required[str | None]
update_config: StoredHassioUpdateConfig
class StoredHassioUpdateConfig(TypedDict):
"""Represent the stored update config."""
add_on_backup_before_update: bool
add_on_backup_retain_copies: int
core_backup_before_update: bool

View File

@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from .config import HassioConfig
from .handler import HassIO
@ -74,6 +75,7 @@ ADDONS_COORDINATOR = "hassio_addons_coordinator"
DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN)
DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store")
DATA_CORE_INFO = "hassio_core_info"
DATA_CORE_STATS = "hassio_core_stats"
DATA_HOST_INFO = "hassio_host_info"

View File

@ -3,7 +3,7 @@
import logging
from numbers import Number
import re
from typing import Any
from typing import Any, cast
import voluptuous as vol
@ -19,6 +19,7 @@ from homeassistant.helpers.dispatcher import (
)
from . import HassioAPIError
from .config import HassioUpdateParametersDict
from .const import (
ATTR_DATA,
ATTR_ENDPOINT,
@ -29,6 +30,7 @@ from .const import (
ATTR_VERSION,
ATTR_WS_EVENT,
DATA_COMPONENT,
DATA_CONFIG_STORE,
EVENT_SUPERVISOR_EVENT,
WS_ID,
WS_TYPE,
@ -65,6 +67,8 @@ def async_load_websocket_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_subscribe)
websocket_api.async_register_command(hass, websocket_update_addon)
websocket_api.async_register_command(hass, websocket_update_core)
websocket_api.async_register_command(hass, websocket_update_config_info)
websocket_api.async_register_command(hass, websocket_update_config_update)
@callback
@ -185,3 +189,42 @@ async def websocket_update_core(
"""Websocket handler to update Home Assistant Core."""
await update_core(hass, None, msg["backup"])
connection.send_result(msg[WS_ID])
@callback
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "hassio/update/config/info"})
def websocket_update_config_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Send the stored backup config."""
connection.send_result(
msg["id"], hass.data[DATA_CONFIG_STORE].data.update_config.to_dict()
)
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "hassio/update/config/update",
vol.Optional("add_on_backup_before_update"): bool,
vol.Optional("add_on_backup_retain_copies"): vol.All(int, vol.Range(min=1)),
vol.Optional("core_backup_before_update"): bool,
}
)
def websocket_update_config_update(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update the stored backup config."""
changes = dict(msg)
changes.pop("id")
changes.pop("type")
hass.data[DATA_CONFIG_STORE].update(
update_config=cast(HassioUpdateParametersDict, changes)
)
connection.send_result(msg["id"])

View File

@ -0,0 +1,46 @@
# serializer version: 1
# name: test_load_config_store[storage_data0]
dict({
'hassio_user': '766572795f764572b95f72616e646f6d',
'update_config': dict({
'add_on_backup_before_update': False,
'add_on_backup_retain_copies': 1,
'core_backup_before_update': False,
}),
})
# ---
# name: test_load_config_store[storage_data1]
dict({
'hassio_user': '00112233445566778899aabbccddeeff',
'update_config': dict({
'add_on_backup_before_update': False,
'add_on_backup_retain_copies': 1,
'core_backup_before_update': False,
}),
})
# ---
# name: test_load_config_store[storage_data2]
dict({
'hassio_user': '00112233445566778899aabbccddeeff',
'update_config': dict({
'add_on_backup_before_update': True,
'add_on_backup_retain_copies': 2,
'core_backup_before_update': True,
}),
})
# ---
# name: test_save_config_store
dict({
'data': dict({
'hassio_user': '766572795f764572b95f72616e646f6d',
'update_config': dict({
'add_on_backup_before_update': False,
'add_on_backup_retain_copies': 1,
'core_backup_before_update': False,
}),
}),
'key': 'hassio',
'minor_version': 1,
'version': 1,
})
# ---

View File

@ -0,0 +1,33 @@
# serializer version: 1
# name: test_read_update_config
dict({
'id': 1,
'result': dict({
'add_on_backup_before_update': False,
'add_on_backup_retain_copies': 1,
'core_backup_before_update': False,
}),
'success': True,
'type': 'result',
})
# ---
# name: test_read_update_config.1
dict({
'id': 2,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_read_update_config.2
dict({
'id': 3,
'result': dict({
'add_on_backup_before_update': True,
'add_on_backup_retain_copies': 2,
'core_backup_before_update': True,
}),
'success': True,
'type': 'result',
})
# ---

View File

@ -0,0 +1,182 @@
"""Test websocket API."""
from typing import Any
from unittest.mock import AsyncMock, patch
from uuid import UUID
import pytest
from syrupy import SnapshotAssertion
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components.hassio.const import DATA_CONFIG_STORE, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockUser
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import WebSocketGenerator
MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"}
@pytest.fixture(autouse=True)
def mock_all(
aioclient_mock: AiohttpClientMocker,
supervisor_is_connected: AsyncMock,
resolution_info: AsyncMock,
addon_info: AsyncMock,
) -> None:
"""Mock all setup requests."""
aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"})
aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"})
aioclient_mock.get(
"http://127.0.0.1/info",
json={
"result": "ok",
"data": {"supervisor": "222", "homeassistant": "0.110.0", "hassos": None},
},
)
aioclient_mock.get(
"http://127.0.0.1/host/info",
json={
"result": "ok",
"data": {
"result": "ok",
"data": {
"chassis": "vm",
"operating_system": "Debian GNU/Linux 10 (buster)",
"kernel": "4.19.0-6-amd64",
},
},
},
)
aioclient_mock.get(
"http://127.0.0.1/core/info",
json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}},
)
aioclient_mock.get(
"http://127.0.0.1/os/info",
json={"result": "ok", "data": {"version_latest": "1.0.0"}},
)
aioclient_mock.get(
"http://127.0.0.1/supervisor/info",
json={
"result": "ok",
"data": {
"version": "1.0.0",
"version_latest": "1.0.0",
"auto_update": True,
"addons": [
{
"name": "test",
"state": "started",
"slug": "test",
"installed": True,
"update_available": True,
"icon": False,
"version": "2.0.0",
"version_latest": "2.0.1",
"repository": "core",
"url": "https://github.com/home-assistant/addons/test",
},
],
},
},
)
aioclient_mock.get(
"http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}}
)
aioclient_mock.get(
"http://127.0.0.1/network/info",
json={
"result": "ok",
"data": {
"host_internet": True,
"supervisor_internet": True,
},
},
)
@pytest.mark.usefixtures("hassio_env")
@pytest.mark.parametrize(
"storage_data",
[
{},
{
"hassio": {
"data": {
"hassio_user": "00112233445566778899aabbccddeeff",
"update_config": {
"add_on_backup_before_update": False,
"add_on_backup_retain_copies": 1,
"core_backup_before_update": False,
},
},
"key": "hassio",
"minor_version": 1,
"version": 1,
}
},
{
"hassio": {
"data": {
"hassio_user": "00112233445566778899aabbccddeeff",
"update_config": {
"add_on_backup_before_update": True,
"add_on_backup_retain_copies": 2,
"core_backup_before_update": True,
},
},
"key": "hassio",
"minor_version": 1,
"version": 1,
}
},
],
)
async def test_load_config_store(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
hass_storage: dict[str, Any],
storage_data: dict[str, dict[str, Any]],
snapshot: SnapshotAssertion,
) -> None:
"""Test loading the config store."""
hass_storage.update(storage_data)
user = MockUser(id="00112233445566778899aabbccddeeff", system_generated=True)
user.add_to_hass(hass)
await hass.auth.async_create_refresh_token(user)
await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN])
with (
patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0),
patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)),
):
assert await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
await hass.async_block_till_done()
assert hass.data[DATA_CONFIG_STORE].data.to_dict() == snapshot
@pytest.mark.usefixtures("hassio_env")
async def test_save_config_store(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
hass_storage: dict[str, Any],
snapshot: SnapshotAssertion,
) -> None:
"""Test saving the config store."""
with (
patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0),
patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)),
):
assert await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
await hass.async_block_till_done()
assert hass_storage[DOMAIN] == snapshot

View File

@ -17,12 +17,12 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI
from homeassistant.components.hassio import (
ADDONS_COORDINATOR,
DOMAIN,
STORAGE_KEY,
get_core_info,
get_supervisor_ip,
hostname_from_addon_slug,
is_hassio as deprecated_is_hassio,
)
from homeassistant.components.hassio.config import STORAGE_KEY
from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant
@ -309,7 +309,10 @@ async def test_setup_api_push_api_data_default(
supervisor_client: AsyncMock,
) -> None:
"""Test setup with API push default data."""
with patch.dict(os.environ, MOCK_ENVIRON):
with (
patch.dict(os.environ, MOCK_ENVIRON),
patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0),
):
result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}})
await hass.async_block_till_done()

View File

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.backup import BackupManagerError, ManagerBackup
@ -469,13 +470,15 @@ async def test_update_addon_with_backup(
@pytest.mark.parametrize(
("backups", "removed_backups"),
("ws_commands", "backups", "removed_backups"),
[
(
[],
{},
[],
),
(
[],
{
"backup-1": MagicMock(
agents={"hassio.local": MagicMock(spec=AgentBackupStatus)},
@ -520,6 +523,52 @@ async def test_update_addon_with_backup(
},
["backup-5"],
),
(
[{"type": "hassio/update/config/update", "add_on_backup_retain_copies": 2}],
{
"backup-1": MagicMock(
agents={"hassio.local": MagicMock(spec=AgentBackupStatus)},
date="2024-11-10T04:45:00+01:00",
with_automatic_settings=True,
spec=ManagerBackup,
),
"backup-2": MagicMock(
agents={"hassio.local": MagicMock(spec=AgentBackupStatus)},
date="2024-11-11T04:45:00+01:00",
with_automatic_settings=False,
spec=ManagerBackup,
),
"backup-3": MagicMock(
agents={"hassio.local": MagicMock(spec=AgentBackupStatus)},
date="2024-11-11T04:45:00+01:00",
extra_metadata={"supervisor.addon_update": "other"},
with_automatic_settings=True,
spec=ManagerBackup,
),
"backup-4": MagicMock(
agents={"hassio.local": MagicMock(spec=AgentBackupStatus)},
date="2024-11-11T04:45:00+01:00",
extra_metadata={"supervisor.addon_update": "other"},
with_automatic_settings=True,
spec=ManagerBackup,
),
"backup-5": MagicMock(
agents={"hassio.local": MagicMock(spec=AgentBackupStatus)},
date="2024-11-11T04:45:00+01:00",
extra_metadata={"supervisor.addon_update": "test"},
with_automatic_settings=True,
spec=ManagerBackup,
),
"backup-6": MagicMock(
agents={"hassio.local": MagicMock(spec=AgentBackupStatus)},
date="2024-11-12T04:45:00+01:00",
extra_metadata={"supervisor.addon_update": "test"},
with_automatic_settings=True,
spec=ManagerBackup,
),
},
[],
),
],
)
async def test_update_addon_with_backup_removes_old_backups(
@ -527,6 +576,7 @@ async def test_update_addon_with_backup_removes_old_backups(
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
update_addon: AsyncMock,
ws_commands: list[dict[str, Any]],
backups: dict[str, ManagerBackup],
removed_backups: list[str],
) -> None:
@ -544,6 +594,12 @@ async def test_update_addon_with_backup_removes_old_backups(
await setup_backup_integration(hass)
client = await hass_ws_client(hass)
for command in ws_commands:
await client.send_json_auto_id(command)
result = await client.receive_json()
assert result["success"]
supervisor_client.mounts.info.return_value.default_backup_mount = None
with (
patch(
@ -856,3 +912,31 @@ async def test_update_core_with_backup_and_error(
"code": "home_assistant_error",
"message": "Error creating backup: ",
}
@pytest.mark.usefixtures("hassio_env")
async def test_read_update_config(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test read and update config."""
assert await async_setup_component(hass, "hassio", {})
websocket_client = await hass_ws_client(hass)
await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"})
assert await websocket_client.receive_json() == snapshot
await websocket_client.send_json_auto_id(
{
"type": "hassio/update/config/update",
"add_on_backup_before_update": True,
"add_on_backup_retain_copies": 2,
"core_backup_before_update": True,
}
)
assert await websocket_client.receive_json() == snapshot
await websocket_client.send_json_auto_id({"type": "hassio/update/config/info"})
assert await websocket_client.receive_json() == snapshot