diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e8591695d..cfc0cc7b2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -429,4 +429,4 @@ jobs: coverage report coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1.0.12 + uses: codecov/codecov-action@v1.0.13 diff --git a/.github/workflows/sentry.yaml b/.github/workflows/sentry.yaml new file mode 100644 index 000000000..c81b15c85 --- /dev/null +++ b/.github/workflows/sentry.yaml @@ -0,0 +1,21 @@ +name: Sentry Release + +# yamllint disable-line rule:truthy +on: + release: + types: [published, prereleased] + +jobs: + createSentryRelease: + runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@v2 + - name: Sentry Release + uses: getsentry/action-release@v1.0.0 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + with: + environment: production diff --git a/API.md b/API.md index 17e41db45..c859beb88 100644 --- a/API.md +++ b/API.md @@ -535,6 +535,7 @@ Get all available add-ons. "stdin": "bool", "webui": "null|http(s)://[HOST]:port/xy/zx", "gpio": "bool", + "usb": "[physical_path_to_usb_device]", "kernel_modules": "bool", "devicetree": "bool", "docker_api": "bool", diff --git a/requirements.txt b/requirements.txt index 82803a60a..29eca833a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,6 @@ pulsectl==20.5.1 pytz==2020.1 pyudev==0.22.0 ruamel.yaml==0.15.100 -sentry-sdk==0.16.4 +sentry-sdk==0.16.5 uvloop==0.14.0 voluptuous==0.11.7 diff --git a/requirements_tests.txt b/requirements_tests.txt index 4f1e60a38..e9ae3b258 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -7,7 +7,7 @@ pre-commit==2.6.0 pydocstyle==5.0.2 pylint==2.5.3 pytest-aiohttp==0.3.0 -pytest-cov==2.10.0 +pytest-cov==2.10.1 pytest-timeout==1.4.2 pytest==6.0.1 pyupgrade==2.7.2 diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index d0c6601fa..11801adf3 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -58,6 +58,7 @@ from ..const import ( ATTR_TMPFS, ATTR_UDEV, ATTR_URL, + ATTR_USB, ATTR_VERSION, ATTR_VIDEO, ATTR_WEBUI, @@ -292,11 +293,6 @@ class AddonModel(CoreSysAttributes, ABC): """Return devices of add-on.""" return self.data.get(ATTR_DEVICES, []) - @property - def auto_uart(self) -> bool: - """Return True if we should map all UART device.""" - return self.data[ATTR_AUTO_UART] - @property def tmpfs(self) -> Optional[str]: """Return tmpfs of add-on.""" @@ -376,6 +372,16 @@ class AddonModel(CoreSysAttributes, ABC): """Return True if the add-on access to GPIO interface.""" return self.data[ATTR_GPIO] + @property + def with_usb(self) -> bool: + """Return True if the add-on need USB access.""" + return self.data[ATTR_USB] + + @property + def with_uart(self) -> bool: + """Return True if we should map all UART device.""" + return self.data[ATTR_AUTO_UART] + @property def with_udev(self) -> bool: """Return True if the add-on have his own udev.""" diff --git a/supervisor/addons/validate.py b/supervisor/addons/validate.py index 0ae589027..367d5f919 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -75,6 +75,7 @@ from ..const import ( ATTR_TMPFS, ATTR_UDEV, ATTR_URL, + ATTR_USB, ATTR_USER, ATTR_UUID, ATTR_VERSION, @@ -226,6 +227,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema( vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(), vol.Optional(ATTR_VIDEO, default=False): vol.Boolean(), vol.Optional(ATTR_GPIO, default=False): vol.Boolean(), + vol.Optional(ATTR_USB, default=False): vol.Boolean(), vol.Optional(ATTR_DEVICETREE, default=False): vol.Boolean(), vol.Optional(ATTR_KERNEL_MODULES, default=False): vol.Boolean(), vol.Optional(ATTR_HASSIO_API, default=False): vol.Boolean(), diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index 9800f73f3..6fcf07920 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -81,6 +81,7 @@ from ..const import ( ATTR_STDIN, ATTR_UDEV, ATTR_URL, + ATTR_USB, ATTR_VERSION, ATTR_VERSION_LATEST, ATTR_VIDEO, @@ -237,6 +238,7 @@ class APIAddons(CoreSysAttributes): ATTR_AUTH_API: addon.access_auth_api, ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api, ATTR_GPIO: addon.with_gpio, + ATTR_USB: addon.with_usb, ATTR_KERNEL_MODULES: addon.with_kernel_modules, ATTR_DEVICETREE: addon.with_devicetree, ATTR_UDEV: addon.with_udev, diff --git a/supervisor/api/hardware.py b/supervisor/api/hardware.py index e66c84a52..1db46dd5b 100644 --- a/supervisor/api/hardware.py +++ b/supervisor/api/hardware.py @@ -1,7 +1,7 @@ """Init file for Supervisor hardware RESTful API.""" import asyncio import logging -from typing import Any, Awaitable, Dict +from typing import Any, Awaitable, Dict, List from aiohttp import web @@ -12,6 +12,7 @@ from ..const import ( ATTR_INPUT, ATTR_OUTPUT, ATTR_SERIAL, + ATTR_USB, ) from ..coresys import CoreSysAttributes from .utils import api_process @@ -25,13 +26,24 @@ class APIHardware(CoreSysAttributes): @api_process async def info(self, request: web.Request) -> Dict[str, Any]: """Show hardware info.""" + serial: List[str] = [] + + # Create Serial list with device links + for device in self.sys_hardware.serial_devices: + serial.append(device.path.as_posix()) + for link in device.links: + serial.append(link.as_posix()) + return { - ATTR_SERIAL: list( - self.sys_hardware.serial_devices | self.sys_hardware.serial_by_id - ), + ATTR_SERIAL: serial, ATTR_INPUT: list(self.sys_hardware.input_devices), - ATTR_DISK: list(self.sys_hardware.disk_devices), + ATTR_DISK: [ + device.path.as_posix() for device in self.sys_hardware.disk_devices + ], ATTR_GPIO: list(self.sys_hardware.gpio_devices), + ATTR_USB: [ + device.path.as_posix() for device in self.sys_hardware.usb_devices + ], ATTR_AUDIO: self.sys_hardware.audio_devices, } diff --git a/supervisor/api/info.py b/supervisor/api/info.py index d3cc898b8..c8e889690 100644 --- a/supervisor/api/info.py +++ b/supervisor/api/info.py @@ -42,5 +42,5 @@ class APIInfo(CoreSysAttributes): ATTR_SUPPORTED: self.sys_core.supported, ATTR_CHANNEL: self.sys_updater.channel, ATTR_LOGGING: self.sys_config.logging, - ATTR_TIMEZONE: self.sys_timezone, + ATTR_TIMEZONE: self.sys_config.timezone, } diff --git a/supervisor/api/security.py b/supervisor/api/security.py index 1779c3f79..cf7b645b0 100644 --- a/supervisor/api/security.py +++ b/supervisor/api/security.py @@ -131,8 +131,7 @@ class SecurityMiddleware(CoreSysAttributes): request_from = self.sys_homeassistant # Host - # Remove machine_id handling later if all use new CLI - if supervisor_token in (self.sys_machine_id, self.sys_plugins.cli.supervisor_token): + if supervisor_token == self.sys_plugins.cli.supervisor_token: _LOGGER.debug("%s access from Host", request.path) request_from = self.sys_host diff --git a/supervisor/const.py b/supervisor/const.py index 4888a2135..985cf5a43 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -3,7 +3,7 @@ from enum import Enum from ipaddress import ip_network from pathlib import Path -SUPERVISOR_VERSION = "234" +SUPERVISOR_VERSION = "235" URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_VERSION = "https://version.home-assistant.io/{channel}.json" @@ -108,6 +108,7 @@ ATTR_PROVIDERS = "providers" ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_AUTO_UART = "auto_uart" +ATTR_USB = "usb" ATTR_LAST_BOOT = "last_boot" ATTR_CHANNEL = "channel" ATTR_NAME = "name" diff --git a/supervisor/core.py b/supervisor/core.py index 952064072..2f9808463 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -7,7 +7,12 @@ import async_timeout from .const import SOCKET_DBUS, SUPERVISED_SUPPORTED_OS, AddonStartup, CoreStates from .coresys import CoreSys, CoreSysAttributes -from .exceptions import HassioError, HomeAssistantError, SupervisorUpdateError +from .exceptions import ( + DockerAPIError, + HassioError, + HomeAssistantError, + SupervisorUpdateError, +) _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -125,13 +130,8 @@ class Core(CoreSysAttributes): if self.sys_host.info.operating_system not in SUPERVISED_SUPPORTED_OS: self.supported = False _LOGGER.error( - "Using '%s' as the OS is not supported", - self.sys_host.info.operating_system, + "Detected unsupported OS: %s", self.sys_host.info.operating_system, ) - else: - # Check rauc connectivity on our OS - if not self.sys_dbus.rauc.is_connected: - self.healthy = False # Check all DBUS connectivity if not self.sys_dbus.hostname.is_connected: @@ -145,8 +145,11 @@ class Core(CoreSysAttributes): _LOGGER.error("Systemd DBUS is not connected") # Check if image names from denylist exist - if await self.sys_run_in_executor(self.sys_docker.check_denylist_images): - self.coresys.supported = False + try: + if await self.sys_run_in_executor(self.sys_docker.check_denylist_images): + self.coresys.supported = False + self.healthy = False + except DockerAPIError: self.healthy = False async def start(self): @@ -157,7 +160,7 @@ class Core(CoreSysAttributes): # Check if system is healthy if not self.supported: _LOGGER.critical("System running in a unsupported environment!") - elif not self.healthy: + if not self.healthy: _LOGGER.critical( "System running in a unhealthy state and need manual intervention!" ) @@ -173,11 +176,12 @@ class Core(CoreSysAttributes): _LOGGER.warning("Ignore Supervisor updates!") else: await self.sys_supervisor.update() - except SupervisorUpdateError: + except SupervisorUpdateError as err: _LOGGER.critical( "Can't update supervisor! This will break some Add-ons or affect " "future version of Home Assistant!" ) + self.sys_capture_exception(err) # Start addon mark as initialize await self.sys_addons.boot(AddonStartup.INITIALIZE) diff --git a/supervisor/coresys.py b/supervisor/coresys.py index eb8b0bc6b..1500dcd89 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -90,11 +90,6 @@ class CoreSys: return False return self._updater.channel == UpdateChannels.DEV - @property - def timezone(self) -> str: - """Return timezone.""" - return self._config.timezone - @property def loop(self) -> asyncio.BaseEventLoop: """Return loop object.""" @@ -459,16 +454,6 @@ class CoreSysAttributes: """Return True if we run dev mode.""" return self.coresys.dev - @property - def sys_timezone(self) -> str: - """Return timezone.""" - return self.coresys.timezone - - @property - def sys_machine_id(self) -> Optional[str]: - """Return timezone.""" - return self.coresys.machine_id - @property def sys_loop(self) -> asyncio.BaseEventLoop: """Return loop object.""" diff --git a/supervisor/docker/__init__.py b/supervisor/docker/__init__.py index 06fabbe9d..6505e9b80 100644 --- a/supervisor/docker/__init__.py +++ b/supervisor/docker/__init__.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Optional import attr import docker from packaging import version as pkg_version +import requests from ..const import DNS_SUFFIX, DOCKER_IMAGE_DENYLIST, SOCKET_DOCKER from ..exceptions import DockerAPIError @@ -128,7 +129,7 @@ class DockerAPI: container = self.docker.containers.create( f"{image}:{version}", use_config_proxy=False, **kwargs ) - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't create container from %s: %s", name, err) raise DockerAPIError() @@ -146,12 +147,12 @@ class DockerAPI: # Run container try: container.start() - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't start %s: %s", name, err) raise DockerAPIError() # Update metadata - with suppress(docker.errors.DockerException): + with suppress(docker.errors.DockerException, requests.RequestException): container.reload() return container @@ -184,13 +185,13 @@ class DockerAPI: result = container.wait() output = container.logs(stdout=stdout, stderr=stderr) - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't execute command: %s", err) raise DockerAPIError() finally: # cleanup container - with suppress(docker.errors.DockerException): + with suppress(docker.errors.DockerException, requests.RequestException): container.remove(force=True) return CommandReturn(result.get("StatusCode"), output) @@ -236,14 +237,19 @@ class DockerAPI: def check_denylist_images(self) -> bool: """Return a boolean if the host has images in the denylist.""" denied_images = set() - for image in self.images.list(): - for tag in image.tags: - image_name = tag.split(":")[0] - if ( - image_name in DOCKER_IMAGE_DENYLIST - and image_name not in denied_images - ): - denied_images.add(image_name) + + try: + for image in self.images.list(): + for tag in image.tags: + image_name = tag.split(":")[0] + if ( + image_name in DOCKER_IMAGE_DENYLIST + and image_name not in denied_images + ): + denied_images.add(image_name) + except (docker.errors.DockerException, requests.RequestException) as err: + _LOGGER.error("Corrupt docker overlayfs detect: %s", err) + raise DockerAPIError() if not denied_images: return False diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index df8580ad8..276847a2c 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -116,7 +116,7 @@ class DockerAddon(DockerInterface): return { **addon_env, - ENV_TIME: self.sys_timezone, + ENV_TIME: self.sys_config.timezone, ENV_TOKEN: self.addon.supervisor_token, ENV_TOKEN_OLD: self.addon.supervisor_token, } @@ -127,20 +127,21 @@ class DockerAddon(DockerInterface): devices = [] # Extend add-on config - if self.addon.devices: - devices.extend(self.addon.devices) + for device in self.addon.devices: + if not Path(device.split(":")[0]).exists(): + continue + devices.append(device) # Auto mapping UART devices - if self.addon.auto_uart: - if self.addon.with_udev: - serial_devs = self.sys_hardware.serial_devices - else: - serial_devs = ( - self.sys_hardware.serial_devices | self.sys_hardware.serial_by_id - ) - - for device in serial_devs: - devices.append(f"{device}:{device}:rwm") + if self.addon.with_uart: + for device in self.sys_hardware.serial_devices: + devices.append(f"{device.path.as_posix()}:{device.path.as_posix()}:rwm") + if self.addon.with_udev: + continue + for device_link in device.links: + devices.append( + f"{device_link.as_posix()}:{device_link.as_posix()}:rwm" + ) # Use video devices if self.addon.with_video: @@ -286,6 +287,10 @@ class DockerAddon(DockerInterface): } ) + # USB support + if self.addon.with_usb and self.sys_hardware.usb_devices: + volumes.update({"/dev/bus/usb": {"bind": "/dev/bus/usb", "mode": "rw"}}) + # Kernel Modules support if self.addon.with_kernel_modules: volumes.update({"/lib/modules": {"bind": "/lib/modules", "mode": "ro"}}) @@ -334,8 +339,7 @@ class DockerAddon(DockerInterface): _LOGGER.warning("%s run with disabled protected mode!", self.addon.name) # Cleanup - with suppress(DockerAPIError): - self._stop() + self._stop() # Create & Run container docker_container = self.sys_docker.run( @@ -396,7 +400,7 @@ class DockerAddon(DockerInterface): # Update meta data self._meta = image.attrs - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't build %s:%s: %s", self.image, tag, err) raise DockerAPIError() @@ -414,7 +418,7 @@ class DockerAddon(DockerInterface): """ try: image = self.sys_docker.api.get_image(f"{self.image}:{self.version}") - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't fetch image %s: %s", self.image, err) raise DockerAPIError() @@ -423,7 +427,7 @@ class DockerAddon(DockerInterface): with tar_file.open("wb") as write_tar: for chunk in image: write_tar.write(chunk) - except (OSError, requests.exceptions.ReadTimeout) as err: + except (OSError, requests.RequestException) as err: _LOGGER.error("Can't write tar file %s: %s", tar_file, err) raise DockerAPIError() @@ -471,7 +475,7 @@ class DockerAddon(DockerInterface): # Load needed docker objects container = self.sys_docker.containers.get(self.name) socket = container.attach_socket(params={"stdin": 1, "stream": 1}) - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't attach to %s stdin: %s", self.name, err) raise DockerAPIError() diff --git a/supervisor/docker/audio.py b/supervisor/docker/audio.py index c5fd1749d..b98c47c16 100644 --- a/supervisor/docker/audio.py +++ b/supervisor/docker/audio.py @@ -1,12 +1,10 @@ """Audio docker object.""" -from contextlib import suppress import logging from pathlib import Path from typing import Dict from ..const import ENV_TIME, MACHINE_ID from ..coresys import CoreSysAttributes -from ..exceptions import DockerAPIError from .interface import DockerInterface _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -56,8 +54,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes): return # Cleanup - with suppress(DockerAPIError): - self._stop() + self._stop() # Create & Run container docker_container = self.sys_docker.run( @@ -69,7 +66,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes): hostname=self.name.replace("_", "-"), detach=True, privileged=True, - environment={ENV_TIME: self.sys_timezone}, + environment={ENV_TIME: self.sys_config.timezone}, volumes=self.volumes, ) diff --git a/supervisor/docker/cli.py b/supervisor/docker/cli.py index c9738924b..6ce6e1140 100644 --- a/supervisor/docker/cli.py +++ b/supervisor/docker/cli.py @@ -1,10 +1,8 @@ """HA Cli docker object.""" -from contextlib import suppress import logging from ..const import ENV_TIME, ENV_TOKEN from ..coresys import CoreSysAttributes -from ..exceptions import DockerAPIError from .interface import DockerInterface _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -34,8 +32,7 @@ class DockerCli(DockerInterface, CoreSysAttributes): return # Cleanup - with suppress(DockerAPIError): - self._stop() + self._stop() # Create & Run container docker_container = self.sys_docker.run( @@ -50,7 +47,7 @@ class DockerCli(DockerInterface, CoreSysAttributes): detach=True, extra_hosts={"supervisor": self.sys_docker.network.supervisor}, environment={ - ENV_TIME: self.sys_timezone, + ENV_TIME: self.sys_config.timezone, ENV_TOKEN: self.sys_plugins.cli.supervisor_token, }, ) diff --git a/supervisor/docker/dns.py b/supervisor/docker/dns.py index 94f87eb1b..d664261cc 100644 --- a/supervisor/docker/dns.py +++ b/supervisor/docker/dns.py @@ -1,10 +1,8 @@ """DNS docker object.""" -from contextlib import suppress import logging from ..const import ENV_TIME from ..coresys import CoreSysAttributes -from ..exceptions import DockerAPIError from .interface import DockerInterface _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -34,8 +32,7 @@ class DockerDNS(DockerInterface, CoreSysAttributes): return # Cleanup - with suppress(DockerAPIError): - self._stop() + self._stop() # Create & Run container docker_container = self.sys_docker.run( @@ -47,7 +44,7 @@ class DockerDNS(DockerInterface, CoreSysAttributes): name=self.name, hostname=self.name.replace("_", "-"), detach=True, - environment={ENV_TIME: self.sys_timezone}, + environment={ENV_TIME: self.sys_config.timezone}, volumes={ str(self.sys_config.path_extern_dns): {"bind": "/config", "mode": "ro"} }, diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index 27b4c1b99..6b2c7f715 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -1,10 +1,10 @@ """Init file for Supervisor Docker object.""" -from contextlib import suppress from ipaddress import IPv4Address import logging from typing import Awaitable, Dict, Optional import docker +import requests from ..const import ENV_TIME, ENV_TOKEN, ENV_TOKEN_OLD, LABEL_MACHINE, MACHINE_ID from ..exceptions import DockerAPIError @@ -98,8 +98,7 @@ class DockerHomeAssistant(DockerInterface): return # Cleanup - with suppress(DockerAPIError): - self._stop() + self._stop() # Create & Run container docker_container = self.sys_docker.run( @@ -115,7 +114,7 @@ class DockerHomeAssistant(DockerInterface): environment={ "HASSIO": self.sys_docker.network.supervisor, "SUPERVISOR": self.sys_docker.network.supervisor, - ENV_TIME: self.sys_timezone, + ENV_TIME: self.sys_config.timezone, ENV_TOKEN: self.sys_homeassistant.supervisor_token, ENV_TOKEN_OLD: self.sys_homeassistant.supervisor_token, }, @@ -150,7 +149,7 @@ class DockerHomeAssistant(DockerInterface): "mode": "ro", }, }, - environment={ENV_TIME: self.sys_timezone}, + environment={ENV_TIME: self.sys_config.timezone}, ) def is_initialize(self) -> Awaitable[bool]: @@ -167,8 +166,10 @@ class DockerHomeAssistant(DockerInterface): docker_image = self.sys_docker.images.get( f"{self.image}:{self.sys_homeassistant.version}" ) - except docker.errors.DockerException: + except docker.errors.NotFound: return False + except (docker.errors.DockerException, requests.RequestException): + return DockerAPIError() # we run on an old image, stop and start it if docker_container.image.id != docker_image.id: diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 85da16e90..26c9a233b 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -5,6 +5,7 @@ import logging from typing import Any, Awaitable, Dict, List, Optional import docker +import requests from . import CommandReturn from ..const import LABEL_ARCH, LABEL_VERSION @@ -107,6 +108,10 @@ class DockerInterface(CoreSysAttributes): free_space, ) raise DockerAPIError() + except (docker.errors.DockerException, requests.RequestException) as err: + _LOGGER.error("Unknown error with %s:%s -> %s", image, tag, err) + self.sys_capture_exception(err) + raise DockerAPIError() else: self._meta = docker_image.attrs @@ -119,7 +124,7 @@ class DockerInterface(CoreSysAttributes): Need run inside executor. """ - with suppress(docker.errors.DockerException): + with suppress(docker.errors.DockerException, requests.RequestException): self.sys_docker.images.get(f"{self.image}:{self.version}") return True return False @@ -138,8 +143,10 @@ class DockerInterface(CoreSysAttributes): """ try: docker_container = self.sys_docker.containers.get(self.name) - except docker.errors.DockerException: + except docker.errors.NotFound: return False + except (docker.errors.DockerException, requests.RequestException): + raise DockerAPIError() return docker_container.status == "running" @@ -153,10 +160,10 @@ class DockerInterface(CoreSysAttributes): Need run inside executor. """ - with suppress(docker.errors.DockerException): + with suppress(docker.errors.DockerException, requests.RequestException): self._meta = self.sys_docker.containers.get(self.name).attrs - with suppress(docker.errors.DockerException): + 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 @@ -189,16 +196,18 @@ class DockerInterface(CoreSysAttributes): """ try: docker_container = self.sys_docker.containers.get(self.name) - except docker.errors.DockerException: + except docker.errors.NotFound: + return + except (docker.errors.DockerException, requests.RequestException): raise DockerAPIError() if docker_container.status == "running": _LOGGER.info("Stop %s application", self.name) - with suppress(docker.errors.DockerException): + with suppress(docker.errors.DockerException, requests.RequestException): docker_container.stop(timeout=self.timeout) if remove_container: - with suppress(docker.errors.DockerException): + with suppress(docker.errors.DockerException, requests.RequestException): _LOGGER.info("Clean %s application", self.name) docker_container.remove(force=True) @@ -214,13 +223,14 @@ class DockerInterface(CoreSysAttributes): """ try: docker_container = self.sys_docker.containers.get(self.name) - except docker.errors.DockerException: + except (docker.errors.DockerException, requests.RequestException): + _LOGGER.error("%s not found for starting up", self.name) raise DockerAPIError() _LOGGER.info("Start %s", self.name) try: docker_container.start() - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't start %s: %s", self.name, err) raise DockerAPIError() @@ -249,7 +259,7 @@ class DockerInterface(CoreSysAttributes): image=f"{self.image}:{self.version}", force=True ) - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.warning("Can't remove image %s: %s", self.image, err) raise DockerAPIError() @@ -296,12 +306,12 @@ class DockerInterface(CoreSysAttributes): """ try: docker_container = self.sys_docker.containers.get(self.name) - except docker.errors.DockerException: + except (docker.errors.DockerException, requests.RequestException): return b"" try: return docker_container.logs(tail=100, stdout=True, stderr=True) - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.warning("Can't grep logs from %s: %s", self.image, err) return b"" @@ -318,7 +328,7 @@ class DockerInterface(CoreSysAttributes): """ try: origin = self.sys_docker.images.get(f"{self.image}:{self.version}") - except docker.errors.DockerException: + except (docker.errors.DockerException, requests.RequestException): _LOGGER.warning("Can't find %s for cleanup", self.image) raise DockerAPIError() @@ -327,7 +337,7 @@ class DockerInterface(CoreSysAttributes): if origin.id == image.id: continue - with suppress(docker.errors.DockerException): + with suppress(docker.errors.DockerException, requests.RequestException): _LOGGER.info("Cleanup images: %s", image.tags) self.sys_docker.images.remove(image.id, force=True) @@ -336,7 +346,7 @@ class DockerInterface(CoreSysAttributes): return for image in self.sys_docker.images.list(name=old_image): - with suppress(docker.errors.DockerException): + with suppress(docker.errors.DockerException, requests.RequestException): _LOGGER.info("Cleanup images: %s", image.tags) self.sys_docker.images.remove(image.id, force=True) @@ -352,13 +362,13 @@ class DockerInterface(CoreSysAttributes): """ try: container = self.sys_docker.containers.get(self.name) - except docker.errors.DockerException: + except (docker.errors.DockerException, requests.RequestException): raise DockerAPIError() _LOGGER.info("Restart %s", self.image) try: container.restart(timeout=self.timeout) - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.warning("Can't restart %s: %s", self.image, err) raise DockerAPIError() @@ -385,13 +395,13 @@ class DockerInterface(CoreSysAttributes): """ try: docker_container = self.sys_docker.containers.get(self.name) - except docker.errors.DockerException: + except (docker.errors.DockerException, requests.RequestException): raise DockerAPIError() try: stats = docker_container.stats(stream=False) return DockerStats(stats) - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't read stats from %s: %s", self.name, err) raise DockerAPIError() @@ -409,7 +419,7 @@ class DockerInterface(CoreSysAttributes): """ try: docker_container = self.sys_docker.containers.get(self.name) - except docker.errors.DockerException: + except (docker.errors.DockerException, requests.RequestException): return False # container is not running diff --git a/supervisor/docker/multicast.py b/supervisor/docker/multicast.py index 6168d89cc..f0de4accf 100644 --- a/supervisor/docker/multicast.py +++ b/supervisor/docker/multicast.py @@ -1,10 +1,8 @@ """HA Cli docker object.""" -from contextlib import suppress import logging from ..const import ENV_TIME from ..coresys import CoreSysAttributes -from ..exceptions import DockerAPIError from .interface import DockerInterface _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -34,8 +32,7 @@ class DockerMulticast(DockerInterface, CoreSysAttributes): return # Cleanup - with suppress(DockerAPIError): - self._stop() + self._stop() # Create & Run container docker_container = self.sys_docker.run( @@ -47,7 +44,7 @@ class DockerMulticast(DockerInterface, CoreSysAttributes): network_mode="host", detach=True, extra_hosts={"supervisor": self.sys_docker.network.supervisor}, - environment={ENV_TIME: self.sys_timezone}, + environment={ENV_TIME: self.sys_config.timezone}, ) self._meta = docker_container.attrs diff --git a/supervisor/docker/network.py b/supervisor/docker/network.py index dc683c714..d6eb7ceec 100644 --- a/supervisor/docker/network.py +++ b/supervisor/docker/network.py @@ -5,6 +5,7 @@ import logging from typing import List, Optional import docker +import requests from ..const import DOCKER_NETWORK, DOCKER_NETWORK_MASK, DOCKER_NETWORK_RANGE from ..exceptions import DockerAPIError @@ -35,9 +36,11 @@ class DockerNetwork: for cid, data in self.network.attrs.get("Containers", {}).items(): try: containers.append(self.docker.containers.get(cid)) - except docker.errors.APIError as err: - _LOGGER.warning("Docker network is corrupt! %s - run autofix", err) + except docker.errors.NotFound: + _LOGGER.warning("Docker network is corrupt! %s - run autofix", cid) self.stale_cleanup(data.get("Name", cid)) + except (docker.errors.DockerException, requests.RequestException) as err: + _LOGGER.error("Unknown error with container lookup %s", err) return containers @@ -132,5 +135,5 @@ class DockerNetwork: Fix: https://github.com/moby/moby/issues/23302 """ - with suppress(docker.errors.APIError): + with suppress(docker.errors.DockerException, requests.RequestException): self.network.disconnect(container_name, force=True) diff --git a/supervisor/docker/supervisor.py b/supervisor/docker/supervisor.py index 88ff24977..fe6b67043 100644 --- a/supervisor/docker/supervisor.py +++ b/supervisor/docker/supervisor.py @@ -5,6 +5,7 @@ import os from typing import Awaitable import docker +import requests from ..coresys import CoreSysAttributes from ..exceptions import DockerAPIError @@ -38,7 +39,7 @@ class DockerSupervisor(DockerInterface, CoreSysAttributes): """ try: docker_container = self.sys_docker.containers.get(self.name) - except docker.errors.DockerException: + except (docker.errors.DockerException, requests.RequestException): raise DockerAPIError() self._meta = docker_container.attrs @@ -74,7 +75,7 @@ class DockerSupervisor(DockerInterface, CoreSysAttributes): docker_container.image.tag(self.image, tag=self.version) docker_container.image.tag(self.image, tag="latest") - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't retag supervisor version: %s", err) raise DockerAPIError() @@ -101,6 +102,6 @@ class DockerSupervisor(DockerInterface, CoreSysAttributes): continue docker_image.tag(start_image, start_tag) - except docker.errors.DockerException as err: + except (docker.errors.DockerException, requests.RequestException) as err: _LOGGER.error("Can't fix start tag: %s", err) raise DockerAPIError() diff --git a/supervisor/misc/filter.py b/supervisor/misc/filter.py index 74d3c8b42..e04cc7c4e 100644 --- a/supervisor/misc/filter.py +++ b/supervisor/misc/filter.py @@ -38,25 +38,40 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: if coresys.core.state in (CoreStates.INITIALIZE, CoreStates.SETUP): return event + # List installed addons + installed_addons = [ + {"slug": addon.slug, "repository": addon.repository, "name": addon.name} + for addon in coresys.addons.installed + ] + # Update information - event.setdefault("extra", {}).update( + event.setdefault("user", {}).update({"id": coresys.machine_id}) + event.setdefault("contexts", {}).update( { "supervisor": { - "machine": coresys.machine, - "arch": coresys.arch.default, - "docker": coresys.docker.info.version, "channel": coresys.updater.channel, - "supervisor": coresys.supervisor.version, - "os": coresys.hassos.version, + "installed_addons": installed_addons, + "repositories": coresys.config.addons_repositories, + }, + "host": { + "arch": coresys.arch.default, + "board": coresys.hassos.board, + "deployment": coresys.host.info.deployment, + "disk_free_space": coresys.host.info.free_space, "host": coresys.host.info.operating_system, "kernel": coresys.host.info.kernel, - "core": coresys.homeassistant.version, + "machine": coresys.machine, + }, + "versions": { "audio": coresys.plugins.audio.version, - "dns": coresys.plugins.dns.version, - "multicast": coresys.plugins.multicast.version, "cli": coresys.plugins.cli.version, - "disk_free_space": coresys.host.info.free_space, - } + "core": coresys.homeassistant.version, + "dns": coresys.plugins.dns.version, + "docker": coresys.docker.info.version, + "multicast": coresys.plugins.multicast.version, + "os": coresys.hassos.version, + "supervisor": coresys.supervisor.version, + }, } ) event.setdefault("tags", []).extend( @@ -86,4 +101,5 @@ def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: if key in [hdrs.HOST, hdrs.X_FORWARDED_HOST]: event["request"]["headers"][i] = [key, "example.com"] + return event diff --git a/supervisor/misc/hardware.py b/supervisor/misc/hardware.py index df3f95b29..72de4a15b 100644 --- a/supervisor/misc/hardware.py +++ b/supervisor/misc/hardware.py @@ -31,13 +31,15 @@ RE_TTY: re.Pattern = re.compile(r"tty[A-Z]+") RE_VIDEO_DEVICES = re.compile(r"^(?:vchiq|cec\d+|video\d+)") -@attr.s(frozen=True) +@attr.s(slots=True, frozen=True) class Device: """Represent a device.""" name: str = attr.ib() path: Path = attr.ib() + subsystem: str = attr.ib() links: List[Path] = attr.ib() + attributes: Dict[str, str] = attr.ib() class Hardware: @@ -62,7 +64,9 @@ class Hardware: Device( device.sys_name, Path(device.device_node), + device.subsystem, [Path(node) for node in device.device_links], + {attr: device.properties[attr] for attr in device.properties}, ) ) @@ -81,28 +85,30 @@ class Hardware: return dev_list @property - def serial_devices(self) -> Set[str]: + def serial_devices(self) -> List[Device]: """Return all serial and connected devices.""" - dev_list: Set[str] = set() - for device in self.context.list_devices(subsystem="tty"): - if "ID_VENDOR" in device.properties or RE_TTY.search(device.device_node): - dev_list.add(device.device_node) + dev_list: List[Device] = [] + for device in self.devices: + if device.subsystem != "tty" or ( + "ID_VENDOR" not in device.attributes + and not RE_TTY.search(str(device.path)) + ): + continue + + # Cleanup not usable device links + for link in device.links.copy(): + if link.match("/dev/serial/by-id/*"): + continue + device.links.remove(link) + + dev_list.append(device) return dev_list @property - def serial_by_id(self) -> Set[str]: - """Return all /dev/serial/by-id for serial devices.""" - dev_list: Set[str] = set() - for device in self.context.list_devices(subsystem="tty"): - if "ID_VENDOR" in device.properties or RE_TTY.search(device.device_node): - # Add /dev/serial/by-id devlink for current device - for dev_link in device.device_links: - if not dev_link.startswith("/dev/serial/by-id"): - continue - dev_list.add(dev_link) - - return dev_list + def usb_devices(self) -> List[Device]: + """Return all usb and connected devices.""" + return [device for device in self.devices if device.subsystem == "usb"] @property def input_devices(self) -> Set[str]: @@ -115,12 +121,13 @@ class Hardware: return dev_list @property - def disk_devices(self) -> Set[str]: + def disk_devices(self) -> List[Device]: """Return all disk devices.""" - dev_list: Set[str] = set() - for device in self.context.list_devices(subsystem="block"): - if "ID_NAME" in device.properties: - dev_list.add(device.device_node) + dev_list: List[Device] = [] + for device in self.devices: + if device.subsystem != "block" or "ID_NAME" not in device.attributes: + continue + dev_list.append(device) return dev_list diff --git a/tests/conftest.py b/tests/conftest.py index 35538180f..22c8f99f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Common test functions.""" from unittest.mock import MagicMock, PropertyMock, patch +from uuid import uuid4 import pytest @@ -39,6 +40,9 @@ async def coresys(loop, docker): coresys_obj.ingress.save_data = MagicMock() coresys_obj.arch._default_arch = "amd64" + coresys_obj._machine = "qemux86-64" + coresys_obj._machine_id = uuid4() + yield coresys_obj diff --git a/tests/misc/test_filter_data.py b/tests/misc/test_filter_data.py index 80b2f1d17..cb38cc459 100644 --- a/tests/misc/test_filter_data.py +++ b/tests/misc/test_filter_data.py @@ -1,7 +1,7 @@ """Test sentry data filter.""" from unittest.mock import patch -from supervisor.const import CoreStates +from supervisor.const import SUPERVISOR_VERSION, CoreStates from supervisor.exceptions import AddonConfigurationError from supervisor.misc.filter import filter_data @@ -58,7 +58,10 @@ def test_defaults(coresys): filtered = filter_data(coresys, SAMPLE_EVENT, {}) assert ["installation_type", "supervised"] in filtered["tags"] - assert filtered["extra"]["supervisor"]["arch"] == "amd64" + assert filtered["contexts"]["host"]["arch"] == "amd64" + assert filtered["contexts"]["host"]["machine"] == "qemux86-64" + assert filtered["contexts"]["versions"]["supervisor"] == SUPERVISOR_VERSION + assert filtered["user"]["id"] == coresys.machine_id def test_sanitize(coresys): diff --git a/tests/misc/test_hardware.py b/tests/misc/test_hardware.py index 6c0f63d6f..246374991 100644 --- a/tests/misc/test_hardware.py +++ b/tests/misc/test_hardware.py @@ -16,10 +16,10 @@ def test_video_devices(): """Test video device filter.""" system = Hardware() device_list = [ - Device("test-dev", Path("/dev/test-dev"), []), - Device("vchiq", Path("/dev/vchiq"), []), - Device("cec0", Path("/dev/cec0"), []), - Device("video1", Path("/dev/video1"), []), + Device("test-dev", Path("/dev/test-dev"), "xy", [], {}), + Device("vchiq", Path("/dev/vchiq"), "xy", [], {}), + Device("cec0", Path("/dev/cec0"), "xy", [], {}), + Device("video1", Path("/dev/video1"), "xy", [], {}), ] with patch( @@ -27,10 +27,80 @@ def test_video_devices(): ) as mock_device: mock_device.return_value = device_list - assert system.video_devices == [ - Device("vchiq", Path("/dev/vchiq"), []), - Device("cec0", Path("/dev/cec0"), []), - Device("video1", Path("/dev/video1"), []), + assert [device.name for device in system.video_devices] == [ + "vchiq", + "cec0", + "video1", + ] + + +def test_serial_devices(): + """Test serial device filter.""" + system = Hardware() + device_list = [ + Device("ttyACM0", Path("/dev/ttyACM0"), "tty", [], {"ID_VENDOR": "xy"}), + Device( + "ttyUSB0", + Path("/dev/ttyUSB0"), + "tty", + [Path("/dev/ttyS1"), Path("/dev/serial/by-id/xyx")], + {"ID_VENDOR": "xy"}, + ), + Device("ttyS0", Path("/dev/ttyS0"), "tty", [], {}), + Device("video1", Path("/dev/video1"), "misc", [], {"ID_VENDOR": "xy"}), + ] + + with patch( + "supervisor.misc.hardware.Hardware.devices", new_callable=PropertyMock + ) as mock_device: + mock_device.return_value = device_list + + assert [(device.name, device.links) for device in system.serial_devices] == [ + ("ttyACM0", []), + ("ttyUSB0", [Path("/dev/serial/by-id/xyx")]), + ("ttyS0", []), + ] + + +def test_usb_devices(): + """Test usb device filter.""" + system = Hardware() + device_list = [ + Device("usb1", Path("/dev/bus/usb/1/1"), "usb", [], {}), + Device("usb2", Path("/dev/bus/usb/2/1"), "usb", [], {}), + Device("cec0", Path("/dev/cec0"), "xy", [], {}), + Device("video1", Path("/dev/video1"), "xy", [], {}), + ] + + with patch( + "supervisor.misc.hardware.Hardware.devices", new_callable=PropertyMock + ) as mock_device: + mock_device.return_value = device_list + + assert [device.name for device in system.usb_devices] == [ + "usb1", + "usb2", + ] + + +def test_block_devices(): + """Test usb device filter.""" + system = Hardware() + device_list = [ + Device("sda", Path("/dev/sda"), "block", [], {"ID_NAME": "xy"}), + Device("sdb", Path("/dev/sdb"), "block", [], {"ID_NAME": "xy"}), + Device("cec0", Path("/dev/cec0"), "xy", [], {}), + Device("video1", Path("/dev/video1"), "xy", [], {"ID_NAME": "xy"}), + ] + + with patch( + "supervisor.misc.hardware.Hardware.devices", new_callable=PropertyMock + ) as mock_device: + mock_device.return_value = device_list + + assert [device.name for device in system.disk_devices] == [ + "sda", + "sdb", ]