From 50a2e8fde33f652b532bd37fc83fe83b3c3de1f9 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 10 Apr 2024 04:25:22 -0400 Subject: [PATCH] Allow adoption of existing data disk (#4991) * Allow adoption of existing data disk * Fix existing tests * Add test cases and fix image issues * Fix addon build test * Run checks during setup not startup * Addon load mimics plugin and HA load for docker part * Default image accessible in except --- supervisor/addons/addon.py | 13 +- supervisor/addons/build.py | 4 +- supervisor/api/homeassistant.py | 3 + supervisor/docker/addon.py | 6 +- supervisor/docker/interface.py | 39 +++++ supervisor/homeassistant/const.py | 1 + supervisor/homeassistant/core.py | 7 + supervisor/homeassistant/module.py | 19 ++- supervisor/homeassistant/validate.py | 2 + supervisor/os/data_disk.py | 4 +- supervisor/plugins/audio.py | 49 +----- supervisor/plugins/base.py | 54 +++++- supervisor/plugins/cli.py | 50 +----- supervisor/plugins/dns.py | 46 +---- supervisor/plugins/multicast.py | 50 +----- supervisor/plugins/observer.py | 54 +----- .../resolution/checks/multiple_data_disks.py | 7 +- supervisor/resolution/const.py | 1 + .../fixups/system_adopt_data_disk.py | 90 ++++++++++ tests/addons/test_addon.py | 56 ++++++- tests/addons/test_manager.py | 2 +- tests/api/test_homeassistant.py | 30 ++++ tests/conftest.py | 15 +- tests/homeassistant/test_core.py | 75 +++++++++ tests/homeassistant/test_module.py | 3 + tests/plugins/test_plugin_base.py | 35 ++++ .../check/test_check_multiple_data_disks.py | 5 +- .../fixup/test_system_adopt_data_disk.py | 158 ++++++++++++++++++ 28 files changed, 640 insertions(+), 238 deletions(-) create mode 100644 supervisor/resolution/fixups/system_adopt_data_disk.py create mode 100644 tests/resolution/fixup/test_system_adopt_data_disk.py diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index c4d30c3a9..7012f8486 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -195,9 +195,20 @@ class Addon(AddonModel): ) await self._check_ingress_port() - with suppress(DockerError): + default_image = self._image(self.data) + try: await self.instance.attach(version=self.version) + # Ensure we are using correct image for this system + await self.instance.check_image(self.version, default_image, self.arch) + except DockerError: + _LOGGER.info("No %s addon Docker image %s found", self.slug, self.image) + with suppress(AddonsError): + await self.instance.install(self.version, default_image, arch=self.arch) + + self.persist[ATTR_IMAGE] = default_image + self.save_persist() + @property def ip_address(self) -> IPv4Address: """Return IP of add-on instance.""" diff --git a/supervisor/addons/build.py b/supervisor/addons/build.py index 2acdbef1b..838051f4c 100644 --- a/supervisor/addons/build.py +++ b/supervisor/addons/build.py @@ -102,11 +102,11 @@ class AddonBuild(FileConfiguration, CoreSysAttributes): except HassioArchNotFound: return False - def get_docker_args(self, version: AwesomeVersion): + def get_docker_args(self, version: AwesomeVersion, image: str | None = None): """Create a dict with Docker build arguments.""" args = { "path": str(self.addon.path_location), - "tag": f"{self.addon.image}:{version!s}", + "tag": f"{image or self.addon.image}:{version!s}", "dockerfile": str(self.dockerfile), "pull": True, "forcerm": not self.sys_dev, diff --git a/supervisor/api/homeassistant.py b/supervisor/api/homeassistant.py index 20dd353d1..de6cb13e1 100644 --- a/supervisor/api/homeassistant.py +++ b/supervisor/api/homeassistant.py @@ -93,6 +93,9 @@ class APIHomeAssistant(CoreSysAttributes): if ATTR_IMAGE in body: self.sys_homeassistant.image = body[ATTR_IMAGE] + self.sys_homeassistant.override_image = ( + self.sys_homeassistant.image != self.sys_homeassistant.default_image + ) if ATTR_BOOT in body: self.sys_homeassistant.boot = body[ATTR_BOOT] diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index b7065736a..950c62966 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -641,11 +641,11 @@ class DockerAddon(DockerInterface): ) -> None: """Pull Docker image or build it.""" if need_build is None and self.addon.need_build or need_build: - await self._build(version) + await self._build(version, image) else: await super().install(version, image, latest, arch) - async def _build(self, version: AwesomeVersion) -> None: + async def _build(self, version: AwesomeVersion, image: str | None = None) -> None: """Build a Docker container.""" build_env = AddonBuild(self.coresys, self.addon) if not build_env.is_valid: @@ -657,7 +657,7 @@ class DockerAddon(DockerInterface): image, log = await self.sys_run_in_executor( self.sys_docker.images.build, use_config_proxy=False, - **build_env.get_docker_args(version), + **build_env.get_docker_args(version, image), ) _LOGGER.debug("Build %s:%s done: %s", self.image, version, log) diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index aba8bb773..8ccf8c346 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -14,6 +14,7 @@ from awesomeversion import AwesomeVersion from awesomeversion.strategy import AwesomeVersionStrategy import docker from docker.models.containers import Container +from docker.models.images import Image import requests from ..const import ( @@ -438,6 +439,44 @@ class DockerInterface(JobGroup): ) self._meta = None + @Job( + name="docker_interface_check_image", + limit=JobExecutionLimit.GROUP_ONCE, + on_condition=DockerJobError, + ) + async def check_image( + self, + version: AwesomeVersion, + expected_image: str, + expected_arch: CpuArch | None = None, + ) -> None: + """Check we have expected image with correct arch.""" + expected_arch = expected_arch or self.sys_arch.supervisor + image_name = f"{expected_image}:{version!s}" + if self.image == expected_image: + try: + image: Image = await self.sys_run_in_executor( + self.sys_docker.images.get, image_name + ) + except (docker.errors.DockerException, requests.RequestException) as err: + raise DockerError( + f"Could not get {image_name} for check due to: {err!s}", + _LOGGER.error, + ) from err + + image_arch = f"{image.attrs['Os']}/{image.attrs['Architecture']}" + if "Variant" in image.attrs: + image_arch = f"{image_arch}/{image.attrs['Variant']}" + + # If we have an image and its the right arch, all set + if MAP_ARCH[expected_arch] == image_arch: + return + + # We're missing the image we need. Stop and clean up what we have then pull the right one + with suppress(DockerError): + await self.remove() + await self.install(version, expected_image, arch=expected_arch) + @Job( name="docker_interface_update", limit=JobExecutionLimit.GROUP_ONCE, diff --git a/supervisor/homeassistant/const.py b/supervisor/homeassistant/const.py index cecfe6761..1a7577470 100644 --- a/supervisor/homeassistant/const.py +++ b/supervisor/homeassistant/const.py @@ -6,6 +6,7 @@ from awesomeversion import AwesomeVersion from ..const import CoreState +ATTR_OVERRIDE_IMAGE = "override_image" LANDINGPAGE: AwesomeVersion = AwesomeVersion("landingpage") WATCHDOG_RETRY_SECONDS = 10 WATCHDOG_MAX_ATTEMPTS = 5 diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index b62723866..ee98b51aa 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -89,6 +89,13 @@ class HomeAssistantCore(JobGroup): await self.instance.attach( version=self.sys_homeassistant.version, skip_state_event_if_down=True ) + + # Ensure we are using correct image for this system (unless user has overridden it) + if not self.sys_homeassistant.override_image: + await self.instance.check_image( + self.sys_homeassistant.version, self.sys_homeassistant.default_image + ) + self.sys_homeassistant.image = self.sys_homeassistant.default_image except DockerError: _LOGGER.info( "No Home Assistant Docker image %s found.", self.sys_homeassistant.image diff --git a/supervisor/homeassistant/module.py b/supervisor/homeassistant/module.py index 561fd5430..0a63094bd 100644 --- a/supervisor/homeassistant/module.py +++ b/supervisor/homeassistant/module.py @@ -48,7 +48,7 @@ from ..utils import remove_folder from ..utils.common import FileConfiguration from ..utils.json import read_json_file, write_json_file from .api import HomeAssistantAPI -from .const import WSType +from .const import ATTR_OVERRIDE_IMAGE, WSType from .core import HomeAssistantCore from .secrets import HomeAssistantSecrets from .validate import SCHEMA_HASS_CONFIG @@ -170,18 +170,33 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes): """Return last available version of Home Assistant.""" return self.sys_updater.version_homeassistant + @property + def default_image(self) -> str: + """Return the default image for this system.""" + return f"ghcr.io/home-assistant/{self.sys_machine}-homeassistant" + @property def image(self) -> str: """Return image name of the Home Assistant container.""" if self._data.get(ATTR_IMAGE): return self._data[ATTR_IMAGE] - return f"ghcr.io/home-assistant/{self.sys_machine}-homeassistant" + return self.default_image @image.setter def image(self, value: str | None) -> None: """Set image name of Home Assistant container.""" self._data[ATTR_IMAGE] = value + @property + def override_image(self) -> bool: + """Return if user has overridden the image to use for Home Assistant.""" + return self._data[ATTR_OVERRIDE_IMAGE] + + @override_image.setter + def override_image(self, value: bool) -> None: + """Enable/disable image override.""" + self._data[ATTR_OVERRIDE_IMAGE] = value + @property def version(self) -> AwesomeVersion | None: """Return version of local version.""" diff --git a/supervisor/homeassistant/validate.py b/supervisor/homeassistant/validate.py index 90669eef8..01f3093c8 100644 --- a/supervisor/homeassistant/validate.py +++ b/supervisor/homeassistant/validate.py @@ -18,6 +18,7 @@ from ..const import ( ATTR_WATCHDOG, ) from ..validate import docker_image, network_port, token, uuid_match, version_tag +from .const import ATTR_OVERRIDE_IMAGE # pylint: disable=no-value-for-parameter SCHEMA_HASS_CONFIG = vol.Schema( @@ -34,6 +35,7 @@ SCHEMA_HASS_CONFIG = vol.Schema( vol.Optional(ATTR_AUDIO_OUTPUT, default=None): vol.Maybe(str), vol.Optional(ATTR_AUDIO_INPUT, default=None): vol.Maybe(str), vol.Optional(ATTR_BACKUPS_EXCLUDE_DATABASE, default=False): vol.Boolean(), + vol.Optional(ATTR_OVERRIDE_IMAGE, default=False): vol.Boolean(), }, extra=vol.REMOVE_EXTRA, ) diff --git a/supervisor/os/data_disk.py b/supervisor/os/data_disk.py index 873df2301..609e125c9 100644 --- a/supervisor/os/data_disk.py +++ b/supervisor/os/data_disk.py @@ -123,9 +123,9 @@ class DataDisk(CoreSysAttributes): vendor="", model="", serial="", - id=self.sys_dbus.agent.datadisk.current_device, + id=self.sys_dbus.agent.datadisk.current_device.as_posix(), size=0, - device_path=self.sys_dbus.agent.datadisk.current_device, + device_path=self.sys_dbus.agent.datadisk.current_device.as_posix(), object_path="", device_object_path="", ) diff --git a/supervisor/plugins/audio.py b/supervisor/plugins/audio.py index 2c57f4c4e..64471622a 100644 --- a/supervisor/plugins/audio.py +++ b/supervisor/plugins/audio.py @@ -2,8 +2,6 @@ Code: https://github.com/home-assistant/plugin-audio """ -import asyncio -from contextlib import suppress import errno import logging from pathlib import Path, PurePath @@ -72,6 +70,11 @@ class PluginAudio(PluginBase): """Return Path to pulse audio config file.""" return Path(self.sys_config.path_audio, "pulse_audio.json") + @property + def default_image(self) -> str: + """Return default image for audio plugin.""" + return self.sys_updater.image_audio + @property def latest_version(self) -> AwesomeVersion | None: """Return latest version of Audio.""" @@ -102,28 +105,6 @@ class PluginAudio(PluginBase): self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE _LOGGER.error("Can't create default asound: %s", err) - async def install(self) -> None: - """Install Audio.""" - _LOGGER.info("Setup Audio plugin") - while True: - # read audio tag and install it - if not self.latest_version: - await self.sys_updater.reload() - - if self.latest_version: - with suppress(DockerError): - await self.instance.install( - self.latest_version, image=self.sys_updater.image_audio - ) - break - _LOGGER.warning("Error on installing Audio plugin, retrying in 30sec") - await asyncio.sleep(30) - - _LOGGER.info("Audio plugin now installed") - self.version = self.instance.version - self.image = self.sys_updater.image_audio - self.save_data() - @Job( name="plugin_audio_update", conditions=PLUGIN_UPDATE_CONDITIONS, @@ -131,29 +112,11 @@ class PluginAudio(PluginBase): ) async def update(self, version: str | None = None) -> None: """Update Audio plugin.""" - version = version or self.latest_version - old_image = self.image - - if version == self.version: - _LOGGER.warning("Version %s is already installed for Audio", version) - return - try: - await self.instance.update(version, image=self.sys_updater.image_audio) + await super().update(version) except DockerError as err: raise AudioUpdateError("Audio update failed", _LOGGER.error) from err - self.version = version - self.image = self.sys_updater.image_audio - self.save_data() - - # Cleanup - with suppress(DockerError): - await self.instance.cleanup(old_image=old_image) - - # Start Audio - await self.start() - async def restart(self) -> None: """Restart Audio plugin.""" _LOGGER.info("Restarting Audio plugin") diff --git a/supervisor/plugins/base.py b/supervisor/plugins/base.py index b0e109657..ff3028d55 100644 --- a/supervisor/plugins/base.py +++ b/supervisor/plugins/base.py @@ -36,12 +36,17 @@ class PluginBase(ABC, FileConfiguration, CoreSysAttributes): """Set current version of the plugin.""" self._data[ATTR_VERSION] = value + @property + def default_image(self) -> str: + """Return default image for plugin.""" + return f"ghcr.io/home-assistant/{self.sys_arch.supervisor}-hassio-{self.slug}" + @property def image(self) -> str: """Return current image of plugin.""" if self._data.get(ATTR_IMAGE): return self._data[ATTR_IMAGE] - return f"ghcr.io/home-assistant/{self.sys_arch.supervisor}-hassio-{self.slug}" + return self.default_image @image.setter def image(self, value: str) -> None: @@ -160,6 +165,8 @@ class PluginBase(ABC, FileConfiguration, CoreSysAttributes): await self.instance.attach( version=self.version, skip_state_event_if_down=True ) + + await self.instance.check_image(self.version, self.default_image) except DockerError: _LOGGER.info( "No %s plugin Docker image %s found.", self.slug, self.instance.image @@ -170,7 +177,7 @@ class PluginBase(ABC, FileConfiguration, CoreSysAttributes): await self.install() else: self.version = self.instance.version - self.image = self.instance.image + self.image = self.default_image self.save_data() # Run plugin @@ -178,13 +185,52 @@ class PluginBase(ABC, FileConfiguration, CoreSysAttributes): if not await self.instance.is_running(): await self.start() - @abstractmethod async def install(self) -> None: """Install system plugin.""" + _LOGGER.info("Setup %s plugin", self.slug) + while True: + # read plugin tag and install it + if not self.latest_version: + await self.sys_updater.reload() + + if self.latest_version: + with suppress(DockerError): + await self.instance.install( + self.latest_version, image=self.default_image + ) + break + _LOGGER.warning( + "Error on installing %s plugin, retrying in 30sec", self.slug + ) + await asyncio.sleep(30) + + _LOGGER.info("%s plugin now installed", self.slug) + self.version = self.instance.version + self.image = self.default_image + self.save_data() - @abstractmethod async def update(self, version: str | None = None) -> None: """Update system plugin.""" + version = version or self.latest_version + old_image = self.image + + if version == self.version: + _LOGGER.warning( + "Version %s is already installed for %s", version, self.slug + ) + return + + await self.instance.update(version, image=self.default_image) + self.version = self.instance.version + self.image = self.default_image + self.save_data() + + # Cleanup + with suppress(DockerError): + await self.instance.cleanup(old_image=old_image) + + # Start plugin + await self.start() @abstractmethod async def repair(self) -> None: diff --git a/supervisor/plugins/cli.py b/supervisor/plugins/cli.py index 893819a0f..bdb584052 100644 --- a/supervisor/plugins/cli.py +++ b/supervisor/plugins/cli.py @@ -2,9 +2,7 @@ Code: https://github.com/home-assistant/plugin-cli """ -import asyncio from collections.abc import Awaitable -from contextlib import suppress import logging import secrets @@ -41,6 +39,11 @@ class PluginCli(PluginBase): self.coresys: CoreSys = coresys self.instance: DockerCli = DockerCli(coresys) + @property + def default_image(self) -> str: + """Return default image for cli plugin.""" + return self.sys_updater.image_cli + @property def latest_version(self) -> AwesomeVersion | None: """Return version of latest cli.""" @@ -51,29 +54,6 @@ class PluginCli(PluginBase): """Return an access token for the Supervisor API.""" return self._data.get(ATTR_ACCESS_TOKEN) - async def install(self) -> None: - """Install cli.""" - _LOGGER.info("Running setup for CLI plugin") - while True: - # read cli tag and install it - if not self.latest_version: - await self.sys_updater.reload() - - if self.latest_version: - with suppress(DockerError): - await self.instance.install( - self.latest_version, - image=self.sys_updater.image_cli, - ) - break - _LOGGER.warning("Error on install cli plugin. Retrying in 30sec") - await asyncio.sleep(30) - - _LOGGER.info("CLI plugin is now installed") - self.version = self.instance.version - self.image = self.sys_updater.image_cli - self.save_data() - @Job( name="plugin_cli_update", conditions=PLUGIN_UPDATE_CONDITIONS, @@ -81,29 +61,11 @@ class PluginCli(PluginBase): ) async def update(self, version: AwesomeVersion | None = None) -> None: """Update local HA cli.""" - version = version or self.latest_version - old_image = self.image - - if version == self.version: - _LOGGER.warning("Version %s is already installed for CLI", version) - return - try: - await self.instance.update(version, image=self.sys_updater.image_cli) + await super().update(version) except DockerError as err: raise CliUpdateError("CLI update failed", _LOGGER.error) from err - self.version = version - self.image = self.sys_updater.image_cli - self.save_data() - - # Cleanup - with suppress(DockerError): - await self.instance.cleanup(old_image=old_image) - - # Start cli - await self.start() - async def start(self) -> None: """Run cli.""" # Create new API token diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index 393a055ba..6fa29e838 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -108,6 +108,11 @@ class PluginDns(PluginBase): """Return list of DNS servers.""" self._data[ATTR_SERVERS] = value + @property + def default_image(self) -> str: + """Return default image for dns plugin.""" + return self.sys_updater.image_dns + @property def latest_version(self) -> AwesomeVersion | None: """Return latest version of CoreDNS.""" @@ -168,25 +173,7 @@ class PluginDns(PluginBase): async def install(self) -> None: """Install CoreDNS.""" - _LOGGER.info("Running setup for CoreDNS plugin") - while True: - # read homeassistant tag and install it - if not self.latest_version: - await self.sys_updater.reload() - - if self.latest_version: - with suppress(DockerError): - await self.instance.install( - self.latest_version, image=self.sys_updater.image_dns - ) - break - _LOGGER.warning("Error on install CoreDNS plugin. Retrying in 30sec") - await asyncio.sleep(30) - - _LOGGER.info("CoreDNS plugin now installed") - self.version = self.instance.version - self.image = self.sys_updater.image_dns - self.save_data() + await super().install() # Init Hosts await self.write_hosts() @@ -198,30 +185,11 @@ class PluginDns(PluginBase): ) async def update(self, version: AwesomeVersion | None = None) -> None: """Update CoreDNS plugin.""" - version = version or self.latest_version - old_image = self.image - - if version == self.version: - _LOGGER.warning("Version %s is already installed for CoreDNS", version) - return - - # Update try: - await self.instance.update(version, image=self.sys_updater.image_dns) + await super().update(version) except DockerError as err: raise CoreDNSUpdateError("CoreDNS update failed", _LOGGER.error) from err - self.version = version - self.image = self.sys_updater.image_dns - self.save_data() - - # Cleanup - with suppress(DockerError): - await self.instance.cleanup(old_image=old_image) - - # Start CoreDNS - await self.start() - async def restart(self) -> None: """Restart CoreDNS plugin.""" self._write_config() diff --git a/supervisor/plugins/multicast.py b/supervisor/plugins/multicast.py index d69103d86..f5ad2d164 100644 --- a/supervisor/plugins/multicast.py +++ b/supervisor/plugins/multicast.py @@ -2,8 +2,6 @@ Code: https://github.com/home-assistant/plugin-multicast """ -import asyncio -from contextlib import suppress import logging from awesomeversion import AwesomeVersion @@ -43,33 +41,16 @@ class PluginMulticast(PluginBase): self.coresys: CoreSys = coresys self.instance: DockerMulticast = DockerMulticast(coresys) + @property + def default_image(self) -> str: + """Return default image for multicast plugin.""" + return self.sys_updater.image_multicast + @property def latest_version(self) -> AwesomeVersion | None: """Return latest version of Multicast.""" return self.sys_updater.version_multicast - async def install(self) -> None: - """Install Multicast.""" - _LOGGER.info("Running setup for Multicast plugin") - while True: - # read multicast tag and install it - if not self.latest_version: - await self.sys_updater.reload() - - if self.latest_version: - with suppress(DockerError): - await self.instance.install( - self.latest_version, image=self.sys_updater.image_multicast - ) - break - _LOGGER.warning("Error on install Multicast plugin. Retrying in 30sec") - await asyncio.sleep(30) - - _LOGGER.info("Multicast plugin is now installed") - self.version = self.instance.version - self.image = self.sys_updater.image_multicast - self.save_data() - @Job( name="plugin_multicast_update", conditions=PLUGIN_UPDATE_CONDITIONS, @@ -77,32 +58,13 @@ class PluginMulticast(PluginBase): ) async def update(self, version: AwesomeVersion | None = None) -> None: """Update Multicast plugin.""" - version = version or self.latest_version - old_image = self.image - - if version == self.version: - _LOGGER.warning("Version %s is already installed for Multicast", version) - return - - # Update try: - await self.instance.update(version, image=self.sys_updater.image_multicast) + await super().update(version) except DockerError as err: raise MulticastUpdateError( "Multicast update failed", _LOGGER.error ) from err - self.version = version - self.image = self.sys_updater.image_multicast - self.save_data() - - # Cleanup - with suppress(DockerError): - await self.instance.cleanup(old_image=old_image) - - # Start Multicast plugin - await self.start() - async def restart(self) -> None: """Restart Multicast plugin.""" _LOGGER.info("Restarting Multicast plugin") diff --git a/supervisor/plugins/observer.py b/supervisor/plugins/observer.py index 5d2fe86ca..2da1a0922 100644 --- a/supervisor/plugins/observer.py +++ b/supervisor/plugins/observer.py @@ -2,8 +2,6 @@ Code: https://github.com/home-assistant/plugin-observer """ -import asyncio -from contextlib import suppress import logging import secrets @@ -46,6 +44,11 @@ class PluginObserver(PluginBase): self.coresys: CoreSys = coresys self.instance: DockerObserver = DockerObserver(coresys) + @property + def default_image(self) -> str: + """Return default image for observer plugin.""" + return self.sys_updater.image_observer + @property def latest_version(self) -> AwesomeVersion | None: """Return version of latest observer.""" @@ -56,28 +59,6 @@ class PluginObserver(PluginBase): """Return an access token for the Observer API.""" return self._data.get(ATTR_ACCESS_TOKEN) - async def install(self) -> None: - """Install observer.""" - _LOGGER.info("Running setup for observer plugin") - while True: - # read observer tag and install it - if not self.latest_version: - await self.sys_updater.reload() - - if self.latest_version: - with suppress(DockerError): - await self.instance.install( - self.latest_version, image=self.sys_updater.image_observer - ) - break - _LOGGER.warning("Error on install observer plugin. Retrying in 30sec") - await asyncio.sleep(30) - - _LOGGER.info("observer plugin now installed") - self.version = self.instance.version - self.image = self.sys_updater.image_observer - self.save_data() - @Job( name="plugin_observer_update", conditions=PLUGIN_UPDATE_CONDITIONS, @@ -85,29 +66,12 @@ class PluginObserver(PluginBase): ) async def update(self, version: AwesomeVersion | None = None) -> None: """Update local HA observer.""" - version = version or self.latest_version - old_image = self.image - - if version == self.version: - _LOGGER.warning("Version %s is already installed for observer", version) - return - try: - await self.instance.update(version, image=self.sys_updater.image_observer) + await super().update(version) except DockerError as err: - _LOGGER.error("HA observer update failed") - raise ObserverUpdateError() from err - - self.version = version - self.image = self.sys_updater.image_observer - self.save_data() - - # Cleanup - with suppress(DockerError): - await self.instance.cleanup(old_image=old_image) - - # Start observer - await self.start() + raise ObserverUpdateError( + "HA observer update failed", _LOGGER.error + ) from err async def start(self) -> None: """Run observer.""" diff --git a/supervisor/resolution/checks/multiple_data_disks.py b/supervisor/resolution/checks/multiple_data_disks.py index 3f386d59e..6c28c7aeb 100644 --- a/supervisor/resolution/checks/multiple_data_disks.py +++ b/supervisor/resolution/checks/multiple_data_disks.py @@ -27,7 +27,10 @@ class CheckMultipleDataDisks(CheckBase): IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference=block_device.device.as_posix(), - suggestions=[SuggestionType.RENAME_DATA_DISK], + suggestions=[ + SuggestionType.RENAME_DATA_DISK, + SuggestionType.ADOPT_DATA_DISK, + ], ) async def approve_check(self, reference: str | None = None) -> bool: @@ -58,4 +61,4 @@ class CheckMultipleDataDisks(CheckBase): @property def states(self) -> list[CoreState]: """Return a list of valid states when this check can run.""" - return [CoreState.RUNNING, CoreState.STARTUP] + return [CoreState.RUNNING, CoreState.SETUP] diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 6f67ad8bf..9f4f83ec2 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -96,6 +96,7 @@ class IssueType(StrEnum): class SuggestionType(StrEnum): """Sugestion type.""" + ADOPT_DATA_DISK = "adopt_data_disk" CLEAR_FULL_BACKUP = "clear_full_backup" CREATE_FULL_BACKUP = "create_full_backup" EXECUTE_INTEGRITY = "execute_integrity" diff --git a/supervisor/resolution/fixups/system_adopt_data_disk.py b/supervisor/resolution/fixups/system_adopt_data_disk.py new file mode 100644 index 000000000..246c34cb3 --- /dev/null +++ b/supervisor/resolution/fixups/system_adopt_data_disk.py @@ -0,0 +1,90 @@ +"""Adopt data disk fixup.""" + +import logging +from pathlib import Path + +from ...coresys import CoreSys +from ...dbus.udisks2.data import DeviceSpecification +from ...exceptions import DBusError, HostError, ResolutionFixupError +from ...os.const import FILESYSTEM_LABEL_OLD_DATA_DISK +from ..const import ContextType, IssueType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def setup(coresys: CoreSys) -> FixupBase: + """Check setup function.""" + return FixupSystemAdoptDataDisk(coresys) + + +class FixupSystemAdoptDataDisk(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: str | None = None) -> None: + """Initialize the fixup class.""" + if not await self.sys_dbus.udisks2.resolve_device( + DeviceSpecification(path=Path(reference)) + ): + _LOGGER.info( + "Data disk at %s with name conflict was removed, skipping adopt", + reference, + ) + return + + current = self.sys_dbus.agent.datadisk.current_device + if ( + not current + or not ( + resolved := await self.sys_dbus.udisks2.resolve_device( + DeviceSpecification(path=current) + ) + ) + or not resolved[0].filesystem + ): + raise ResolutionFixupError( + "Cannot resolve current data disk for rename", _LOGGER.error + ) + + _LOGGER.info( + "Renaming current data disk at %s to %s so new data disk at %s becomes primary ", + self.sys_dbus.agent.datadisk.current_device, + FILESYSTEM_LABEL_OLD_DATA_DISK, + reference, + ) + try: + await resolved[0].filesystem.set_label(FILESYSTEM_LABEL_OLD_DATA_DISK) + except DBusError as err: + raise ResolutionFixupError( + f"Could not rename filesystem at {current.as_posix()}: {err!s}", + _LOGGER.error, + ) from err + + _LOGGER.info("Rebooting the host to finish adoption") + try: + await self.sys_host.control.reboot() + except (HostError, DBusError) as err: + _LOGGER.warning( + "Could not reboot host to finish data disk adoption, manual reboot required to finish process: %s", + err, + ) + self.sys_resolution.create_issue( + IssueType.REBOOT_REQUIRED, + ContextType.SYSTEM, + suggestions=[SuggestionType.EXECUTE_REBOOT], + ) + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.ADOPT_DATA_DISK + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.SYSTEM + + @property + def issues(self) -> list[IssueType]: + """Return a IssueType enum list.""" + return [IssueType.MULTIPLE_DATA_DISKS] diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index 4faab9bde..1634fa72e 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -7,7 +7,7 @@ from pathlib import Path from unittest.mock import MagicMock, PropertyMock, patch from awesomeversion import AwesomeVersion -from docker.errors import DockerException, NotFound +from docker.errors import DockerException, ImageNotFound, NotFound import pytest from securetar import SecureTarFile @@ -763,3 +763,57 @@ async def test_paths_cache(coresys: CoreSys, install_addon_ssh: Addon): assert install_addon_ssh.with_icon assert install_addon_ssh.with_changelog assert install_addon_ssh.with_documentation + + +async def test_addon_loads_wrong_image( + coresys: CoreSys, + install_addon_ssh: Addon, + container: MagicMock, + mock_amd64_arch_supported, +): + """Test addon is loaded with incorrect image for architecture.""" + coresys.addons.data.save_data.reset_mock() + install_addon_ssh.persist["image"] = "local/aarch64-addon-ssh" + assert install_addon_ssh.image == "local/aarch64-addon-ssh" + + with patch("pathlib.Path.is_file", return_value=True): + await install_addon_ssh.load() + + container.remove.assert_called_once_with(force=True) + assert coresys.docker.images.remove.call_args_list[0].kwargs == { + "image": "local/aarch64-addon-ssh:latest", + "force": True, + } + assert coresys.docker.images.remove.call_args_list[1].kwargs == { + "image": "local/aarch64-addon-ssh:9.2.1", + "force": True, + } + coresys.docker.images.build.assert_called_once() + assert ( + coresys.docker.images.build.call_args.kwargs["tag"] + == "local/amd64-addon-ssh:9.2.1" + ) + assert coresys.docker.images.build.call_args.kwargs["platform"] == "linux/amd64" + assert install_addon_ssh.image == "local/amd64-addon-ssh" + coresys.addons.data.save_data.assert_called_once() + + +async def test_addon_loads_missing_image( + coresys: CoreSys, + install_addon_ssh: Addon, + container: MagicMock, + mock_amd64_arch_supported, +): + """Test addon corrects a missing image on load.""" + coresys.docker.images.get.side_effect = ImageNotFound("missing") + + with patch("pathlib.Path.is_file", return_value=True): + await install_addon_ssh.load() + + coresys.docker.images.build.assert_called_once() + assert ( + coresys.docker.images.build.call_args.kwargs["tag"] + == "local/amd64-addon-ssh:9.2.1" + ) + assert coresys.docker.images.build.call_args.kwargs["platform"] == "linux/amd64" + assert install_addon_ssh.image == "local/amd64-addon-ssh" diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py index 49cca77c8..2b7b5f3c1 100644 --- a/tests/addons/test_manager.py +++ b/tests/addons/test_manager.py @@ -86,7 +86,7 @@ async def test_image_added_removed_on_update( DockerAddon, "_build" ) as build: await coresys.addons.update(TEST_ADDON_SLUG) - build.assert_called_once_with(AwesomeVersion("11.0.0")) + build.assert_called_once_with(AwesomeVersion("11.0.0"), "local/amd64-addon-ssh") install.assert_not_called() diff --git a/tests/api/test_homeassistant.py b/tests/api/test_homeassistant.py index 4f8854b6a..19958864f 100644 --- a/tests/api/test_homeassistant.py +++ b/tests/api/test_homeassistant.py @@ -62,3 +62,33 @@ async def test_api_set_options(api_client: TestClient, coresys: CoreSys): result = await resp.json() assert result["data"]["watchdog"] is False assert result["data"]["backups_exclude_database"] is True + + +async def test_api_set_image(api_client: TestClient, coresys: CoreSys): + """Test changing the image for homeassistant.""" + assert ( + coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant" + ) + assert coresys.homeassistant.override_image is False + + with patch.object(HomeAssistant, "save_data"): + resp = await api_client.post( + "/homeassistant/options", + json={"image": "test_image"}, + ) + + assert resp.status == 200 + assert coresys.homeassistant.image == "test_image" + assert coresys.homeassistant.override_image is True + + with patch.object(HomeAssistant, "save_data"): + resp = await api_client.post( + "/homeassistant/options", + json={"image": "ghcr.io/home-assistant/qemux86-64-homeassistant"}, + ) + + assert resp.status == 200 + assert ( + coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant" + ) + assert coresys.homeassistant.override_image is False diff --git a/tests/conftest.py b/tests/conftest.py index 90b15dc6f..57a5ea11e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,6 +77,8 @@ async def supervisor_name() -> None: async def docker() -> DockerAPI: """Mock DockerAPI.""" images = [MagicMock(tags=["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"])] + image = MagicMock() + image.attrs = {"Os": "linux", "Architecture": "amd64"} with patch( "supervisor.docker.manager.DockerClient", return_value=MagicMock() @@ -86,6 +88,8 @@ async def docker() -> DockerAPI: "supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock() ), patch( "supervisor.docker.manager.DockerAPI.api", return_value=MagicMock() + ), patch( + "supervisor.docker.manager.DockerAPI.images.get", return_value=image ), patch( "supervisor.docker.manager.DockerAPI.images.list", return_value=images ), patch( @@ -317,6 +321,9 @@ async def coresys( coresys_obj._mounts.save_data = MagicMock() # Mock test client + coresys_obj._supervisor.instance._meta = { + "Config": {"Labels": {"io.hass.arch": "amd64"}} + } coresys_obj.arch._default_arch = "amd64" coresys_obj.arch._supported_set = {"amd64"} coresys_obj._machine = "qemux86-64" @@ -716,15 +723,15 @@ async def container(docker: DockerAPI) -> MagicMock: @pytest.fixture def mock_amd64_arch_supported(coresys: CoreSys) -> None: """Mock amd64 arch as supported.""" - with patch.object(coresys.arch, "_supported_set", {"amd64"}): - yield + coresys.arch._supported_arch = ["amd64"] + coresys.arch._supported_set = {"amd64"} @pytest.fixture def mock_aarch64_arch_supported(coresys: CoreSys) -> None: """Mock aarch64 arch as supported.""" - with patch.object(coresys.arch, "_supported_set", {"aarch64"}): - yield + coresys.arch._supported_arch = ["amd64"] + coresys.arch._supported_set = {"amd64"} @pytest.fixture diff --git a/tests/homeassistant/test_core.py b/tests/homeassistant/test_core.py index a52422f64..690ca52df 100644 --- a/tests/homeassistant/test_core.py +++ b/tests/homeassistant/test_core.py @@ -314,3 +314,78 @@ async def test_api_check_success( assert coresys.homeassistant.api.get_api_state.call_count == 1 assert "Detect a running Home Assistant instance" in caplog.text + + +async def test_core_loads_wrong_image_for_machine( + coresys: CoreSys, container: MagicMock +): + """Test core is loaded with wrong image for machine.""" + coresys.homeassistant.image = "ghcr.io/home-assistant/odroid-n2-homeassistant" + coresys.homeassistant.version = AwesomeVersion("2024.4.0") + container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}} + + await coresys.homeassistant.core.load() + + container.remove.assert_called_once_with(force=True) + assert coresys.docker.images.remove.call_args_list[0].kwargs == { + "image": "ghcr.io/home-assistant/odroid-n2-homeassistant:latest", + "force": True, + } + assert coresys.docker.images.remove.call_args_list[1].kwargs == { + "image": "ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0", + "force": True, + } + coresys.docker.images.pull.assert_called_once_with( + "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0", + platform="linux/amd64", + ) + assert ( + coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant" + ) + + +async def test_core_load_allows_image_override(coresys: CoreSys, container: MagicMock): + """Test core does not change image if user overrode it.""" + coresys.homeassistant.image = "ghcr.io/home-assistant/odroid-n2-homeassistant" + coresys.homeassistant.version = AwesomeVersion("2024.4.0") + container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}} + + coresys.homeassistant.override_image = True + await coresys.homeassistant.core.load() + + container.remove.assert_not_called() + coresys.docker.images.remove.assert_not_called() + coresys.docker.images.pull.assert_not_called() + assert ( + coresys.homeassistant.image == "ghcr.io/home-assistant/odroid-n2-homeassistant" + ) + + +async def test_core_loads_wrong_image_for_architecture( + coresys: CoreSys, container: MagicMock +): + """Test core is loaded with wrong image for architecture.""" + coresys.homeassistant.version = AwesomeVersion("2024.4.0") + container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}} + coresys.docker.images.get("ghcr.io/home-assistant/qemux86-64-homeassistant").attrs[ + "Architecture" + ] = "arm64" + + await coresys.homeassistant.core.load() + + container.remove.assert_called_once_with(force=True) + assert coresys.docker.images.remove.call_args_list[0].kwargs == { + "image": "ghcr.io/home-assistant/qemux86-64-homeassistant:latest", + "force": True, + } + assert coresys.docker.images.remove.call_args_list[1].kwargs == { + "image": "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0", + "force": True, + } + coresys.docker.images.pull.assert_called_once_with( + "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0", + platform="linux/amd64", + ) + assert ( + coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant" + ) diff --git a/tests/homeassistant/test_module.py b/tests/homeassistant/test_module.py index e97de0620..1172b16a7 100644 --- a/tests/homeassistant/test_module.py +++ b/tests/homeassistant/test_module.py @@ -22,6 +22,8 @@ async def test_load( # Unwrap read_secrets to prevent throttling between tests with patch.object(DockerInterface, "attach") as attach, patch.object( + DockerInterface, "check_image" + ) as check_image, patch.object( HomeAssistantSecrets, "_read_secrets", new=HomeAssistantSecrets._read_secrets.__wrapped__, @@ -29,6 +31,7 @@ async def test_load( await coresys.homeassistant.load() attach.assert_called_once() + check_image.assert_called_once() assert coresys.homeassistant.secrets.secrets == {"hello": "world"} diff --git a/tests/plugins/test_plugin_base.py b/tests/plugins/test_plugin_base.py index c65d5e6f2..feb9a8bc1 100644 --- a/tests/plugins/test_plugin_base.py +++ b/tests/plugins/test_plugin_base.py @@ -325,3 +325,38 @@ async def test_repair_failed( capture_exception.assert_called_once() assert check_exception_chain(capture_exception.call_args[0][0], CodeNotaryUntrusted) + + +@pytest.mark.parametrize( + "plugin", + [PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver], + indirect=True, +) +async def test_load_with_incorrect_image( + coresys: CoreSys, container: MagicMock, plugin: PluginBase +): + """Test plugin loads with the incorrect image.""" + plugin.image = old_image = f"ghcr.io/home-assistant/aarch64-hassio-{plugin.slug}" + correct_image = f"ghcr.io/home-assistant/amd64-hassio-{plugin.slug}" + coresys.updater._data["image"][plugin.slug] = correct_image # pylint: disable=protected-access + plugin.version = AwesomeVersion("2024.4.0") + + container.status = "running" + container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}} + + await plugin.load() + + container.remove.assert_called_once_with(force=True) + assert coresys.docker.images.remove.call_args_list[0].kwargs == { + "image": f"{old_image}:latest", + "force": True, + } + assert coresys.docker.images.remove.call_args_list[1].kwargs == { + "image": f"{old_image}:2024.4.0", + "force": True, + } + coresys.docker.images.pull.assert_called_once_with( + f"{correct_image}:2024.4.0", + platform="linux/amd64", + ) + assert plugin.image == correct_image diff --git a/tests/resolution/check/test_check_multiple_data_disks.py b/tests/resolution/check/test_check_multiple_data_disks.py index c5dfe2844..f18367daa 100644 --- a/tests/resolution/check/test_check_multiple_data_disks.py +++ b/tests/resolution/check/test_check_multiple_data_disks.py @@ -53,7 +53,10 @@ async def test_check(coresys: CoreSys, sda1_block_service: BlockService): assert coresys.resolution.suggestions == [ Suggestion( SuggestionType.RENAME_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" - ) + ), + Suggestion( + SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ), ] diff --git a/tests/resolution/fixup/test_system_adopt_data_disk.py b/tests/resolution/fixup/test_system_adopt_data_disk.py new file mode 100644 index 000000000..a0828ef0e --- /dev/null +++ b/tests/resolution/fixup/test_system_adopt_data_disk.py @@ -0,0 +1,158 @@ +"""Test system fixup adopt data disk.""" + +from dbus_fast import DBusError, ErrorType, Variant +import pytest + +from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion +from supervisor.resolution.fixups.system_adopt_data_disk import FixupSystemAdoptDataDisk + +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.logind import Logind as LogindService +from tests.dbus_service_mocks.udisks2_filesystem import Filesystem as FilesystemService +from tests.dbus_service_mocks.udisks2_manager import ( + UDisks2Manager as UDisks2ManagerService, +) + + +@pytest.fixture(name="sda1_filesystem_service") +async def fixture_sda1_filesystem_service( + udisks2_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], +) -> FilesystemService: + """Return sda1 filesystem service.""" + return udisks2_services["udisks2_filesystem"][ + "/org/freedesktop/UDisks2/block_devices/sda1" + ] + + +@pytest.fixture(name="mmcblk1p3_filesystem_service") +async def fixture_mmcblk1p3_filesystem_service( + udisks2_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], +) -> FilesystemService: + """Return mmcblk1p3 filesystem service.""" + return udisks2_services["udisks2_filesystem"][ + "/org/freedesktop/UDisks2/block_devices/mmcblk1p3" + ] + + +@pytest.fixture(name="udisks2_service") +async def fixture_udisks2_service( + udisks2_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], +) -> UDisks2ManagerService: + """Return udisks2 manager service.""" + return udisks2_services["udisks2_manager"] + + +@pytest.fixture(name="logind_service") +async def fixture_logind_service( + all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], +) -> LogindService: + """Return logind service.""" + return all_dbus_services["logind"] + + +async def test_fixup( + coresys: CoreSys, + mmcblk1p3_filesystem_service: FilesystemService, + udisks2_service: UDisks2ManagerService, + logind_service: LogindService, +): + """Test fixup.""" + mmcblk1p3_filesystem_service.SetLabel.calls.clear() + logind_service.Reboot.calls.clear() + system_adopt_data_disk = FixupSystemAdoptDataDisk(coresys) + + assert not system_adopt_data_disk.auto + + coresys.resolution.suggestions = Suggestion( + SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) + coresys.resolution.issues = Issue( + IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1" + ) + udisks2_service.resolved_devices = [ + "/org/freedesktop/UDisks2/block_devices/mmcblk1p3" + ] + + await system_adopt_data_disk() + + assert mmcblk1p3_filesystem_service.SetLabel.calls == [ + ("hassos-data-old", {"auth.no_user_interaction": Variant("b", True)}) + ] + assert len(coresys.resolution.suggestions) == 0 + assert len(coresys.resolution.issues) == 0 + assert logind_service.Reboot.calls == [(False,)] + + +async def test_fixup_device_removed( + coresys: CoreSys, + mmcblk1p3_filesystem_service: FilesystemService, + udisks2_service: UDisks2ManagerService, + logind_service: LogindService, + caplog: pytest.LogCaptureFixture, +): + """Test fixup when device removed.""" + mmcblk1p3_filesystem_service.SetLabel.calls.clear() + logind_service.Reboot.calls.clear() + system_adopt_data_disk = FixupSystemAdoptDataDisk(coresys) + + assert not system_adopt_data_disk.auto + + coresys.resolution.suggestions = Suggestion( + SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) + coresys.resolution.issues = Issue( + IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1" + ) + + udisks2_service.resolved_devices = [] + await system_adopt_data_disk() + + assert len(coresys.resolution.suggestions) == 0 + assert len(coresys.resolution.issues) == 0 + assert "Data disk at /dev/sda1 with name conflict was removed" in caplog.text + assert mmcblk1p3_filesystem_service.SetLabel.calls == [] + assert logind_service.Reboot.calls == [] + + +async def test_fixup_reboot_failed( + coresys: CoreSys, + mmcblk1p3_filesystem_service: FilesystemService, + udisks2_service: UDisks2ManagerService, + logind_service: LogindService, + caplog: pytest.LogCaptureFixture, +): + """Test fixup when reboot fails.""" + mmcblk1p3_filesystem_service.SetLabel.calls.clear() + logind_service.side_effect_reboot = DBusError(ErrorType.SERVICE_ERROR, "error") + system_adopt_data_disk = FixupSystemAdoptDataDisk(coresys) + + assert not system_adopt_data_disk.auto + + coresys.resolution.suggestions = Suggestion( + SuggestionType.ADOPT_DATA_DISK, ContextType.SYSTEM, reference="/dev/sda1" + ) + coresys.resolution.issues = Issue( + IssueType.MULTIPLE_DATA_DISKS, ContextType.SYSTEM, reference="/dev/sda1" + ) + udisks2_service.resolved_devices = [ + "/org/freedesktop/UDisks2/block_devices/mmcblk1p3" + ] + + await system_adopt_data_disk() + + assert mmcblk1p3_filesystem_service.SetLabel.calls == [ + ("hassos-data-old", {"auth.no_user_interaction": Variant("b", True)}) + ] + assert len(coresys.resolution.suggestions) == 1 + assert ( + Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM) + in coresys.resolution.suggestions + ) + assert len(coresys.resolution.issues) == 1 + assert ( + Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + in coresys.resolution.issues + ) + assert "Could not reboot host to finish data disk adoption" in caplog.text