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
This commit is contained in:
Mike Degatano 2024-04-10 04:25:22 -04:00 committed by GitHub
parent 55ed63cc79
commit 50a2e8fde3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 640 additions and 238 deletions

View File

@ -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."""

View File

@ -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,

View File

@ -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]

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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,
)

View File

@ -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="",
)

View File

@ -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")

View File

@ -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:

View File

@ -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

View File

@ -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()

View File

@ -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")

View File

@ -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."""

View File

@ -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]

View File

@ -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"

View File

@ -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]

View File

@ -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"

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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"
)

View File

@ -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"}

View File

@ -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

View File

@ -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"
),
]

View File

@ -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