mirror of
https://github.com/home-assistant/core.git
synced 2026-07-01 02:55:57 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69e281658d | |||
| b5980491a8 | |||
| 58c4423345 | |||
| e5f7ac0cf0 |
@@ -51,16 +51,20 @@ from . import ( # noqa: F401
|
||||
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState
|
||||
from .addon_panel import async_setup_addon_panel
|
||||
from .auth import async_setup_auth_view
|
||||
from .config import HassioConfig
|
||||
from .config import HassioConfigStore, StoredHassioConfig
|
||||
from .config_entry import async_get_hassio_entry
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
DATA_COMPONENT,
|
||||
DATA_CONFIG_STORE,
|
||||
DATA_HASSIO_HOST,
|
||||
DATA_HASSIO_SUPERVISOR_USER,
|
||||
DATA_KEY_SUPERVISOR_ISSUES,
|
||||
DOMAIN,
|
||||
ENTRY_DATA_USER,
|
||||
MAIN_COORDINATOR,
|
||||
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE,
|
||||
OPTION_ADD_ON_BACKUP_RETAIN_COPIES,
|
||||
OPTION_CORE_BACKUP_BEFORE_UPDATE,
|
||||
STATS_COORDINATOR,
|
||||
)
|
||||
from .coordinator import (
|
||||
@@ -163,6 +167,69 @@ def hostname_from_addon_slug(addon_slug: str) -> str:
|
||||
return addon_slug.replace("_", "-")
|
||||
|
||||
|
||||
async def _async_get_or_create_supervisor_user(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry | None,
|
||||
legacy_user_id: str | None = None,
|
||||
) -> User:
|
||||
"""Get or create the Supervisor system user."""
|
||||
user: User | None = None
|
||||
|
||||
if entry is not None and (entry_user_id := entry.data.get(ENTRY_DATA_USER)):
|
||||
user = await hass.auth.async_get_user(entry_user_id)
|
||||
|
||||
if user is None and legacy_user_id is not None:
|
||||
user = await hass.auth.async_get_user(legacy_user_id)
|
||||
|
||||
if user is None:
|
||||
user = await hass.auth.async_create_system_user(
|
||||
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
|
||||
)
|
||||
if entry is not None:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, ENTRY_DATA_USER: user.id},
|
||||
)
|
||||
|
||||
# Migrate old Hass.io users to be admin.
|
||||
if not user.is_admin:
|
||||
await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN])
|
||||
|
||||
# Migrate old name
|
||||
if user.name == "Hass.io":
|
||||
await hass.auth.async_update_user(user, name=HASSIO_USER_NAME)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@callback
|
||||
def _async_migrate_legacy_options(
|
||||
entry: ConfigEntry, legacy_data: StoredHassioConfig
|
||||
) -> dict[str, bool | int]:
|
||||
"""Merge legacy update options into entry options without overriding existing values."""
|
||||
if not (legacy_update_config := legacy_data.get("update_config")):
|
||||
return {}
|
||||
|
||||
option_updates: dict[str, bool | int] = {}
|
||||
|
||||
if OPTION_ADD_ON_BACKUP_BEFORE_UPDATE not in entry.options:
|
||||
option_updates[OPTION_ADD_ON_BACKUP_BEFORE_UPDATE] = legacy_update_config[
|
||||
"add_on_backup_before_update"
|
||||
]
|
||||
|
||||
if OPTION_ADD_ON_BACKUP_RETAIN_COPIES not in entry.options:
|
||||
option_updates[OPTION_ADD_ON_BACKUP_RETAIN_COPIES] = legacy_update_config[
|
||||
"add_on_backup_retain_copies"
|
||||
]
|
||||
|
||||
if OPTION_CORE_BACKUP_BEFORE_UPDATE not in entry.options:
|
||||
option_updates[OPTION_CORE_BACKUP_BEFORE_UPDATE] = legacy_update_config[
|
||||
"core_backup_before_update"
|
||||
]
|
||||
|
||||
return option_updates
|
||||
|
||||
|
||||
@callback
|
||||
def _check_deprecated_setup(hass: HomeAssistant) -> None:
|
||||
"""Create issues for deprecated installation types and architectures."""
|
||||
@@ -234,32 +301,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
|
||||
hass.data[DATA_HASSIO_HOST] = host
|
||||
|
||||
# Load the store
|
||||
config_store = HassioConfig(hass)
|
||||
await config_store.load()
|
||||
hass.data[DATA_CONFIG_STORE] = config_store
|
||||
legacy_store = HassioConfigStore(hass)
|
||||
legacy_data = await legacy_store.async_load()
|
||||
|
||||
# Cache the Supervisor user. Create one if necessary
|
||||
user: User | None = None
|
||||
if (hassio_user := config_store.data.hassio_user) is not None:
|
||||
user = await hass.auth.async_get_user(hassio_user)
|
||||
if user:
|
||||
# Migrate old Hass.io users to be admin.
|
||||
if not user.is_admin:
|
||||
await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN])
|
||||
entry = async_get_hassio_entry(hass)
|
||||
|
||||
# Migrate old name
|
||||
if user.name == "Hass.io":
|
||||
await hass.auth.async_update_user(user, name=HASSIO_USER_NAME)
|
||||
legacy_user_id: str | None = None
|
||||
if legacy_data is not None:
|
||||
legacy_user_id = legacy_data.get("hassio_user")
|
||||
|
||||
if user is None:
|
||||
user = await hass.auth.async_create_system_user(
|
||||
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
|
||||
)
|
||||
config_store.update(hassio_user=user.id)
|
||||
|
||||
assert user is not None
|
||||
hass.data[DATA_HASSIO_SUPERVISOR_USER] = user
|
||||
hass.data[DATA_HASSIO_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user(
|
||||
hass, entry, legacy_user_id
|
||||
)
|
||||
|
||||
async_load_websocket_api(hass)
|
||||
hass.http.register_view(HassIOView(host, websession))
|
||||
@@ -270,14 +323,40 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async_setup_addon_panel(hass)
|
||||
frontend.async_register_built_in_panel(hass, "app")
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
if entry is None:
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
# Deprecated in 2026.8: remove this legacy store migration path after the
|
||||
# deprecation window for .storage/hassio has elapsed.
|
||||
legacy_store = HassioConfigStore(hass)
|
||||
if (legacy_data := await legacy_store.async_load()) is not None:
|
||||
option_updates = _async_migrate_legacy_options(entry, legacy_data)
|
||||
|
||||
if option_updates:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
options={**entry.options, **option_updates},
|
||||
)
|
||||
|
||||
await legacy_store.async_remove()
|
||||
|
||||
if (user := hass.data.get(DATA_HASSIO_SUPERVISOR_USER)) is not None:
|
||||
if entry.data.get(ENTRY_DATA_USER) != user.id:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, ENTRY_DATA_USER: user.id},
|
||||
)
|
||||
else:
|
||||
user = await _async_get_or_create_supervisor_user(hass, entry)
|
||||
|
||||
hass.data[DATA_HASSIO_SUPERVISOR_USER] = user
|
||||
|
||||
supervisor_client = get_supervisor_client(hass)
|
||||
|
||||
try:
|
||||
@@ -311,7 +390,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
# Get or create a refresh token for the Supervisor user
|
||||
user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
|
||||
if user.refresh_tokens:
|
||||
refresh_token = list(user.refresh_tokens.values())[0]
|
||||
else:
|
||||
|
||||
@@ -58,7 +58,8 @@ 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 DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT
|
||||
from .config_entry import async_get_update_options
|
||||
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT, OPTION_ADD_ON_BACKUP_RETAIN_COPIES
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
|
||||
@@ -824,12 +825,14 @@ async def backup_addon_before_update(
|
||||
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
|
||||
retain_copies = async_get_update_options(hass)[
|
||||
OPTION_ADD_ON_BACKUP_RETAIN_COPIES
|
||||
]
|
||||
return dict(
|
||||
sorted(
|
||||
backups.items(),
|
||||
key=lambda backup_item: backup_item[1].date,
|
||||
)[: max(len(backups) - update_config.add_on_backup_retain_copies, 0)]
|
||||
)[: max(len(backups) - retain_copies, 0)]
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,146 +1,51 @@
|
||||
"""Provide persistent configuration for the hassio integration."""
|
||||
"""Legacy hassio storage helpers for migration.
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Required, Self, TypedDict
|
||||
Deprecated in 2026.8; keep only for one-way migration into config entries.
|
||||
"""
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from typing import Required, TypedDict
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
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."""
|
||||
class StoredHassioUpdateConfig(TypedDict):
|
||||
"""Represent legacy stored update configuration."""
|
||||
|
||||
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."""
|
||||
"""Represent the legacy hassio store payload."""
|
||||
|
||||
hassio_user: Required[str | None]
|
||||
update_config: StoredHassioUpdateConfig
|
||||
|
||||
|
||||
class StoredHassioUpdateConfig(TypedDict):
|
||||
"""Represent the stored update config."""
|
||||
class HassioConfigStore:
|
||||
"""Load/remove the legacy hassio store (deprecated in 2026.8)."""
|
||||
|
||||
add_on_backup_before_update: bool
|
||||
add_on_backup_retain_copies: int
|
||||
core_backup_before_update: bool
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the legacy hassio config store."""
|
||||
self._store: Store[StoredHassioConfig] = Store(
|
||||
hass,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_KEY,
|
||||
minor_version=STORAGE_VERSION_MINOR,
|
||||
)
|
||||
|
||||
async def async_load(self) -> StoredHassioConfig | None:
|
||||
"""Load legacy hassio storage data."""
|
||||
return await self._store.async_load()
|
||||
|
||||
async def async_remove(self) -> None:
|
||||
"""Remove the legacy hassio storage file."""
|
||||
await self._store.async_remove()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Helpers for hassio config entry access."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
DEFAULT_UPDATE_OPTIONS,
|
||||
DOMAIN,
|
||||
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE,
|
||||
OPTION_ADD_ON_BACKUP_RETAIN_COPIES,
|
||||
OPTION_CORE_BACKUP_BEFORE_UPDATE,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_hassio_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
||||
"""Return the active hassio config entry if it exists."""
|
||||
entries = hass.config_entries.async_entries(
|
||||
DOMAIN, include_ignore=False, include_disabled=False
|
||||
)
|
||||
return entries[0] if entries else None
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_update_options(
|
||||
hass: HomeAssistant, entry: ConfigEntry | None = None
|
||||
) -> dict[str, bool | int]:
|
||||
"""Return hassio update options with defaults applied."""
|
||||
if entry is None:
|
||||
entry = async_get_hassio_entry(hass)
|
||||
|
||||
if entry is None:
|
||||
return dict(DEFAULT_UPDATE_OPTIONS)
|
||||
|
||||
return {
|
||||
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE: entry.options.get(
|
||||
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE,
|
||||
DEFAULT_UPDATE_OPTIONS[OPTION_ADD_ON_BACKUP_BEFORE_UPDATE],
|
||||
),
|
||||
OPTION_ADD_ON_BACKUP_RETAIN_COPIES: entry.options.get(
|
||||
OPTION_ADD_ON_BACKUP_RETAIN_COPIES,
|
||||
DEFAULT_UPDATE_OPTIONS[OPTION_ADD_ON_BACKUP_RETAIN_COPIES],
|
||||
),
|
||||
OPTION_CORE_BACKUP_BEFORE_UPDATE: entry.options.get(
|
||||
OPTION_CORE_BACKUP_BEFORE_UPDATE,
|
||||
DEFAULT_UPDATE_OPTIONS[OPTION_CORE_BACKUP_BEFORE_UPDATE],
|
||||
),
|
||||
}
|
||||
@@ -3,8 +3,14 @@
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import HASSIO_USER_NAME
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
DATA_HASSIO_SUPERVISOR_USER,
|
||||
DEFAULT_UPDATE_OPTIONS,
|
||||
DOMAIN,
|
||||
ENTRY_DATA_USER,
|
||||
)
|
||||
|
||||
|
||||
class HassIoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -16,4 +22,12 @@ class HassIoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Supervisor", data={})
|
||||
data: dict[str, Any] = {}
|
||||
if (user := self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER)) is not None:
|
||||
data[ENTRY_DATA_USER] = user.id
|
||||
|
||||
return self.async_create_entry(
|
||||
title=HASSIO_USER_NAME,
|
||||
data=data,
|
||||
options=DEFAULT_UPDATE_OPTIONS,
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ if TYPE_CHECKING:
|
||||
|
||||
from homeassistant.auth.models import User
|
||||
|
||||
from .config import HassioConfig
|
||||
from .coordinator import (
|
||||
HassioAddOnDataUpdateCoordinator,
|
||||
HassioMainDataUpdateCoordinator,
|
||||
@@ -106,7 +105,6 @@ STATS_COORDINATOR: HassKey[HassioStatsDataUpdateCoordinator] = HassKey(
|
||||
|
||||
|
||||
DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN)
|
||||
DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store")
|
||||
DATA_CORE_INFO: HassKey[HomeAssistantInfo] = HassKey("hassio_core_info")
|
||||
DATA_CORE_STATS = "hassio_core_stats"
|
||||
DATA_HOST_INFO: HassKey[HostInfo] = HassKey("hassio_host_info")
|
||||
@@ -148,6 +146,18 @@ DATA_KEY_MOUNTS = "mounts"
|
||||
DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host")
|
||||
DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user")
|
||||
|
||||
ENTRY_DATA_USER = "user"
|
||||
|
||||
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE = "add_on_backup_before_update"
|
||||
OPTION_ADD_ON_BACKUP_RETAIN_COPIES = "add_on_backup_retain_copies"
|
||||
OPTION_CORE_BACKUP_BEFORE_UPDATE = "core_backup_before_update"
|
||||
|
||||
DEFAULT_UPDATE_OPTIONS = {
|
||||
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE: False,
|
||||
OPTION_ADD_ON_BACKUP_RETAIN_COPIES: 1,
|
||||
OPTION_CORE_BACKUP_BEFORE_UPDATE: False,
|
||||
}
|
||||
|
||||
PLACEHOLDER_KEY_ADDON = "addon"
|
||||
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
|
||||
PLACEHOLDER_KEY_ADDON_DOCUMENTATION = "addon_documentation"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from numbers import Number
|
||||
import re
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
|
||||
from .config import HassioUpdateParametersDict
|
||||
from .config_entry import async_get_hassio_entry, async_get_update_options
|
||||
from .const import (
|
||||
ATTR_DATA,
|
||||
ATTR_ENDPOINT,
|
||||
@@ -30,7 +30,6 @@ from .const import (
|
||||
ATTR_VERSION,
|
||||
ATTR_WS_EVENT,
|
||||
DATA_COMPONENT,
|
||||
DATA_CONFIG_STORE,
|
||||
EVENT_SUPERVISOR_EVENT,
|
||||
WS_ID,
|
||||
WS_TYPE,
|
||||
@@ -225,9 +224,7 @@ def websocket_update_config_info(
|
||||
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()
|
||||
)
|
||||
connection.send_result(msg["id"], async_get_update_options(hass))
|
||||
|
||||
|
||||
@callback
|
||||
@@ -246,10 +243,23 @@ def websocket_update_config_update(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update the stored backup config."""
|
||||
entry = async_get_hassio_entry(hass)
|
||||
if entry is None:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
code=websocket_api.ERR_UNKNOWN_ERROR,
|
||||
message="Hassio config entry is not available",
|
||||
)
|
||||
return
|
||||
|
||||
changes = dict(msg)
|
||||
changes.pop("id")
|
||||
changes.pop("type")
|
||||
hass.data[DATA_CONFIG_STORE].update(
|
||||
update_config=cast(HassioUpdateParametersDict, changes)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
options={
|
||||
**async_get_update_options(hass, entry),
|
||||
**changes,
|
||||
},
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -10,7 +10,7 @@ from aiohasupervisor.models import AddonsStats, AddonState, InstalledAddonComple
|
||||
from aiohttp.test_utils import TestClient
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hassio.const import DATA_CONFIG_STORE
|
||||
from homeassistant.components.hassio.const import DATA_HASSIO_SUPERVISOR_USER
|
||||
from homeassistant.components.hassio.handler import HassIO
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -53,9 +53,7 @@ async def hassio_client_supervisor(
|
||||
hassio_stubs: None,
|
||||
) -> TestClient:
|
||||
"""Return an authenticated HTTP client."""
|
||||
hassio_user_id = hass.data[DATA_CONFIG_STORE].data.hassio_user
|
||||
hassio_user = await hass.auth.async_get_user(hassio_user_id)
|
||||
assert hassio_user
|
||||
hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
|
||||
assert hassio_user.refresh_tokens
|
||||
refresh_token = next(iter(hassio_user.refresh_tokens.values()))
|
||||
access_token = hass.auth.async_create_access_token(refresh_token)
|
||||
@@ -73,9 +71,7 @@ def hass_supervisor_ws_client(
|
||||
"""Return a websocket client authenticated as the Supervisor user."""
|
||||
|
||||
async def create_client() -> WebSocketGenerator:
|
||||
hassio_user_id = hass.data[DATA_CONFIG_STORE].data.hassio_user
|
||||
hassio_user = await hass.auth.async_get_user(hassio_user_id)
|
||||
assert hassio_user
|
||||
hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
|
||||
assert hassio_user.refresh_tokens
|
||||
refresh_token = next(iter(hassio_user.refresh_tokens.values()))
|
||||
access_token = hass.auth.async_create_access_token(refresh_token)
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Test websocket API."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.components.hassio import HASSIO_USER_NAME
|
||||
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.typing import WebSocketGenerator
|
||||
|
||||
MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_all(
|
||||
supervisor_is_connected: AsyncMock,
|
||||
resolution_info: AsyncMock,
|
||||
addon_info: AsyncMock,
|
||||
host_info: AsyncMock,
|
||||
supervisor_root_info: AsyncMock,
|
||||
homeassistant_info: AsyncMock,
|
||||
supervisor_info: AsyncMock,
|
||||
addons_list: AsyncMock,
|
||||
network_info: AsyncMock,
|
||||
os_info: AsyncMock,
|
||||
ingress_panels: AsyncMock,
|
||||
) -> None:
|
||||
"""Mock all setup requests."""
|
||||
supervisor_root_info.return_value = replace(
|
||||
supervisor_root_info.return_value, hassos=None
|
||||
)
|
||||
addons_list.return_value.pop(1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_hassio_user_id() -> Generator[None]:
|
||||
"""Mock the HASSIO user ID for snapshot testing."""
|
||||
original_user_init = User.__init__
|
||||
|
||||
def mock_user_init(self, *args, **kwargs):
|
||||
with patch("homeassistant.auth.models.uuid.uuid4") as mock_uuid:
|
||||
if kwargs.get("name") == HASSIO_USER_NAME:
|
||||
mock_uuid.return_value = UUID(bytes=b"very_very_random", version=4)
|
||||
else:
|
||||
mock_uuid.return_value = uuid4()
|
||||
original_user_init(self, *args, **kwargs)
|
||||
|
||||
with patch.object(User, "__init__", mock_user_init):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_env", "mock_hassio_user_id")
|
||||
@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):
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
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", "mock_hassio_user_id")
|
||||
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):
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass_storage[DOMAIN] == snapshot
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.components.hassio import DOMAIN
|
||||
from homeassistant.components.hassio.const import (
|
||||
DATA_HASSIO_SUPERVISOR_USER,
|
||||
DEFAULT_UPDATE_OPTIONS,
|
||||
ENTRY_DATA_USER,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
@@ -25,6 +31,7 @@ async def test_config_flow(hass: HomeAssistant) -> None:
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Supervisor"
|
||||
assert result["data"] == {}
|
||||
assert result["options"] == DEFAULT_UPDATE_OPTIONS
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
@@ -39,3 +46,38 @@ async def test_multiple_entries(hass: HomeAssistant) -> None:
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_config_flow_uses_bootstrap_user(hass: HomeAssistant) -> None:
|
||||
"""Test config flow stores the bootstrap supervisor user in entry data."""
|
||||
user = await hass.auth.async_create_system_user(
|
||||
"Supervisor", group_ids=[GROUP_ID_ADMIN]
|
||||
)
|
||||
hass.data[DATA_HASSIO_SUPERVISOR_USER] = user
|
||||
|
||||
with (
|
||||
patch("homeassistant.components.hassio.async_setup", return_value=True),
|
||||
patch("homeassistant.components.hassio.async_setup_entry", return_value=True),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": "system"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {ENTRY_DATA_USER: user.id}
|
||||
assert result["options"] == DEFAULT_UPDATE_OPTIONS
|
||||
|
||||
|
||||
async def test_config_flow_without_bootstrap_user(hass: HomeAssistant) -> None:
|
||||
"""Test config flow still creates default options without bootstrap user."""
|
||||
with (
|
||||
patch("homeassistant.components.hassio.async_setup", return_value=True),
|
||||
patch("homeassistant.components.hassio.async_setup_entry", return_value=True),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": "system"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {}
|
||||
assert result["options"] == DEFAULT_UPDATE_OPTIONS
|
||||
|
||||
@@ -52,10 +52,14 @@ from homeassistant.components.hassio import (
|
||||
get_supervisor_stats,
|
||||
hostname_from_addon_slug,
|
||||
)
|
||||
from homeassistant.components.hassio.config import STORAGE_KEY
|
||||
from homeassistant.components.hassio.const import (
|
||||
DATA_HASSIO_SUPERVISOR_USER,
|
||||
DATA_KEY_SUPERVISOR_ISSUES,
|
||||
ENTRY_DATA_USER,
|
||||
HASSIO_MAIN_UPDATE_INTERVAL,
|
||||
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE,
|
||||
OPTION_ADD_ON_BACKUP_RETAIN_COPIES,
|
||||
OPTION_CORE_BACKUP_BEFORE_UPDATE,
|
||||
REQUEST_REFRESH_DELAY,
|
||||
)
|
||||
from homeassistant.components.homeassistant import (
|
||||
@@ -329,13 +333,10 @@ async def test_setup_api_push_api_data_server_host(
|
||||
|
||||
|
||||
async def test_setup_api_push_api_data_default(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any], supervisor_client: AsyncMock
|
||||
hass: HomeAssistant, supervisor_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test setup with API push default data."""
|
||||
with (
|
||||
patch.dict(os.environ, MOCK_ENVIRON),
|
||||
patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0),
|
||||
):
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -347,10 +348,7 @@ async def test_setup_api_push_api_data_default(
|
||||
refresh_token = (
|
||||
supervisor_client.homeassistant.set_options.mock_calls[0].args[0].refresh_token
|
||||
)
|
||||
hassio_user = await hass.auth.async_get_user(
|
||||
hass_storage[STORAGE_KEY]["data"]["hassio_user"]
|
||||
)
|
||||
assert hassio_user is not None
|
||||
hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
|
||||
assert hassio_user.system_generated
|
||||
assert len(hassio_user.groups) == 1
|
||||
assert hassio_user.groups[0].id == GROUP_ID_ADMIN
|
||||
@@ -362,20 +360,18 @@ async def test_setup_api_push_api_data_default(
|
||||
pytest.fail("refresh token not found")
|
||||
|
||||
|
||||
async def test_setup_adds_admin_group_to_user(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test setup with API push default data."""
|
||||
# Create user without admin
|
||||
async def test_setup_adds_admin_group_to_user(hass: HomeAssistant) -> None:
|
||||
"""Test setup migrates the configured user to admin."""
|
||||
user = await hass.auth.async_create_system_user("Hass.io")
|
||||
assert not user.is_admin
|
||||
await hass.auth.async_create_refresh_token(user)
|
||||
|
||||
hass_storage[STORAGE_KEY] = {
|
||||
"data": {"hassio_user": user.id},
|
||||
"key": STORAGE_KEY,
|
||||
"version": 1,
|
||||
}
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={ENTRY_DATA_USER: user.id},
|
||||
unique_id=DOMAIN,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
|
||||
@@ -384,19 +380,17 @@ async def test_setup_adds_admin_group_to_user(
|
||||
assert user.is_admin
|
||||
|
||||
|
||||
async def test_setup_migrate_user_name(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test setup with migrating the user name."""
|
||||
# Create user with old name
|
||||
async def test_setup_migrate_user_name(hass: HomeAssistant) -> None:
|
||||
"""Test setup migrates the configured user name."""
|
||||
user = await hass.auth.async_create_system_user("Hass.io")
|
||||
await hass.auth.async_create_refresh_token(user)
|
||||
|
||||
hass_storage[STORAGE_KEY] = {
|
||||
"data": {"hassio_user": user.id},
|
||||
"key": STORAGE_KEY,
|
||||
"version": 1,
|
||||
}
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={ENTRY_DATA_USER: user.id},
|
||||
unique_id=DOMAIN,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
|
||||
@@ -406,12 +400,18 @@ async def test_setup_migrate_user_name(
|
||||
|
||||
|
||||
async def test_setup_api_existing_hassio_user(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any], supervisor_client: AsyncMock
|
||||
hass: HomeAssistant, supervisor_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test setup with API push default data."""
|
||||
"""Test setup uses the user from config entry data."""
|
||||
user = await hass.auth.async_create_system_user("Hass.io test")
|
||||
token = await hass.auth.async_create_refresh_token(user)
|
||||
hass_storage[STORAGE_KEY] = {"version": 1, "data": {"hassio_user": user.id}}
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={ENTRY_DATA_USER: user.id},
|
||||
unique_id=DOMAIN,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
|
||||
await hass.async_block_till_done()
|
||||
@@ -423,6 +423,51 @@ async def test_setup_api_existing_hassio_user(
|
||||
)
|
||||
|
||||
|
||||
async def test_setup_migrates_legacy_hassio_store_to_config_entry(
|
||||
hass: HomeAssistant,
|
||||
hass_storage: dict[str, Any],
|
||||
supervisor_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test setup migrates legacy hassio store user/options into config entry."""
|
||||
user = await hass.auth.async_create_system_user("Hass.io test")
|
||||
token = await hass.auth.async_create_refresh_token(user)
|
||||
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
hass_storage[DOMAIN] = {
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": DOMAIN,
|
||||
"data": {
|
||||
"hassio_user": user.id,
|
||||
"update_config": {
|
||||
"add_on_backup_before_update": True,
|
||||
"add_on_backup_retain_copies": 2,
|
||||
"core_backup_before_update": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result
|
||||
assert DOMAIN not in hass_storage
|
||||
|
||||
entry = hass.config_entries.async_entries(DOMAIN, include_ignore=False)[0]
|
||||
assert entry.data[ENTRY_DATA_USER] == user.id
|
||||
assert entry.options[OPTION_ADD_ON_BACKUP_BEFORE_UPDATE] is True
|
||||
assert entry.options[OPTION_ADD_ON_BACKUP_RETAIN_COPIES] == 2
|
||||
assert entry.options[OPTION_CORE_BACKUP_BEFORE_UPDATE] is True
|
||||
|
||||
assert len(supervisor_client.mock_calls) == 16
|
||||
supervisor_client.homeassistant.set_options.assert_called_once_with(
|
||||
HomeAssistantOptions(ssl=False, port=8123, refresh_token=token.token)
|
||||
)
|
||||
|
||||
|
||||
async def test_setup_core_push_config(
|
||||
hass: HomeAssistant, supervisor_client: AsyncMock
|
||||
) -> None:
|
||||
|
||||
@@ -969,6 +969,9 @@ async def test_read_update_config(
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test read and update config."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
websocket_client = await hass_ws_client(hass)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user