From 97c35de49a55a59ae193442a8d4e6faf64cb8e9f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 31 Dec 2020 17:09:33 +0100 Subject: [PATCH] Refacture version handling with AwesomeVersion (#2392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refacture version handling with AwesomeVersion Signed-off-by: Pascal Vizeli * next Signed-off-by: Pascal Vizeli * next Signed-off-by: Pascal Vizeli * next Signed-off-by: Pascal Vizeli * v20.12.3 Signed-off-by: Pascal Vizeli * next Signed-off-by: Pascal Vizeli * next Signed-off-by: Pascal Vizeli * fix exception * fix schema * cleanup plugins * fix tests * fix attach * fix TypeError * fix issue with compairing * make lint happy * Update supervisor/homeassistant/__init__.py Co-authored-by: Joakim Sørensen * Update tests/test_validate.py Co-authored-by: Joakim Sørensen * Update supervisor/docker/__init__.py Co-authored-by: Joakim Sørensen * Update supervisor/supervisor.py Co-authored-by: Joakim Sørensen Co-authored-by: Joakim Sørensen --- requirements.txt | 3 +- supervisor/addons/addon.py | 2 +- supervisor/addons/build.py | 6 ++- supervisor/addons/model.py | 17 +++---- supervisor/addons/validate.py | 12 ++--- supervisor/api/utils.py | 7 ++- supervisor/config.py | 6 ++- supervisor/core.py | 3 +- supervisor/docker/__init__.py | 21 ++++---- supervisor/docker/addon.py | 23 +++++---- supervisor/docker/audio.py | 2 +- supervisor/docker/cli.py | 2 +- supervisor/docker/dns.py | 2 +- supervisor/docker/homeassistant.py | 2 +- supervisor/docker/interface.py | 75 +++++++++++++++------------- supervisor/docker/multicast.py | 2 +- supervisor/docker/observer.py | 2 +- supervisor/docker/supervisor.py | 11 ++-- supervisor/hassos.py | 22 ++++---- supervisor/homeassistant/__init__.py | 13 +++-- supervisor/homeassistant/core.py | 22 ++++---- supervisor/plugins/__init__.py | 30 +++++------ supervisor/plugins/audio.py | 46 +++-------------- supervisor/plugins/base.py | 66 ++++++++++++++++++++++++ supervisor/plugins/cli.py | 49 ++++-------------- supervisor/plugins/dns.py | 50 ++++--------------- supervisor/plugins/multicast.py | 48 +++--------------- supervisor/plugins/observer.py | 49 ++++-------------- supervisor/supervisor.py | 16 +++--- supervisor/updater.py | 37 ++++++++------ supervisor/utils/json.py | 24 ++++++++- supervisor/validate.py | 34 ++++++------- tests/misc/test_filter_data.py | 5 +- 33 files changed, 331 insertions(+), 378 deletions(-) create mode 100644 supervisor/plugins/base.py diff --git a/requirements.txt b/requirements.txt index 71e9641bf..8f279d8b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ aiohttp==3.7.3 async_timeout==3.0.1 atomicwrites==1.4.0 attrs==20.3.0 +awesomeversion==20.12.4 brotli==1.0.9 cchardet==2.1.7 colorlog==4.6.2 @@ -17,4 +18,4 @@ pytz==2020.5 pyudev==0.22.0 ruamel.yaml==0.15.100 sentry-sdk==0.19.5 -voluptuous==0.12.1 \ No newline at end of file +voluptuous==0.12.1 diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 09620c6f4..b7487d280 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -101,7 +101,7 @@ class Addon(AddonModel): async def load(self) -> None: """Async initialize of object.""" with suppress(DockerError): - await self.instance.attach(tag=self.version) + await self.instance.attach(version=self.version) # Evaluate state if await self.instance.is_running(): diff --git a/supervisor/addons/build.py b/supervisor/addons/build.py index 13ef6c410..d0c2ff86b 100644 --- a/supervisor/addons/build.py +++ b/supervisor/addons/build.py @@ -4,6 +4,8 @@ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Dict +from awesomeversion import AwesomeVersion + from ..const import ATTR_ARGS, ATTR_BUILD_FROM, ATTR_SQUASH, META_ADDON from ..coresys import CoreSys, CoreSysAttributes from ..utils.json import JsonConfig @@ -46,11 +48,11 @@ class AddonBuild(JsonConfig, CoreSysAttributes): """Return additional Docker build arguments.""" return self._data[ATTR_ARGS] - def get_docker_args(self, version): + def get_docker_args(self, version: AwesomeVersion): """Create a dict with Docker build arguments.""" args = { "path": str(self.addon.path_location), - "tag": f"{self.addon.image}:{version}", + "tag": f"{self.addon.image}:{version!s}", "pull": True, "forcerm": True, "squash": self.squash, diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index 362a06192..d37e7e112 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Awaitable, Dict, List, Optional -from packaging import version as pkg_version +from awesomeversion import AwesomeVersion, AwesomeVersionException import voluptuous as vol from ..const import ( @@ -183,12 +183,12 @@ class AddonModel(CoreSysAttributes, ABC): return self.data[ATTR_REPOSITORY] @property - def latest_version(self) -> str: + def latest_version(self) -> AwesomeVersion: """Return latest version of add-on.""" return self.data[ATTR_VERSION] @property - def version(self) -> Optional[str]: + def version(self) -> AwesomeVersion: """Return version of add-on.""" return self.data[ATTR_VERSION] @@ -554,15 +554,10 @@ class AddonModel(CoreSysAttributes, ABC): return False # Home Assistant - version = config.get(ATTR_HOMEASSISTANT) - if version is None or self.sys_homeassistant.version is None: - return True - + version: Optional[AwesomeVersion] = config.get(ATTR_HOMEASSISTANT) try: - return pkg_version.parse( - self.sys_homeassistant.version - ) >= pkg_version.parse(version) - except pkg_version.InvalidVersion: + return self.sys_homeassistant.version >= version + except (AwesomeVersionException, TypeError): return True def _image(self, config) -> str: diff --git a/supervisor/addons/validate.py b/supervisor/addons/validate.py index 6844e8ef8..06e319575 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -93,6 +93,7 @@ from ..const import ( from ..coresys import CoreSys from ..discovery.validate import valid_discovery_service from ..validate import ( + docker_image, docker_ports, docker_ports_description, network_port, @@ -144,7 +145,6 @@ _SCHEMA_LENGTH_PARTS = ( "p_max", ) -RE_DOCKER_IMAGE = re.compile(r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)$") RE_DOCKER_IMAGE_BUILD = re.compile( r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$" ) @@ -186,7 +186,7 @@ def _simple_startup(value) -> str: SCHEMA_ADDON_CONFIG = vol.Schema( { vol.Required(ATTR_NAME): vol.Coerce(str), - vol.Required(ATTR_VERSION): vol.All(version_tag, str), + vol.Required(ATTR_VERSION): version_tag, vol.Required(ATTR_SLUG): vol.Coerce(str), vol.Required(ATTR_DESCRIPTON): vol.Coerce(str), vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)], @@ -213,7 +213,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema( vol.Optional(ATTR_PANEL_ICON, default="mdi:puzzle"): vol.Coerce(str), vol.Optional(ATTR_PANEL_TITLE): vol.Coerce(str), vol.Optional(ATTR_PANEL_ADMIN, default=True): vol.Boolean(), - vol.Optional(ATTR_HOMEASSISTANT): vol.Maybe(vol.Coerce(str)), + vol.Optional(ATTR_HOMEASSISTANT): vol.Maybe(version_tag), vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(), @@ -267,7 +267,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema( ), False, ), - vol.Optional(ATTR_IMAGE): vol.Match(RE_DOCKER_IMAGE), + vol.Optional(ATTR_IMAGE): docker_image, vol.Optional(ATTR_TIMEOUT, default=10): vol.All( vol.Coerce(int), vol.Range(min=10, max=300) ), @@ -294,8 +294,8 @@ SCHEMA_BUILD_CONFIG = vol.Schema( # pylint: disable=no-value-for-parameter SCHEMA_ADDON_USER = vol.Schema( { - vol.Required(ATTR_VERSION): vol.Coerce(str), - vol.Optional(ATTR_IMAGE): vol.Coerce(str), + vol.Required(ATTR_VERSION): version_tag, + vol.Optional(ATTR_IMAGE): docker_image, vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): uuid_match, vol.Optional(ATTR_ACCESS_TOKEN): token, vol.Optional(ATTR_INGRESS_TOKEN, default=secrets.token_urlsafe): vol.Coerce( diff --git a/supervisor/api/utils.py b/supervisor/api/utils.py index 096e4db74..829daf49e 100644 --- a/supervisor/api/utils.py +++ b/supervisor/api/utils.py @@ -19,6 +19,7 @@ from ..const import ( ) from ..exceptions import APIError, APIForbidden, DockerAPIError, HassioError from ..utils import check_exception_chain, get_message_from_exception_chain +from ..utils.json import JSONEncoder from ..utils.log_format import format_message @@ -112,12 +113,16 @@ def api_return_error( JSON_MESSAGE: message or "Unknown error, see supervisor", }, status=400, + dumps=lambda x: json.dumps(x, cls=JSONEncoder), ) def api_return_ok(data: Optional[Dict[str, Any]] = None) -> web.Response: """Return an API ok answer.""" - return web.json_response({JSON_RESULT: RESULT_OK, JSON_DATA: data or {}}) + return web.json_response( + {JSON_RESULT: RESULT_OK, JSON_DATA: data or {}}, + dumps=lambda x: json.dumps(x, cls=JSONEncoder), + ) async def api_validate( diff --git a/supervisor/config.py b/supervisor/config.py index 0d821b9c4..0ae074e60 100644 --- a/supervisor/config.py +++ b/supervisor/config.py @@ -5,6 +5,8 @@ import os from pathlib import Path, PurePath from typing import List, Optional +from awesomeversion import AwesomeVersion + from .const import ( ATTR_ADDONS_CUSTOM_LIST, ATTR_DEBUG, @@ -64,12 +66,12 @@ class CoreConfig(JsonConfig): self._data[ATTR_TIMEZONE] = value @property - def version(self) -> str: + def version(self) -> AwesomeVersion: """Return config version.""" return self._data[ATTR_VERSION] @version.setter - def version(self, value: str) -> None: + def version(self, value: AwesomeVersion) -> None: """Set config version.""" self._data[ATTR_VERSION] = value diff --git a/supervisor/core.py b/supervisor/core.py index af9467770..bcd3c2a12 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -14,6 +14,7 @@ from .exceptions import ( HomeAssistantError, SupervisorUpdateError, ) +from .homeassistant.core import LANDINGPAGE from .resolution.const import ContextType, IssueType, UnhealthyReason _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -221,7 +222,7 @@ class Core(CoreSysAttributes): await self.sys_tasks.load() # If landingpage / run upgrade in background - if self.sys_homeassistant.version == "landingpage": + if self.sys_homeassistant.version == LANDINGPAGE: self.sys_create_task(self.sys_homeassistant.core.install()) # Start observe the host Hardware diff --git a/supervisor/docker/__init__.py b/supervisor/docker/__init__.py index d07ce6ada..d2249f7a8 100644 --- a/supervisor/docker/__init__.py +++ b/supervisor/docker/__init__.py @@ -6,8 +6,8 @@ from pathlib import Path from typing import Any, Dict, Optional import attr +from awesomeversion import AwesomeVersion import docker -from packaging import version as pkg_version import requests from ..const import ( @@ -40,22 +40,21 @@ class CommandReturn: class DockerInfo: """Return docker information.""" - version: str = attr.ib() + version: AwesomeVersion = attr.ib() storage: str = attr.ib() logging: str = attr.ib() @staticmethod def new(data: Dict[str, Any]): """Create a object from docker info.""" - return DockerInfo(data["ServerVersion"], data["Driver"], data["LoggingDriver"]) + return DockerInfo( + AwesomeVersion(data["ServerVersion"]), data["Driver"], data["LoggingDriver"] + ) @property def supported_version(self) -> bool: """Return true, if docker version is supported.""" - version_local = pkg_version.parse(self.version) - version_min = pkg_version.parse(MIN_SUPPORTED_DOCKER) - - return version_local >= version_min + return self.version >= MIN_SUPPORTED_DOCKER @property def inside_lxc(self) -> bool: @@ -114,7 +113,7 @@ class DockerAPI: def run( self, image: str, - version: str = "latest", + tag: str = "latest", dns: bool = True, ipv4: Optional[IPv4Address] = None, **kwargs: Any, @@ -140,7 +139,7 @@ class DockerAPI: # Create container try: container = self.docker.containers.create( - f"{image}:{version}", use_config_proxy=False, **kwargs + f"{image}:{tag}", use_config_proxy=False, **kwargs ) except docker.errors.NotFound as err: _LOGGER.error("Image %s not exists for %s", image, name) @@ -195,7 +194,7 @@ class DockerAPI: def run_command( self, image: str, - version: str = "latest", + tag: str = "latest", command: Optional[str] = None, **kwargs: Any, ) -> CommandReturn: @@ -210,7 +209,7 @@ class DockerAPI: container = None try: container = self.docker.containers.run( - f"{image}:{version}", + f"{image}:{tag}", command=command, network=self.network.name, use_config_proxy=False, diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index d0fd11547..ea16ca681 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -8,6 +8,7 @@ import os from pathlib import Path from typing import TYPE_CHECKING, Awaitable, Dict, List, Optional, Union +from awesomeversion import AwesomeVersion import docker import requests @@ -72,7 +73,7 @@ class DockerAddon(DockerInterface): return self.addon.timeout @property - def version(self) -> str: + def version(self) -> AwesomeVersion: """Return version of Docker image.""" if self.addon.legacy: return self.addon.version @@ -355,7 +356,7 @@ class DockerAddon(DockerInterface): # Create & Run container docker_container = self.sys_docker.run( self.image, - version=self.addon.version, + tag=self.addon.version.string, name=self.name, hostname=self.addon.hostname, detach=True, @@ -390,37 +391,37 @@ class DockerAddon(DockerInterface): self.sys_capture_exception(err) def _install( - self, tag: str, image: Optional[str] = None, latest: bool = False + self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False ) -> None: """Pull Docker image or build it. Need run inside executor. """ if self.addon.need_build: - self._build(tag) + self._build(version) else: - super()._install(tag, image, latest) + super()._install(version, image, latest) - def _build(self, tag: str) -> None: + def _build(self, version: AwesomeVersion) -> None: """Build a Docker container. Need run inside executor. """ build_env = AddonBuild(self.coresys, self.addon) - _LOGGER.info("Starting build for %s:%s", self.image, tag) + _LOGGER.info("Starting build for %s:%s", self.image, version) try: image, log = self.sys_docker.images.build( - use_config_proxy=False, **build_env.get_docker_args(tag) + use_config_proxy=False, **build_env.get_docker_args(version) ) - _LOGGER.debug("Build %s:%s done: %s", self.image, tag, log) + _LOGGER.debug("Build %s:%s done: %s", self.image, version, log) # Update meta data self._meta = image.attrs except (docker.errors.DockerException, requests.RequestException) as err: - _LOGGER.error("Can't build %s:%s: %s", self.image, tag, err) + _LOGGER.error("Can't build %s:%s: %s", self.image, version, err) if hasattr(err, "build_log"): log = "\n".join( [ @@ -432,7 +433,7 @@ class DockerAddon(DockerInterface): _LOGGER.error("Build log: \n%s", log) raise DockerError() from err - _LOGGER.info("Build %s:%s done", self.image, tag) + _LOGGER.info("Build %s:%s done", self.image, version) @process_lock def export_image(self, tar_file: Path) -> Awaitable[None]: diff --git a/supervisor/docker/audio.py b/supervisor/docker/audio.py index 2d88f7913..c3bdc4fb8 100644 --- a/supervisor/docker/audio.py +++ b/supervisor/docker/audio.py @@ -59,7 +59,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes): # Create & Run container docker_container = self.sys_docker.run( self.image, - version=self.sys_plugins.audio.version, + tag=self.sys_plugins.audio.version.string, init=False, ipv4=self.sys_docker.network.audio, name=self.name, diff --git a/supervisor/docker/cli.py b/supervisor/docker/cli.py index bf2716192..70e80384e 100644 --- a/supervisor/docker/cli.py +++ b/supervisor/docker/cli.py @@ -39,7 +39,7 @@ class DockerCli(DockerInterface, CoreSysAttributes): self.image, entrypoint=["/init"], command=["/bin/bash", "-c", "sleep infinity"], - version=self.sys_plugins.cli.version, + tag=self.sys_plugins.cli.version.string, init=False, ipv4=self.sys_docker.network.cli, name=self.name, diff --git a/supervisor/docker/dns.py b/supervisor/docker/dns.py index 0289c5cd6..76fe32cf2 100644 --- a/supervisor/docker/dns.py +++ b/supervisor/docker/dns.py @@ -37,7 +37,7 @@ class DockerDNS(DockerInterface, CoreSysAttributes): # Create & Run container docker_container = self.sys_docker.run( self.image, - version=self.sys_plugins.dns.version, + tag=self.sys_plugins.dns.version.string, init=False, dns=False, ipv4=self.sys_docker.network.dns, diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index f43aa59fd..2eda4822e 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -107,7 +107,7 @@ class DockerHomeAssistant(DockerInterface): # Create & Run container docker_container = self.sys_docker.run( self.image, - version=self.sys_homeassistant.version, + tag=self.sys_homeassistant.version.string, name=self.name, hostname=self.name, detach=True, diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 8bf16ed85..7aa4eebd9 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -5,8 +5,9 @@ import logging import re from typing import Any, Awaitable, Dict, List, Optional +from awesomeversion import AwesomeVersion +from awesomeversion.strategy import AwesomeVersionStrategy import docker -from packaging import version as pkg_version import requests from . import CommandReturn @@ -76,9 +77,11 @@ class DockerInterface(CoreSysAttributes): return None @property - def version(self) -> Optional[str]: + def version(self) -> Optional[AwesomeVersion]: """Return version of Docker image.""" - return self.meta_labels.get(LABEL_VERSION) + if LABEL_VERSION not in self.meta_labels: + return None + return AwesomeVersion(self.meta_labels[LABEL_VERSION]) @property def arch(self) -> Optional[str]: @@ -90,11 +93,6 @@ class DockerInterface(CoreSysAttributes): """Return True if a task is in progress.""" return self.lock.locked() - @process_lock - def install(self, tag: str, image: Optional[str] = None, latest: bool = False): - """Pull docker image.""" - return self.sys_run_in_executor(self._install, tag, image, latest) - def _get_credentials(self, image: str) -> dict: """Return a dictionay with credentials for docker login.""" registry = None @@ -135,8 +133,15 @@ class DockerInterface(CoreSysAttributes): self.sys_docker.docker.login(**credentials) + @process_lock + def install( + self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False + ): + """Pull docker image.""" + return self.sys_run_in_executor(self._install, version, image, latest) + def _install( - self, tag: str, image: Optional[str] = None, latest: bool = False + self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False ) -> None: """Pull Docker image. @@ -144,18 +149,20 @@ class DockerInterface(CoreSysAttributes): """ image = image or self.image - _LOGGER.info("Downloading docker image %s with tag %s.", image, tag) + _LOGGER.info("Downloading docker image %s with tag %s.", image, version) try: if self.sys_docker.config.registries: # Try login if we have defined credentials self._docker_login(image) - docker_image = self.sys_docker.images.pull(f"{image}:{tag}") + docker_image = self.sys_docker.images.pull(f"{image}:{version!s}") if latest: - _LOGGER.info("Tagging image %s with version %s as latest", image, tag) + _LOGGER.info( + "Tagging image %s with version %s as latest", image, version + ) docker_image.tag(image, tag="latest") except docker.errors.APIError as err: - _LOGGER.error("Can't install %s:%s -> %s.", image, tag, err) + _LOGGER.error("Can't install %s:%s -> %s.", image, version, err) if err.status_code == 429: self.sys_resolution.create_issue( IssueType.DOCKER_RATELIMIT, @@ -168,7 +175,7 @@ class DockerInterface(CoreSysAttributes): ) raise DockerError() from err except (docker.errors.DockerException, requests.RequestException) as err: - _LOGGER.error("Unknown error with %s:%s -> %s", image, tag, err) + _LOGGER.error("Unknown error with %s:%s -> %s", image, version, err) self.sys_capture_exception(err) raise DockerError() from err else: @@ -184,7 +191,7 @@ class DockerInterface(CoreSysAttributes): Need run inside executor. """ with suppress(docker.errors.DockerException, requests.RequestException): - self.sys_docker.images.get(f"{self.image}:{self.version}") + self.sys_docker.images.get(f"{self.image}:{self.version!s}") return True return False @@ -212,11 +219,11 @@ class DockerInterface(CoreSysAttributes): return docker_container.status == "running" @process_lock - def attach(self, tag: str): + def attach(self, version: AwesomeVersion): """Attach to running Docker container.""" - return self.sys_run_in_executor(self._attach, tag) + return self.sys_run_in_executor(self._attach, version) - def _attach(self, tag: str) -> None: + def _attach(self, version: AwesomeVersion) -> None: """Attach to running docker container. Need run inside executor. @@ -226,7 +233,9 @@ class DockerInterface(CoreSysAttributes): with suppress(docker.errors.DockerException, requests.RequestException): if not self._meta and self.image: - self._meta = self.sys_docker.images.get(f"{self.image}:{tag}").attrs + self._meta = self.sys_docker.images.get( + f"{self.image}:{version!s}" + ).attrs # Successfull? if not self._meta: @@ -317,7 +326,7 @@ class DockerInterface(CoreSysAttributes): with suppress(docker.errors.ImageNotFound): self.sys_docker.images.remove( - image=f"{self.image}:{self.version}", force=True + image=f"{self.image}:{self.version!s}", force=True ) except (docker.errors.DockerException, requests.RequestException) as err: @@ -328,13 +337,13 @@ class DockerInterface(CoreSysAttributes): @process_lock def update( - self, tag: str, image: Optional[str] = None, latest: bool = False + self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False ) -> Awaitable[None]: """Update a Docker image.""" - return self.sys_run_in_executor(self._update, tag, image, latest) + return self.sys_run_in_executor(self._update, version, image, latest) def _update( - self, tag: str, image: Optional[str] = None, latest: bool = False + self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False ) -> None: """Update a docker image. @@ -343,11 +352,11 @@ class DockerInterface(CoreSysAttributes): image = image or self.image _LOGGER.info( - "Updating image %s:%s to %s:%s", self.image, self.version, image, tag + "Updating image %s:%s to %s:%s", self.image, self.version, image, version ) # Update docker image - self._install(tag, image=image, latest=latest) + self._install(version, image=image, latest=latest) # Stop container & cleanup with suppress(DockerError): @@ -388,7 +397,7 @@ class DockerInterface(CoreSysAttributes): Need run inside executor. """ try: - origin = self.sys_docker.images.get(f"{self.image}:{self.version}") + origin = self.sys_docker.images.get(f"{self.image}:{self.version!s}") except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.warning("Can't find %s for cleanup", self.image) raise DockerError() from err @@ -504,23 +513,21 @@ class DockerInterface(CoreSysAttributes): # Check return value return int(docker_container.attrs["State"]["ExitCode"]) != 0 - def get_latest_version(self) -> Awaitable[str]: + def get_latest_version(self) -> Awaitable[AwesomeVersion]: """Return latest version of local image.""" return self.sys_run_in_executor(self._get_latest_version) - def _get_latest_version(self) -> str: + def _get_latest_version(self) -> AwesomeVersion: """Return latest version of local image. Need run inside executor. """ - available_version: List[str] = [] + available_version: List[AwesomeVersion] = [] try: for image in self.sys_docker.images.list(self.image): for tag in image.tags: - version = tag.partition(":")[2] - try: - pkg_version.parse(version) - except (TypeError, pkg_version.InvalidVersion): + version = AwesomeVersion(tag.partition(":")[2]) + if version.strategy == AwesomeVersionStrategy.UNKNOWN: continue available_version.append(version) @@ -537,5 +544,5 @@ class DockerInterface(CoreSysAttributes): _LOGGER.info("Found %s versions: %s", self.image, available_version) # Sort version and return latest version - available_version.sort(key=pkg_version.parse, reverse=True) + available_version.sort(reverse=True) return available_version[0] diff --git a/supervisor/docker/multicast.py b/supervisor/docker/multicast.py index f44fc9b63..3a241f617 100644 --- a/supervisor/docker/multicast.py +++ b/supervisor/docker/multicast.py @@ -37,7 +37,7 @@ class DockerMulticast(DockerInterface, CoreSysAttributes): # Create & Run container docker_container = self.sys_docker.run( self.image, - version=self.sys_plugins.multicast.version, + tag=self.sys_plugins.multicast.version.string, init=False, name=self.name, hostname=self.name.replace("_", "-"), diff --git a/supervisor/docker/observer.py b/supervisor/docker/observer.py index c28f1b18c..87a43d736 100644 --- a/supervisor/docker/observer.py +++ b/supervisor/docker/observer.py @@ -38,7 +38,7 @@ class DockerObserver(DockerInterface, CoreSysAttributes): # Create & Run container docker_container = self.sys_docker.run( self.image, - version=self.sys_plugins.observer.version, + tag=self.sys_plugins.observer.version.string, init=False, ipv4=self.sys_docker.network.observer, name=self.name, diff --git a/supervisor/docker/supervisor.py b/supervisor/docker/supervisor.py index eca9500cf..d48584246 100644 --- a/supervisor/docker/supervisor.py +++ b/supervisor/docker/supervisor.py @@ -4,6 +4,7 @@ import logging import os from typing import Awaitable +from awesomeversion.awesomeversion import AwesomeVersion import docker import requests @@ -32,7 +33,7 @@ class DockerSupervisor(DockerInterface, CoreSysAttributes): """Return True if the container run with Privileged.""" return self.meta_host.get("Privileged", False) - def _attach(self, tag: str) -> None: + def _attach(self, version: AwesomeVersion) -> None: """Attach to running docker container. Need run inside executor. @@ -73,24 +74,24 @@ class DockerSupervisor(DockerInterface, CoreSysAttributes): try: docker_container = self.sys_docker.containers.get(self.name) - docker_container.image.tag(self.image, tag=self.version) + docker_container.image.tag(self.image, tag=self.version.string) docker_container.image.tag(self.image, tag="latest") except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't retag Supervisor version: %s", err) raise DockerError() from err - def update_start_tag(self, image: str, version: str) -> Awaitable[None]: + def update_start_tag(self, image: str, version: AwesomeVersion) -> Awaitable[None]: """Update start tag to new version.""" return self.sys_run_in_executor(self._update_start_tag, image, version) - def _update_start_tag(self, image: str, version: str) -> None: + def _update_start_tag(self, image: str, version: AwesomeVersion) -> None: """Update start tag to new version. Need run inside executor. """ try: docker_container = self.sys_docker.containers.get(self.name) - docker_image = self.sys_docker.images.get(f"{image}:{version}") + docker_image = self.sys_docker.images.get(f"{image}:{version!s}") # Find start tag for tag in docker_container.image.tags: diff --git a/supervisor/hassos.py b/supervisor/hassos.py index 3e9503d06..6e0d2a74f 100644 --- a/supervisor/hassos.py +++ b/supervisor/hassos.py @@ -5,8 +5,8 @@ from pathlib import Path from typing import Awaitable, Optional import aiohttp +from awesomeversion import AwesomeVersion, AwesomeVersionException from cpe import CPE -from packaging.version import parse as pkg_parse from .coresys import CoreSys, CoreSysAttributes from .dbus.rauc import RaucState @@ -24,7 +24,7 @@ class HassOS(CoreSysAttributes): self.coresys: CoreSys = coresys self.lock: asyncio.Lock = asyncio.Lock() self._available: bool = False - self._version: Optional[str] = None + self._version: Optional[AwesomeVersion] = None self._board: Optional[str] = None @property @@ -33,12 +33,12 @@ class HassOS(CoreSysAttributes): return self._available @property - def version(self) -> Optional[str]: + def version(self) -> Optional[AwesomeVersion]: """Return version of HassOS.""" return self._version @property - def latest_version(self) -> str: + def latest_version(self) -> Optional[AwesomeVersion]: """Return version of HassOS.""" return self.sys_updater.version_hassos @@ -46,8 +46,8 @@ class HassOS(CoreSysAttributes): def need_update(self) -> bool: """Return true if a HassOS update is available.""" try: - return pkg_parse(self.version) < pkg_parse(self.latest_version) - except (TypeError, ValueError): + return self.version < self.latest_version + except (AwesomeVersionException, TypeError): return False @property @@ -61,16 +61,16 @@ class HassOS(CoreSysAttributes): _LOGGER.error("No Home Assistant Operating System available") raise HassOSNotSupportedError() - async def _download_raucb(self, version: str) -> Path: + async def _download_raucb(self, version: AwesomeVersion) -> Path: """Download rauc bundle (OTA) from github.""" raw_url = self.sys_updater.ota_url if raw_url is None: _LOGGER.error("Don't have an URL for OTA updates!") raise HassOSNotSupportedError() - url = raw_url.format(version=version, board=self.board) + url = raw_url.format(version=version.string, board=self.board) _LOGGER.info("Fetch OTA update from %s", url) - raucb = Path(self.sys_config.path_tmp, f"hassos-{version}.raucb") + raucb = Path(self.sys_config.path_tmp, f"hassos-{version.string}.raucb") try: timeout = aiohttp.ClientTimeout(total=60 * 60, connect=180) async with self.sys_websession.get(url, timeout=timeout) as request: @@ -113,7 +113,7 @@ class HassOS(CoreSysAttributes): self.sys_host.supported_features.cache_clear() # Store meta data - self._version = cpe.get_version()[0] + self._version = AwesomeVersion(cpe.get_version()[0]) self._board = cpe.get_target_hardware()[0] await self.sys_dbus.rauc.update() @@ -135,7 +135,7 @@ class HassOS(CoreSysAttributes): return self.sys_host.services.restart("hassos-config.service") @process_lock - async def update(self, version: Optional[str] = None) -> None: + async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update HassOS system.""" version = version or self.latest_version diff --git a/supervisor/homeassistant/__init__.py b/supervisor/homeassistant/__init__.py index b11a12a2c..778de1a78 100644 --- a/supervisor/homeassistant/__init__.py +++ b/supervisor/homeassistant/__init__.py @@ -7,6 +7,8 @@ import shutil from typing import Optional from uuid import UUID +from awesomeversion import AwesomeVersion, AwesomeVersionException + from ..const import ( ATTR_ACCESS_TOKEN, ATTR_AUDIO_INPUT, @@ -126,7 +128,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): self._data[ATTR_WAIT_BOOT] = value @property - def latest_version(self) -> str: + def latest_version(self) -> Optional[AwesomeVersion]: """Return last available version of Home Assistant.""" return self.sys_updater.version_homeassistant @@ -143,12 +145,12 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): self._data[ATTR_IMAGE] = value @property - def version(self) -> Optional[str]: + def version(self) -> Optional[AwesomeVersion]: """Return version of local version.""" return self._data.get(ATTR_VERSION) @version.setter - def version(self, value: str) -> None: + def version(self, value: AwesomeVersion) -> None: """Set installed version.""" self._data[ATTR_VERSION] = value @@ -220,9 +222,10 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): @property def need_update(self) -> bool: """Return true if a Home Assistant update is available.""" - if not self.latest_version: + try: + return self.version != self.latest_version + except (AwesomeVersionException, TypeError): return False - return self.version != self.latest_version async def load(self) -> None: """Prepare Home Assistant object.""" diff --git a/supervisor/homeassistant/core.py b/supervisor/homeassistant/core.py index 61c80ee88..63f2a2eda 100644 --- a/supervisor/homeassistant/core.py +++ b/supervisor/homeassistant/core.py @@ -10,7 +10,7 @@ import time from typing import Awaitable, Optional import attr -from packaging import version as pkg_version +from awesomeversion import AwesomeVersion, AwesomeVersionException from ..coresys import CoreSys, CoreSysAttributes from ..docker.homeassistant import DockerHomeAssistant @@ -30,7 +30,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") -LANDINGPAGE: str = "landingpage" +LANDINGPAGE: AwesomeVersion = AwesomeVersion("landingpage") @attr.s(frozen=True) @@ -65,7 +65,7 @@ class HomeAssistantCore(CoreSysAttributes): await self.instance.get_latest_version() ) - await self.instance.attach(tag=self.sys_homeassistant.version) + await self.instance.attach(version=self.sys_homeassistant.version) except DockerError: _LOGGER.info( "No Home Assistant Docker image %s found.", self.sys_homeassistant.image @@ -122,11 +122,11 @@ class HomeAssistantCore(CoreSysAttributes): if not self.sys_homeassistant.latest_version: await self.sys_updater.reload() - tag = self.sys_homeassistant.latest_version - if tag: + if self.sys_homeassistant.latest_version: try: await self.instance.update( - tag, image=self.sys_updater.image_homeassistant + self.sys_homeassistant.latest_version, + image=self.sys_updater.image_homeassistant, ) break except DockerError: @@ -162,7 +162,7 @@ class HomeAssistantCore(CoreSysAttributes): ], on_condition=HomeAssistantJobError, ) - async def update(self, version: Optional[str] = None) -> None: + async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update HomeAssistant version.""" version = version or self.sys_homeassistant.latest_version old_image = self.sys_homeassistant.image @@ -175,7 +175,7 @@ class HomeAssistantCore(CoreSysAttributes): return # process an update - async def _update(to_version: str) -> None: + async def _update(to_version: AwesomeVersion) -> None: """Run Home Assistant update.""" _LOGGER.info("Updating Home Assistant to version %s", to_version) try: @@ -348,7 +348,7 @@ class HomeAssistantCore(CoreSysAttributes): _LOGGER.info("Home Assistant config is valid") return ConfigResult(True, log) - async def _block_till_run(self, version: str) -> None: + async def _block_till_run(self, version: AwesomeVersion) -> None: """Block until Home-Assistant is booting up or startup timeout.""" # Skip landingpage if version == LANDINGPAGE: @@ -358,9 +358,9 @@ class HomeAssistantCore(CoreSysAttributes): # Manage timeouts timeout: bool = True start_time = time.monotonic() - with suppress(pkg_version.InvalidVersion): + with suppress(AwesomeVersionException): # Version provide early stage UI - if pkg_version.parse(version) >= pkg_version.parse("0.112.0"): + if version >= AwesomeVersion("0.112.0"): _LOGGER.debug("Disable startup timeouts - early UI") timeout = False diff --git a/supervisor/plugins/__init__.py b/supervisor/plugins/__init__.py index 07d7b3593..0ce0af865 100644 --- a/supervisor/plugins/__init__.py +++ b/supervisor/plugins/__init__.py @@ -5,11 +5,11 @@ import logging from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import HassioError from ..resolution.const import ContextType, IssueType, SuggestionType -from .audio import Audio -from .cli import HaCli -from .dns import CoreDNS -from .multicast import Multicast -from .observer import Observer +from .audio import PluginAudio +from .cli import PluginCli +from .dns import PluginDns +from .multicast import PluginMulticast +from .observer import PluginObserver _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -21,34 +21,34 @@ class PluginManager(CoreSysAttributes): """Initialize plugin manager.""" self.coresys: CoreSys = coresys - self._cli: HaCli = HaCli(coresys) - self._dns: CoreDNS = CoreDNS(coresys) - self._audio: Audio = Audio(coresys) - self._observer: Observer = Observer(coresys) - self._multicast: Multicast = Multicast(coresys) + self._cli: PluginCli = PluginCli(coresys) + self._dns: PluginDns = PluginDns(coresys) + self._audio: PluginAudio = PluginAudio(coresys) + self._observer: PluginObserver = PluginObserver(coresys) + self._multicast: PluginMulticast = PluginMulticast(coresys) @property - def cli(self) -> HaCli: + def cli(self) -> PluginCli: """Return cli handler.""" return self._cli @property - def dns(self) -> CoreDNS: + def dns(self) -> PluginDns: """Return dns handler.""" return self._dns @property - def audio(self) -> Audio: + def audio(self) -> PluginAudio: """Return audio handler.""" return self._audio @property - def observer(self) -> Observer: + def observer(self) -> PluginObserver: """Return observer handler.""" return self._observer @property - def multicast(self) -> Multicast: + def multicast(self) -> PluginMulticast: """Return multicast handler.""" return self._multicast diff --git a/supervisor/plugins/audio.py b/supervisor/plugins/audio.py index 5fcede2ba..9071e7f10 100644 --- a/supervisor/plugins/audio.py +++ b/supervisor/plugins/audio.py @@ -9,15 +9,14 @@ from pathlib import Path, PurePath import shutil from typing import Awaitable, Optional +from awesomeversion import AwesomeVersion import jinja2 -from packaging.version import parse as pkg_parse -from ..const import ATTR_IMAGE, ATTR_VERSION -from ..coresys import CoreSys, CoreSysAttributes +from ..coresys import CoreSys from ..docker.audio import DockerAudio from ..docker.stats import DockerStats from ..exceptions import AudioError, AudioUpdateError, DockerError -from ..utils.json import JsonConfig +from .base import PluginBase from .const import FILE_HASSIO_AUDIO from .validate import SCHEMA_AUDIO_CONFIG @@ -27,14 +26,13 @@ PULSE_CLIENT_TMPL: Path = Path(__file__).parents[1].joinpath("data/pulse-client. ASOUND_TMPL: Path = Path(__file__).parents[1].joinpath("data/asound.tmpl") -class Audio(JsonConfig, CoreSysAttributes): +class PluginAudio(PluginBase): """Home Assistant core object for handle audio.""" - slug: str = "audio" - def __init__(self, coresys: CoreSys): """Initialize hass object.""" super().__init__(FILE_HASSIO_AUDIO, SCHEMA_AUDIO_CONFIG) + self.slug = "audio" self.coresys: CoreSys = coresys self.instance: DockerAudio = DockerAudio(coresys) self.client_template: Optional[jinja2.Template] = None @@ -50,29 +48,7 @@ class Audio(JsonConfig, CoreSysAttributes): return self.sys_config.path_extern_audio.joinpath("asound") @property - def version(self) -> Optional[str]: - """Return current version of Audio.""" - return self._data.get(ATTR_VERSION) - - @version.setter - def version(self, value: str) -> None: - """Set current version of Audio.""" - self._data[ATTR_VERSION] = value - - @property - def image(self) -> str: - """Return current image of Audio.""" - if self._data.get(ATTR_IMAGE): - return self._data[ATTR_IMAGE] - return f"homeassistant/{self.sys_arch.supervisor}-hassio-audio" - - @image.setter - def image(self, value: str) -> None: - """Return current image of Audio.""" - self._data[ATTR_IMAGE] = value - - @property - def latest_version(self) -> Optional[str]: + def latest_version(self) -> Optional[AwesomeVersion]: """Return latest version of Audio.""" return self.sys_updater.version_audio @@ -81,14 +57,6 @@ class Audio(JsonConfig, CoreSysAttributes): """Return True if a task is in progress.""" return self.instance.in_progress - @property - def need_update(self) -> bool: - """Return True if an update is available.""" - try: - return pkg_parse(self.version) < pkg_parse(self.latest_version) - except (TypeError, ValueError): - return False - async def load(self) -> None: """Load Audio setup.""" # Initialize Client Template @@ -103,7 +71,7 @@ class Audio(JsonConfig, CoreSysAttributes): if not self.version: self.version = await self.instance.get_latest_version() - await self.instance.attach(tag=self.version) + await self.instance.attach(version=self.version) except DockerError: _LOGGER.info("No Audio plugin Docker image %s found.", self.instance.image) diff --git a/supervisor/plugins/base.py b/supervisor/plugins/base.py new file mode 100644 index 000000000..8c3b02128 --- /dev/null +++ b/supervisor/plugins/base.py @@ -0,0 +1,66 @@ +"""Supervisor plugins base class.""" +from abc import ABC, abstractmethod, abstractproperty +from typing import Optional + +from awesomeversion import AwesomeVersion, AwesomeVersionException + +from ..const import ATTR_IMAGE, ATTR_VERSION +from ..coresys import CoreSysAttributes +from ..utils.json import JsonConfig + + +class PluginBase(ABC, JsonConfig, CoreSysAttributes): + """Base class for plugins.""" + + slug: str = "" + + @property + def version(self) -> Optional[AwesomeVersion]: + """Return current version of the plugin.""" + return self._data.get(ATTR_VERSION) + + @version.setter + def version(self, value: AwesomeVersion) -> None: + """Set current version of the plugin.""" + self._data[ATTR_VERSION] = value + + @property + def image(self) -> str: + """Return current image of plugin.""" + if self._data.get(ATTR_IMAGE): + return self._data[ATTR_IMAGE] + return f"homeassistant/{self.sys_arch.supervisor}-hassio-{self.slug}" + + @image.setter + def image(self, value: str) -> None: + """Return current image of the plugin.""" + self._data[ATTR_IMAGE] = value + + @property + @abstractproperty + def latest_version(self) -> Optional[AwesomeVersion]: + """Return latest version of the plugin.""" + + @property + def need_update(self) -> bool: + """Return True if an update is available.""" + try: + return self.version < self.latest_version + except (AwesomeVersionException, TypeError): + return False + + @abstractmethod + async def load(self) -> None: + """Load system plugin.""" + + @abstractmethod + async def install(self) -> None: + """Install system plugin.""" + + @abstractmethod + async def update(self, version: Optional[str] = None) -> None: + """Update system plugin.""" + + @abstractmethod + async def repair(self) -> None: + """Repair system plugin.""" diff --git a/supervisor/plugins/cli.py b/supervisor/plugins/cli.py index 8785effe2..e373b74ea 100644 --- a/supervisor/plugins/cli.py +++ b/supervisor/plugins/cli.py @@ -8,66 +8,35 @@ import logging import secrets from typing import Awaitable, Optional -from packaging.version import parse as pkg_parse +from awesomeversion import AwesomeVersion -from ..const import ATTR_ACCESS_TOKEN, ATTR_IMAGE, ATTR_VERSION -from ..coresys import CoreSys, CoreSysAttributes +from ..const import ATTR_ACCESS_TOKEN +from ..coresys import CoreSys from ..docker.cli import DockerCli from ..docker.stats import DockerStats from ..exceptions import CliError, CliUpdateError, DockerError -from ..utils.json import JsonConfig +from .base import PluginBase from .const import FILE_HASSIO_CLI from .validate import SCHEMA_CLI_CONFIG _LOGGER: logging.Logger = logging.getLogger(__name__) -class HaCli(CoreSysAttributes, JsonConfig): +class PluginCli(PluginBase): """HA cli interface inside supervisor.""" - slug: str = "cli" - def __init__(self, coresys: CoreSys): """Initialize cli handler.""" super().__init__(FILE_HASSIO_CLI, SCHEMA_CLI_CONFIG) + self.slug = "cli" self.coresys: CoreSys = coresys self.instance: DockerCli = DockerCli(coresys) @property - def version(self) -> Optional[str]: - """Return version of cli.""" - return self._data.get(ATTR_VERSION) - - @version.setter - def version(self, value: str) -> None: - """Set current version of cli.""" - self._data[ATTR_VERSION] = value - - @property - def image(self) -> str: - """Return current image of cli.""" - if self._data.get(ATTR_IMAGE): - return self._data[ATTR_IMAGE] - return f"homeassistant/{self.sys_arch.supervisor}-hassio-cli" - - @image.setter - def image(self, value: str) -> None: - """Return current image of cli.""" - self._data[ATTR_IMAGE] = value - - @property - def latest_version(self) -> str: + def latest_version(self) -> Optional[AwesomeVersion]: """Return version of latest cli.""" return self.sys_updater.version_cli - @property - def need_update(self) -> bool: - """Return true if a cli update is available.""" - try: - return pkg_parse(self.version) < pkg_parse(self.latest_version) - except (TypeError, ValueError): - return False - @property def supervisor_token(self) -> str: """Return an access token for the Supervisor API.""" @@ -86,7 +55,7 @@ class HaCli(CoreSysAttributes, JsonConfig): if not self.version: self.version = await self.instance.get_latest_version() - await self.instance.attach(tag=self.version) + await self.instance.attach(version=self.version) except DockerError: _LOGGER.info("No cli plugin Docker image %s found.", self.instance.image) @@ -126,7 +95,7 @@ class HaCli(CoreSysAttributes, JsonConfig): self.image = self.sys_updater.image_cli self.save_data() - async def update(self, version: Optional[str] = None) -> None: + async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update local HA cli.""" version = version or self.latest_version old_image = self.image diff --git a/supervisor/plugins/dns.py b/supervisor/plugins/dns.py index 1f9b3ec8c..24ee7e1ca 100644 --- a/supervisor/plugins/dns.py +++ b/supervisor/plugins/dns.py @@ -10,18 +10,19 @@ from pathlib import Path from typing import Awaitable, List, Optional import attr +from awesomeversion import AwesomeVersion import jinja2 -from packaging.version import parse as pkg_parse import voluptuous as vol -from ..const import ATTR_IMAGE, ATTR_SERVERS, ATTR_VERSION, DNS_SUFFIX, LogLevel -from ..coresys import CoreSys, CoreSysAttributes +from ..const import ATTR_SERVERS, DNS_SUFFIX, LogLevel +from ..coresys import CoreSys from ..docker.dns import DockerDNS from ..docker.stats import DockerStats from ..exceptions import CoreDNSError, CoreDNSUpdateError, DockerError, JsonFileError from ..resolution.const import ContextType, IssueType, SuggestionType -from ..utils.json import JsonConfig, write_json_file +from ..utils.json import write_json_file from ..validate import dns_url +from .base import PluginBase from .const import FILE_HASSIO_DNS from .validate import SCHEMA_DNS_CONFIG @@ -40,14 +41,13 @@ class HostEntry: names: List[str] = attr.ib() -class CoreDNS(JsonConfig, CoreSysAttributes): +class PluginDns(PluginBase): """Home Assistant core object for handle it.""" - slug: str = "dns" - def __init__(self, coresys: CoreSys): """Initialize hass object.""" super().__init__(FILE_HASSIO_DNS, SCHEMA_DNS_CONFIG) + self.slug = "dns" self.coresys: CoreSys = coresys self.instance: DockerDNS = DockerDNS(coresys) self.resolv_template: Optional[jinja2.Template] = None @@ -89,29 +89,7 @@ class CoreDNS(JsonConfig, CoreSysAttributes): self._data[ATTR_SERVERS] = value @property - def version(self) -> Optional[str]: - """Return current version of DNS.""" - return self._data.get(ATTR_VERSION) - - @version.setter - def version(self, value: str) -> None: - """Return current version of DNS.""" - self._data[ATTR_VERSION] = value - - @property - def image(self) -> str: - """Return current image of DNS.""" - if self._data.get(ATTR_IMAGE): - return self._data[ATTR_IMAGE] - return f"homeassistant/{self.sys_arch.supervisor}-hassio-dns" - - @image.setter - def image(self, value: str) -> None: - """Return current image of DNS.""" - self._data[ATTR_IMAGE] = value - - @property - def latest_version(self) -> Optional[str]: + def latest_version(self) -> Optional[AwesomeVersion]: """Return latest version of CoreDNS.""" return self.sys_updater.version_dns @@ -120,14 +98,6 @@ class CoreDNS(JsonConfig, CoreSysAttributes): """Return True if a task is in progress.""" return self.instance.in_progress - @property - def need_update(self) -> bool: - """Return True if an update is available.""" - try: - return pkg_parse(self.version) < pkg_parse(self.latest_version) - except (TypeError, ValueError): - return False - async def load(self) -> None: """Load DNS setup.""" # Initialize CoreDNS Template @@ -147,7 +117,7 @@ class CoreDNS(JsonConfig, CoreSysAttributes): if not self.version: self.version = await self.instance.get_latest_version() - await self.instance.attach(tag=self.version) + await self.instance.attach(version=self.version) except DockerError: _LOGGER.info( "No CoreDNS plugin Docker image %s found.", self.instance.image @@ -194,7 +164,7 @@ class CoreDNS(JsonConfig, CoreSysAttributes): # Init Hosts self.write_hosts() - async def update(self, version: Optional[str] = None) -> None: + async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update CoreDNS plugin.""" version = version or self.latest_version old_image = self.image diff --git a/supervisor/plugins/multicast.py b/supervisor/plugins/multicast.py index 7d3c6bded..33ff0dffd 100644 --- a/supervisor/plugins/multicast.py +++ b/supervisor/plugins/multicast.py @@ -7,55 +7,31 @@ from contextlib import suppress import logging from typing import Awaitable, Optional -from packaging.version import parse as pkg_parse +from awesomeversion import AwesomeVersion -from ..const import ATTR_IMAGE, ATTR_VERSION -from ..coresys import CoreSys, CoreSysAttributes +from ..coresys import CoreSys from ..docker.multicast import DockerMulticast from ..docker.stats import DockerStats from ..exceptions import DockerError, MulticastError, MulticastUpdateError -from ..utils.json import JsonConfig +from .base import PluginBase from .const import FILE_HASSIO_MULTICAST from .validate import SCHEMA_MULTICAST_CONFIG _LOGGER: logging.Logger = logging.getLogger(__name__) -class Multicast(JsonConfig, CoreSysAttributes): +class PluginMulticast(PluginBase): """Home Assistant core object for handle it.""" - slug: str = "multicast" - def __init__(self, coresys: CoreSys): """Initialize hass object.""" super().__init__(FILE_HASSIO_MULTICAST, SCHEMA_MULTICAST_CONFIG) + self.slug = "multicast" self.coresys: CoreSys = coresys self.instance: DockerMulticast = DockerMulticast(coresys) @property - def version(self) -> Optional[str]: - """Return current version of Multicast.""" - return self._data.get(ATTR_VERSION) - - @version.setter - def version(self, value: str) -> None: - """Return current version of Multicast.""" - self._data[ATTR_VERSION] = value - - @property - def image(self) -> str: - """Return current image of Multicast.""" - if self._data.get(ATTR_IMAGE): - return self._data[ATTR_IMAGE] - return f"homeassistant/{self.sys_arch.supervisor}-hassio-multicast" - - @image.setter - def image(self, value: str) -> None: - """Return current image of Multicast.""" - self._data[ATTR_IMAGE] = value - - @property - def latest_version(self) -> Optional[str]: + def latest_version(self) -> Optional[AwesomeVersion]: """Return latest version of Multicast.""" return self.sys_updater.version_multicast @@ -64,14 +40,6 @@ class Multicast(JsonConfig, CoreSysAttributes): """Return True if a task is in progress.""" return self.instance.in_progress - @property - def need_update(self) -> bool: - """Return True if an update is available.""" - try: - return pkg_parse(self.version) < pkg_parse(self.latest_version) - except (TypeError, ValueError): - return False - async def load(self) -> None: """Load multicast setup.""" # Check Multicast state @@ -80,7 +48,7 @@ class Multicast(JsonConfig, CoreSysAttributes): if not self.version: self.version = await self.instance.get_latest_version() - await self.instance.attach(tag=self.version) + await self.instance.attach(version=self.version) except DockerError: _LOGGER.info( "No Multicast plugin Docker image %s found.", self.instance.image @@ -121,7 +89,7 @@ class Multicast(JsonConfig, CoreSysAttributes): self.image = self.sys_updater.image_multicast self.save_data() - async def update(self, version: Optional[str] = None) -> None: + async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update Multicast plugin.""" version = version or self.latest_version old_image = self.image diff --git a/supervisor/plugins/observer.py b/supervisor/plugins/observer.py index 7d4725ae6..4dda3c5b1 100644 --- a/supervisor/plugins/observer.py +++ b/supervisor/plugins/observer.py @@ -9,66 +9,35 @@ import secrets from typing import Awaitable, Optional import aiohttp -from packaging.version import parse as pkg_parse +from awesomeversion import AwesomeVersion -from ..const import ATTR_ACCESS_TOKEN, ATTR_IMAGE, ATTR_VERSION -from ..coresys import CoreSys, CoreSysAttributes +from ..const import ATTR_ACCESS_TOKEN +from ..coresys import CoreSys from ..docker.observer import DockerObserver from ..docker.stats import DockerStats from ..exceptions import DockerError, ObserverError, ObserverUpdateError -from ..utils.json import JsonConfig +from .base import PluginBase from .const import FILE_HASSIO_OBSERVER from .validate import SCHEMA_OBSERVER_CONFIG _LOGGER: logging.Logger = logging.getLogger(__name__) -class Observer(CoreSysAttributes, JsonConfig): +class PluginObserver(PluginBase): """Supervisor observer instance.""" - slug: str = "observer" - def __init__(self, coresys: CoreSys): """Initialize observer handler.""" super().__init__(FILE_HASSIO_OBSERVER, SCHEMA_OBSERVER_CONFIG) + self.slug = "observer" self.coresys: CoreSys = coresys self.instance: DockerObserver = DockerObserver(coresys) @property - def version(self) -> Optional[str]: - """Return version of observer.""" - return self._data.get(ATTR_VERSION) - - @version.setter - def version(self, value: str) -> None: - """Set current version of observer.""" - self._data[ATTR_VERSION] = value - - @property - def image(self) -> str: - """Return current image of observer.""" - if self._data.get(ATTR_IMAGE): - return self._data[ATTR_IMAGE] - return f"homeassistant/{self.sys_arch.supervisor}-hassio-observer" - - @image.setter - def image(self, value: str) -> None: - """Return current image of observer.""" - self._data[ATTR_IMAGE] = value - - @property - def latest_version(self) -> str: + def latest_version(self) -> Optional[AwesomeVersion]: """Return version of latest observer.""" return self.sys_updater.version_observer - @property - def need_update(self) -> bool: - """Return true if a observer update is available.""" - try: - return pkg_parse(self.version) < pkg_parse(self.latest_version) - except (TypeError, ValueError): - return False - @property def supervisor_token(self) -> str: """Return an access token for the Observer API.""" @@ -87,7 +56,7 @@ class Observer(CoreSysAttributes, JsonConfig): if not self.version: self.version = await self.instance.get_latest_version() - await self.instance.attach(tag=self.version) + await self.instance.attach(version=self.version) except DockerError: _LOGGER.info( "No observer plugin Docker image %s found.", self.instance.image @@ -128,7 +97,7 @@ class Observer(CoreSysAttributes, JsonConfig): self.image = self.sys_updater.image_observer self.save_data() - async def update(self, version: Optional[str] = None) -> None: + async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update local HA observer.""" version = version or self.latest_version old_image = self.image diff --git a/supervisor/supervisor.py b/supervisor/supervisor.py index c2f97e3a7..fe7329d75 100644 --- a/supervisor/supervisor.py +++ b/supervisor/supervisor.py @@ -9,7 +9,7 @@ from typing import Awaitable, Optional import aiohttp from aiohttp.client_exceptions import ClientError -from packaging.version import parse as pkg_parse +from awesomeversion import AwesomeVersion, AwesomeVersionException from supervisor.jobs.decorator import Job, JobCondition @@ -41,7 +41,7 @@ class Supervisor(CoreSysAttributes): async def load(self) -> None: """Prepare Home Assistant object.""" try: - await self.instance.attach(tag="latest") + await self.instance.attach(version=self.version) except DockerError: _LOGGER.critical("Can't setup Supervisor Docker container!") @@ -65,17 +65,17 @@ class Supervisor(CoreSysAttributes): return False try: - return pkg_parse(self.version) < pkg_parse(self.latest_version) - except (TypeError, ValueError): + return self.version < self.latest_version + except (AwesomeVersionException, TypeError): return False @property - def version(self) -> str: + def version(self) -> AwesomeVersion: """Return version of running Home Assistant.""" - return SUPERVISOR_VERSION + return AwesomeVersion(SUPERVISOR_VERSION) @property - def latest_version(self) -> str: + def latest_version(self) -> AwesomeVersion: """Return last available version of Home Assistant.""" return self.sys_updater.version_supervisor @@ -117,7 +117,7 @@ class Supervisor(CoreSysAttributes): _LOGGER.error("Can't update AppArmor profile!") raise SupervisorError() from err - async def update(self, version: Optional[str] = None) -> None: + async def update(self, version: Optional[AwesomeVersion] = None) -> None: """Update Home Assistant version.""" version = version or self.latest_version diff --git a/supervisor/updater.py b/supervisor/updater.py index ae65c8ea1..bea8267df 100644 --- a/supervisor/updater.py +++ b/supervisor/updater.py @@ -7,6 +7,7 @@ import logging from typing import Optional import aiohttp +from awesomeversion import AwesomeVersion from .const import ( ATTR_AUDIO, @@ -53,42 +54,42 @@ class Updater(JsonConfig, CoreSysAttributes): await self.fetch_data() @property - def version_homeassistant(self) -> Optional[str]: + def version_homeassistant(self) -> Optional[AwesomeVersion]: """Return latest version of Home Assistant.""" return self._data.get(ATTR_HOMEASSISTANT) @property - def version_supervisor(self) -> Optional[str]: + def version_supervisor(self) -> Optional[AwesomeVersion]: """Return latest version of Supervisor.""" return self._data.get(ATTR_SUPERVISOR) @property - def version_hassos(self) -> Optional[str]: + def version_hassos(self) -> Optional[AwesomeVersion]: """Return latest version of HassOS.""" return self._data.get(ATTR_HASSOS) @property - def version_cli(self) -> Optional[str]: + def version_cli(self) -> Optional[AwesomeVersion]: """Return latest version of CLI.""" return self._data.get(ATTR_CLI) @property - def version_dns(self) -> Optional[str]: + def version_dns(self) -> Optional[AwesomeVersion]: """Return latest version of DNS.""" return self._data.get(ATTR_DNS) @property - def version_audio(self) -> Optional[str]: + def version_audio(self) -> Optional[AwesomeVersion]: """Return latest version of Audio.""" return self._data.get(ATTR_AUDIO) @property - def version_observer(self) -> Optional[str]: + def version_observer(self) -> Optional[AwesomeVersion]: """Return latest version of Observer.""" return self._data.get(ATTR_OBSERVER) @property - def version_multicast(self) -> Optional[str]: + def version_multicast(self) -> Optional[AwesomeVersion]: """Return latest version of Multicast.""" return self._data.get(ATTR_MULTICAST) @@ -197,22 +198,26 @@ class Updater(JsonConfig, CoreSysAttributes): try: # Update supervisor version - self._data[ATTR_SUPERVISOR] = data["supervisor"] + self._data[ATTR_SUPERVISOR] = AwesomeVersion(data["supervisor"]) # Update Home Assistant core version - self._data[ATTR_HOMEASSISTANT] = data["homeassistant"][machine] + self._data[ATTR_HOMEASSISTANT] = AwesomeVersion( + data["homeassistant"][machine] + ) # Update HassOS version if self.sys_hassos.board: - self._data[ATTR_HASSOS] = data["hassos"][self.sys_hassos.board] + self._data[ATTR_HASSOS] = AwesomeVersion( + data["hassos"][self.sys_hassos.board] + ) self._data[ATTR_OTA] = data["ota"] # Update Home Assistant plugins - self._data[ATTR_CLI] = data["cli"] - self._data[ATTR_DNS] = data["dns"] - self._data[ATTR_AUDIO] = data["audio"] - self._data[ATTR_OBSERVER] = data["observer"] - self._data[ATTR_MULTICAST] = data["multicast"] + self._data[ATTR_CLI] = AwesomeVersion(data["cli"]) + self._data[ATTR_DNS] = AwesomeVersion(data["dns"]) + self._data[ATTR_AUDIO] = AwesomeVersion(data["audio"]) + self._data[ATTR_OBSERVER] = AwesomeVersion(data["observer"]) + self._data[ATTR_MULTICAST] = AwesomeVersion(data["multicast"]) # Update images for that versions self._data[ATTR_IMAGE][ATTR_HOMEASSISTANT] = data["image"]["core"] diff --git a/supervisor/utils/json.py b/supervisor/utils/json.py index c3465e6aa..ea483b7d3 100644 --- a/supervisor/utils/json.py +++ b/supervisor/utils/json.py @@ -1,10 +1,12 @@ """Tools file for Supervisor.""" +from datetime import datetime import json import logging from pathlib import Path from typing import Any, Dict from atomicwrites import atomic_write +from awesomeversion import AwesomeVersion import voluptuous as vol from voluptuous.humanize import humanize_error @@ -15,11 +17,31 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) _DEFAULT: Dict[str, Any] = {} +class JSONEncoder(json.JSONEncoder): + """JSONEncoder that supports Supervisor objects.""" + + def default(self, o: Any) -> Any: + """Convert Supervisor special objects. + + Hand other objects to the original method. + """ + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, set): + return list(o) + if hasattr(o, "as_dict"): + return o.as_dict() + if isinstance(o, AwesomeVersion): + return o.string + + return json.JSONEncoder.default(self, o) + + def write_json_file(jsonfile: Path, data: Any) -> None: """Write a JSON file.""" try: with atomic_write(jsonfile, overwrite=True) as fp: - fp.write(json.dumps(data, indent=2)) + fp.write(json.dumps(data, indent=2, cls=JSONEncoder)) jsonfile.chmod(0o600) except (OSError, ValueError, TypeError) as err: _LOGGER.error("Can't write %s: %s", jsonfile, err) diff --git a/supervisor/validate.py b/supervisor/validate.py index 294a6f8ed..2afba574e 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -4,7 +4,7 @@ import re from typing import Optional, Union import uuid -from packaging import version as pkg_version +from awesomeversion import AwesomeVersion import voluptuous as vol from .const import ( @@ -55,23 +55,17 @@ RE_REGISTRY = re.compile(r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$") # pylint: disable=invalid-name network_port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) wait_boot = vol.All(vol.Coerce(int), vol.Range(min=1, max=60)) -docker_image = vol.Match(r"^[\w{}]+/[\-\w{}]+$") +docker_image = vol.Match(r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)$") uuid_match = vol.Match(r"^[0-9a-f]{32}$") sha256 = vol.Match(r"^[0-9a-f]{64}$") token = vol.Match(r"^[0-9a-f]{32,256}$") -def version_tag(value: Union[str, None, int, float]) -> Optional[str]: +def version_tag(value: Union[str, None, int, float]) -> Optional[AwesomeVersion]: """Validate main version handling.""" if value is None: return None - - try: - value = str(value) - pkg_version.parse(value) - except (pkg_version.InvalidVersion, TypeError): - raise vol.Invalid(f"Invalid version format {value}") from None - return value + return AwesomeVersion(value) def dns_url(url: str) -> str: @@ -142,14 +136,14 @@ SCHEMA_UPDATER_CONFIG = vol.Schema( vol.Optional(ATTR_CHANNEL, default=UpdateChannel.STABLE): vol.Coerce( UpdateChannel ), - vol.Optional(ATTR_HOMEASSISTANT): vol.All(version_tag, str), - vol.Optional(ATTR_SUPERVISOR): vol.All(version_tag, str), - vol.Optional(ATTR_HASSOS): vol.All(version_tag, str), - vol.Optional(ATTR_CLI): vol.All(version_tag, str), - vol.Optional(ATTR_DNS): vol.All(version_tag, str), - vol.Optional(ATTR_AUDIO): vol.All(version_tag, str), - vol.Optional(ATTR_OBSERVER): vol.All(version_tag, str), - vol.Optional(ATTR_MULTICAST): vol.All(version_tag, str), + vol.Optional(ATTR_HOMEASSISTANT): version_tag, + vol.Optional(ATTR_SUPERVISOR): version_tag, + vol.Optional(ATTR_HASSOS): version_tag, + vol.Optional(ATTR_CLI): version_tag, + vol.Optional(ATTR_DNS): version_tag, + vol.Optional(ATTR_AUDIO): version_tag, + vol.Optional(ATTR_OBSERVER): version_tag, + vol.Optional(ATTR_MULTICAST): version_tag, vol.Optional(ATTR_IMAGE, default=dict): vol.Schema( { vol.Optional(ATTR_HOMEASSISTANT): docker_image, @@ -173,7 +167,9 @@ SCHEMA_SUPERVISOR_CONFIG = vol.Schema( { vol.Optional(ATTR_TIMEZONE, default="UTC"): validate_timezone, vol.Optional(ATTR_LAST_BOOT): vol.Coerce(str), - vol.Optional(ATTR_VERSION, default=SUPERVISOR_VERSION): version_tag, + vol.Optional( + ATTR_VERSION, default=AwesomeVersion(SUPERVISOR_VERSION) + ): version_tag, vol.Optional( ATTR_ADDONS_CUSTOM_LIST, default=["https://github.com/hassio-addons/repository"], diff --git a/tests/misc/test_filter_data.py b/tests/misc/test_filter_data.py index 9eaa2b0c1..a6335fb3c 100644 --- a/tests/misc/test_filter_data.py +++ b/tests/misc/test_filter_data.py @@ -2,6 +2,7 @@ import os from unittest.mock import patch +from awesomeversion import AwesomeVersion import pytest from supervisor.const import SUPERVISOR_VERSION, CoreState @@ -73,7 +74,9 @@ def test_defaults(coresys): assert ["installation_type", "supervised"] in filtered["tags"] assert filtered["contexts"]["host"]["arch"] == "amd64" assert filtered["contexts"]["host"]["machine"] == "qemux86-64" - assert filtered["contexts"]["versions"]["supervisor"] == SUPERVISOR_VERSION + assert filtered["contexts"]["versions"]["supervisor"] == AwesomeVersion( + SUPERVISOR_VERSION + ) assert filtered["user"]["id"] == coresys.machine_id