mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-07 17:26:32 +00:00
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:
parent
55ed63cc79
commit
50a2e8fde3
@ -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."""
|
||||
|
@ -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,
|
||||
|
@ -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]
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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="",
|
||||
)
|
||||
|
@ -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")
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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")
|
||||
|
@ -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."""
|
||||
|
@ -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]
|
||||
|
@ -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"
|
||||
|
90
supervisor/resolution/fixups/system_adopt_data_disk.py
Normal file
90
supervisor/resolution/fixups/system_adopt_data_disk.py
Normal 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]
|
@ -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"
|
||||
|
@ -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()
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
)
|
||||
|
@ -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"}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
158
tests/resolution/fixup/test_system_adopt_data_disk.py
Normal file
158
tests/resolution/fixup/test_system_adopt_data_disk.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user