From 96c0fbaf10e86be8f4730eb752be6f41c4154e19 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 27 Mar 2020 17:37:48 +0100 Subject: [PATCH] Cli rebrand (#1601) * Rebrand CLI * forward * Fix startup command * add cli api * Add token handling * Fix security check * fix repair * fix lint * Update for new cli * Add watchdog * rename * use s6 --- .vscode/tasks.json | 180 ++++++++++++++------------- API.md | 182 +++++----------------------- scripts/test_env.sh | 6 - supervisor/addons/__init__.py | 2 +- supervisor/addons/addon.py | 2 +- supervisor/addons/model.py | 2 +- supervisor/api/__init__.py | 41 ++++--- supervisor/api/cli.py | 62 ++++++++++ supervisor/api/{hassos.py => os.py} | 26 ++-- supervisor/api/proxy.py | 12 +- supervisor/api/security.py | 6 +- supervisor/audio.py | 2 +- supervisor/bootstrap.py | 2 + supervisor/cli.py | 156 ++++++++++++++++++++++++ supervisor/const.py | 4 +- supervisor/core.py | 10 +- supervisor/coresys.py | 19 +++ supervisor/docker/addon.py | 4 +- supervisor/docker/cli.py | 64 ++++++++++ supervisor/docker/hassos_cli.py | 38 ------ supervisor/docker/homeassistant.py | 4 +- supervisor/docker/network.py | 5 + supervisor/exceptions.py | 11 ++ supervisor/hassos.py | 56 +-------- supervisor/homeassistant.py | 2 +- supervisor/hwmon.py | 2 +- supervisor/tasks.py | 28 ++++- supervisor/utils/gdbus.py | 2 +- supervisor/validate.py | 9 ++ 29 files changed, 536 insertions(+), 403 deletions(-) create mode 100644 supervisor/api/cli.py rename supervisor/api/{hassos.py => os.py} (56%) create mode 100644 supervisor/cli.py create mode 100644 supervisor/docker/cli.py delete mode 100644 supervisor/docker/hassos_cli.py diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ea1a90de5..c4ca20cbd 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,92 +1,90 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "Run Testenv", - "type": "shell", - "command": "./scripts/test_env.sh", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "problemMatcher": [] - }, - { - "label": "Run Testenv CLI", - "type": "shell", - "command": "docker run --rm -ti -v /etc/machine-id:/etc/machine-id --network=hassio --add-host hassio:172.30.32.2 homeassistant/amd64-hassio-cli:dev", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "problemMatcher": [] - }, - { - "label": "Update UI", - "type": "shell", - "command": "./scripts/update-frontend.sh", - "group": { - "kind": "build", - "isDefault": true - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "problemMatcher": [] - }, - { - "label": "Pytest", - "type": "shell", - "command": "pytest --timeout=10 tests", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "problemMatcher": [] - }, - { - "label": "Flake8", - "type": "shell", - "command": "flake8 hassio tests", - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "problemMatcher": [] - }, - { - "label": "Pylint", - "type": "shell", - "command": "pylint hassio", - "dependsOn": [ - "Install all Requirements" - ], - "group": { - "kind": "test", - "isDefault": true, - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "problemMatcher": [] - } - ] -} \ No newline at end of file + "version": "2.0.0", + "tasks": [ + { + "label": "Run Testenv", + "type": "shell", + "command": "./scripts/test_env.sh", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Run Testenv CLI", + "type": "shell", + "command": "docker exec -ti hassio_cli /usr/bin/cli.sh", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Update UI", + "type": "shell", + "command": "./scripts/update-frontend.sh", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Pytest", + "type": "shell", + "command": "pytest --timeout=10 tests", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Flake8", + "type": "shell", + "command": "flake8 hassio tests", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Pylint", + "type": "shell", + "command": "pylint hassio", + "dependsOn": ["Install all Requirements"], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] +} diff --git a/API.md b/API.md index 63a954591..f60930681 100644 --- a/API.md +++ b/API.md @@ -291,9 +291,7 @@ return: ```json { "version": "2.3", - "version_cli": "7", "version_latest": "2.4", - "version_cli_latest": "8", "board": "ova|rpi", "boot": "rauc boot slot" } @@ -307,14 +305,6 @@ return: } ``` -- POST `/os/update/cli` - -```json -{ - "version": "optional" -} -``` - - POST `/os/config/sync` Load host configs from a USB stick. @@ -857,90 +847,29 @@ return: } ``` -### Audio +### DNS -- GET `/audio/info` +- GET `/dns/info` ```json { "host": "ip-address", "version": "1", "latest_version": "2", - "audio": { - "card": [ - { - "name": "...", - "index": 1, - "driver": "...", - "profiles": [ - { - "name": "...", - "description": "...", - "active": false - } - ] - } - ], - "input": [ - { - "name": "...", - "index": 0, - "description": "...", - "volume": 0.3, - "mute": false, - "default": false, - "card": "null|int", - "applications": [ - { - "name": "...", - "index": 0, - "stream_index": 0, - "stream_type": "INPUT", - "volume": 0.3, - "mute": false, - "addon": "" - } - ] - } - ], - "output": [ - { - "name": "...", - "index": 0, - "description": "...", - "volume": 0.3, - "mute": false, - "default": false, - "card": "null|int", - "applications": [ - { - "name": "...", - "index": 0, - "stream_index": 0, - "stream_type": "OUTPUT", - "volume": 0.3, - "mute": false, - "addon": "" - } - ] - } - ], - "application": [ - { - "name": "...", - "index": 0, - "stream_index": 0, - "stream_type": "OUTPUT", - "volume": 0.3, - "mute": false, - "addon": "" - } - ] - } + "servers": ["dns://8.8.8.8"], + "locals": ["dns://xy"] } ``` -- POST `/audio/update` +- POST `/dns/options` + +```json +{ + "servers": ["dns://8.8.8.8"] +} +``` + +- POST `/dns/update` ```json { @@ -948,92 +877,47 @@ return: } ``` -- POST `/audio/restart` +- POST `/dns/restart` -- POST `/audio/reload` +- POST `/dns/reset` -- GET `/audio/logs` +- GET `/dns/logs` -- POST `/audio/volume/input` +- GET `/dns/stats` ```json { - "index": "...", - "volume": 0.5 + "cpu_percent": 0.0, + "memory_usage": 283123, + "memory_limit": 329392, + "memory_percent": 1.4, + "network_tx": 0, + "network_rx": 0, + "blk_read": 0, + "blk_write": 0 } ``` -- POST `/audio/volume/output` +### CLI + +- GET `/cli/info` ```json { - "index": "...", - "volume": 0.5 + "version": "1", + "version_latest": "2" } ``` -- POST `/audio/volume/{output|input}/application` +- POST `/cli/update` ```json { - "index": "...", - "volume": 0.5 + "version": "VERSION" } ``` -- POST `/audio/mute/input` - -```json -{ - "index": "...", - "active": false -} -``` - -- POST `/audio/mute/output` - -```json -{ - "index": "...", - "active": false -} -``` - -- POST `/audio/mute/{output|input}/application` - -```json -{ - "index": "...", - "active": false -} -``` - -- POST `/audio/default/input` - -```json -{ - "name": "..." -} -``` - -- POST `/audio/default/output` - -```json -{ - "name": "..." -} -``` - -- POST `/audio/profile` - -```json -{ - "card": "...", - "name": "..." -} -``` - -- GET `/audio/stats` +- GET `/cli/stats` ```json { diff --git a/scripts/test_env.sh b/scripts/test_env.sh index ab0d675eb..fface59b2 100755 --- a/scripts/test_env.sh +++ b/scripts/test_env.sh @@ -81,11 +81,6 @@ function cleanup_docker() { } -function install_cli() { - docker pull homeassistant/amd64-hassio-cli:dev -} - - function setup_test_env() { mkdir -p /workspaces/test_supervisor @@ -131,7 +126,6 @@ start_docker trap "stop_docker" ERR build_supervisor -install_cli cleanup_lastboot cleanup_docker init_dbus diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index be2139830..865f66b8e 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -59,7 +59,7 @@ class AddonManager(CoreSysAttributes): def from_token(self, token: str) -> Optional[Addon]: """Return an add-on from Supervisor token.""" for addon in self.installed: - if token == addon.hassio_token: + if token == addon.supervisor_token: return addon return None diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 80a4efbb7..38c6b4ad4 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -164,7 +164,7 @@ class Addon(AddonModel): return self.persist[ATTR_UUID] @property - def hassio_token(self) -> Optional[str]: + def supervisor_token(self) -> Optional[str]: """Return access token for Supervisor API.""" return self.persist.get(ATTR_ACCESS_TOKEN) diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index c9bfe202d..4d4960a68 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -137,7 +137,7 @@ class AddonModel(CoreSysAttributes): return None @property - def hassio_token(self) -> Optional[str]: + def supervisor_token(self) -> Optional[str]: """Return access token for Supervisor API.""" return None diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 055af90b3..846f2ff2f 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -7,11 +7,13 @@ from aiohttp import web from ..coresys import CoreSys, CoreSysAttributes from .addons import APIAddons +from .audio import APIAudio from .auth import APIAuth +from .cli import APICli from .discovery import APIDiscovery from .dns import APICoreDNS from .hardware import APIHardware -from .hassos import APIHassOS +from .os import APIOS from .homeassistant import APIHomeAssistant from .host import APIHost from .info import APIInfo @@ -21,7 +23,6 @@ from .security import SecurityMiddleware from .services import APIServices from .snapshots import APISnapshots from .supervisor import APISupervisor -from .audio import APIAudio _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -49,7 +50,8 @@ class RestAPI(CoreSysAttributes): """Register REST API Calls.""" self._register_supervisor() self._register_host() - self._register_hassos() + self._register_os() + self._register_cli() self._register_hardware() self._register_homeassistant() self._register_proxy() @@ -84,22 +86,29 @@ class RestAPI(CoreSysAttributes): ] ) - def _register_hassos(self) -> None: - """Register HassOS functions.""" - api_hassos = APIHassOS() - api_hassos.coresys = self.coresys + def _register_os(self) -> None: + """Register OS functions.""" + api_os = APIOS() + api_os.coresys = self.coresys self.webapp.add_routes( [ - web.get("/os/info", api_hassos.info), - web.post("/os/update", api_hassos.update), - web.post("/os/update/cli", api_hassos.update_cli), - web.post("/os/config/sync", api_hassos.config_sync), - # Remove with old Supervisor fallback - web.get("/hassos/info", api_hassos.info), - web.post("/hassos/update", api_hassos.update), - web.post("/hassos/update/cli", api_hassos.update_cli), - web.post("/hassos/config/sync", api_hassos.config_sync), + web.get("/os/info", api_os.info), + web.post("/os/update", api_os.update), + web.post("/os/config/sync", api_os.config_sync), + ] + ) + + def _register_cli(self) -> None: + """Register HA cli functions.""" + api_cli = APICli() + api_cli.coresys = self.coresys + + self.webapp.add_routes( + [ + web.get("/cli/info", api_cli.info), + web.get("/cli/stats", api_cli.stats), + web.post("/cli/update", api_cli.update), ] ) diff --git a/supervisor/api/cli.py b/supervisor/api/cli.py new file mode 100644 index 000000000..34cf13545 --- /dev/null +++ b/supervisor/api/cli.py @@ -0,0 +1,62 @@ +"""Init file for Supervisor HA cli RESTful API.""" +import asyncio +import logging +from typing import Any, Dict + +from aiohttp import web +import voluptuous as vol + +from ..const import ( + ATTR_VERSION, + ATTR_VERSION_LATEST, + ATTR_BLK_READ, + ATTR_BLK_WRITE, + ATTR_CPU_PERCENT, + ATTR_MEMORY_LIMIT, + ATTR_MEMORY_PERCENT, + ATTR_MEMORY_USAGE, + ATTR_NETWORK_RX, + ATTR_NETWORK_TX, +) +from ..coresys import CoreSysAttributes +from .utils import api_process, api_validate + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)}) + + +class APICli(CoreSysAttributes): + """Handle RESTful API for HA Cli functions.""" + + @api_process + async def info(self, request: web.Request) -> Dict[str, Any]: + """Return HA cli information.""" + return { + ATTR_VERSION: self.sys_cli.version, + ATTR_VERSION_LATEST: self.sys_cli.latest_version, + } + + @api_process + async def stats(self, request: web.Request) -> Dict[str, Any]: + """Return resource information.""" + stats = await self.sys_cli.stats() + + return { + ATTR_CPU_PERCENT: stats.cpu_percent, + ATTR_MEMORY_USAGE: stats.memory_usage, + ATTR_MEMORY_LIMIT: stats.memory_limit, + ATTR_MEMORY_PERCENT: stats.memory_percent, + ATTR_NETWORK_RX: stats.network_rx, + ATTR_NETWORK_TX: stats.network_tx, + ATTR_BLK_READ: stats.blk_read, + ATTR_BLK_WRITE: stats.blk_write, + } + + @api_process + async def update(self, request: web.Request) -> None: + """Update HA CLI.""" + body = await api_validate(SCHEMA_VERSION, request) + version = body.get(ATTR_VERSION, self.sys_cli.latest_version) + + await asyncio.shield(self.sys_cli.update(version)) diff --git a/supervisor/api/hassos.py b/supervisor/api/os.py similarity index 56% rename from supervisor/api/hassos.py rename to supervisor/api/os.py index 95128f525..e89474d55 100644 --- a/supervisor/api/hassos.py +++ b/supervisor/api/os.py @@ -10,8 +10,6 @@ from ..const import ( ATTR_BOARD, ATTR_BOOT, ATTR_VERSION, - ATTR_VERSION_CLI, - ATTR_VERSION_CLI_LATEST, ATTR_VERSION_LATEST, ) from ..coresys import CoreSysAttributes @@ -22,38 +20,28 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)}) -class APIHassOS(CoreSysAttributes): - """Handle RESTful API for HassOS functions.""" +class APIOS(CoreSysAttributes): + """Handle RESTful API for OS functions.""" @api_process async def info(self, request: web.Request) -> Dict[str, Any]: - """Return HassOS information.""" + """Return OS information.""" return { ATTR_VERSION: self.sys_hassos.version, - ATTR_VERSION_CLI: self.sys_hassos.version_cli, - ATTR_VERSION_LATEST: self.sys_hassos.version_latest, - ATTR_VERSION_CLI_LATEST: self.sys_hassos.version_cli_latest, + ATTR_VERSION_LATEST: self.sys_hassos.latest_version, ATTR_BOARD: self.sys_hassos.board, ATTR_BOOT: self.sys_dbus.rauc.boot_slot, } @api_process async def update(self, request: web.Request) -> None: - """Update HassOS.""" + """Update OS.""" body = await api_validate(SCHEMA_VERSION, request) - version = body.get(ATTR_VERSION, self.sys_hassos.version_latest) + version = body.get(ATTR_VERSION, self.sys_hassos.latest_version) await asyncio.shield(self.sys_hassos.update(version)) - @api_process - async def update_cli(self, request: web.Request) -> None: - """Update HassOS CLI.""" - body = await api_validate(SCHEMA_VERSION, request) - version = body.get(ATTR_VERSION, self.sys_hassos.version_cli_latest) - - await asyncio.shield(self.sys_hassos.update_cli(version)) - @api_process def config_sync(self, request: web.Request) -> Awaitable[None]: - """Trigger config reload on HassOS.""" + """Trigger config reload on OS.""" return asyncio.shield(self.sys_hassos.config_sync()) diff --git a/supervisor/api/proxy.py b/supervisor/api/proxy.py index 9e8204301..f7f6d74f8 100644 --- a/supervisor/api/proxy.py +++ b/supervisor/api/proxy.py @@ -26,11 +26,11 @@ class APIProxy(CoreSysAttributes): """Check the Supervisor token.""" if AUTHORIZATION in request.headers: bearer = request.headers[AUTHORIZATION] - hassio_token = bearer.split(" ")[-1] + supervisor_token = bearer.split(" ")[-1] else: - hassio_token = request.headers.get(HEADER_HA_ACCESS) + supervisor_token = request.headers.get(HEADER_HA_ACCESS) - addon = self.sys_addons.from_token(hassio_token) + addon = self.sys_addons.from_token(supervisor_token) if not addon: _LOGGER.warning("Unknown Home Assistant API access!") elif not addon.access_homeassistant_api: @@ -177,8 +177,10 @@ class APIProxy(CoreSysAttributes): # Check API access response = await server.receive_json() - hassio_token = response.get("api_password") or response.get("access_token") - addon = self.sys_addons.from_token(hassio_token) + supervisor_token = response.get("api_password") or response.get( + "access_token" + ) + addon = self.sys_addons.from_token(supervisor_token) if not addon or not addon.access_homeassistant_api: _LOGGER.warning("Unauthorized WebSocket access!") diff --git a/supervisor/api/security.py b/supervisor/api/security.py index e338600be..663c4b062 100644 --- a/supervisor/api/security.py +++ b/supervisor/api/security.py @@ -75,6 +75,7 @@ ADDONS_ROLE_ACCESS = { r"^(?:" r"|/audio/.*" r"|/dns/.*" + r"|/cli/.*" r"|/core/.+" r"|/homeassistant/.+" r"|/host/.+" @@ -123,12 +124,13 @@ class SecurityMiddleware(CoreSysAttributes): raise HTTPUnauthorized() # Home-Assistant - if supervisor_token == self.sys_homeassistant.hassio_token: + if supervisor_token == self.sys_homeassistant.supervisor_token: _LOGGER.debug("%s access from Home Assistant", request.path) request_from = self.sys_homeassistant # Host - if supervisor_token == self.sys_machine_id: + # Remove machine_id handling later if all use new CLI + if supervisor_token in (self.sys_machine_id, self.sys_cli.supervisor_token): _LOGGER.debug("%s access from Host", request.path) request_from = self.sys_host diff --git a/supervisor/audio.py b/supervisor/audio.py index e7d84236d..3a4adfe61 100644 --- a/supervisor/audio.py +++ b/supervisor/audio.py @@ -49,7 +49,7 @@ class Audio(JsonConfig, CoreSysAttributes): @version.setter def version(self, value: str) -> None: - """Return current version of Audio.""" + """Set current version of Audio.""" self._data[ATTR_VERSION] = value @property diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 1c467a246..26ae31352 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -14,6 +14,7 @@ from .auth import Auth from .audio import Audio from .const import SOCKET_DOCKER, UpdateChannels from .core import Core +from .cli import HaCli from .coresys import CoreSys from .dbus import DBusManager from .discovery import Discovery @@ -67,6 +68,7 @@ async def initialize_coresys(): coresys.dbus = DBusManager(coresys) coresys.hassos = HassOS(coresys) coresys.secrets = SecretsManager(coresys) + coresys.cli = HaCli(coresys) # bootstrap config initialize_system_data(coresys) diff --git a/supervisor/cli.py b/supervisor/cli.py new file mode 100644 index 000000000..6b2d9bc49 --- /dev/null +++ b/supervisor/cli.py @@ -0,0 +1,156 @@ +"""CLI support on supervisor.""" +import asyncio +from contextlib import suppress +import logging +import secrets +from typing import Awaitable, Optional + +from .const import ATTR_ACCESS_TOKEN, ATTR_VERSION, FILE_HASSIO_CLI +from .coresys import CoreSys, CoreSysAttributes +from .docker.cli import DockerCli +from .docker.stats import DockerStats +from .exceptions import CliError, CliUpdateError, DockerAPIError +from .utils.json import JsonConfig +from .validate import SCHEMA_CLI_CONFIG + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class HaCli(CoreSysAttributes, JsonConfig): + """HA cli interface inside supervisor.""" + + def __init__(self, coresys: CoreSys): + """Initialize cli handler.""" + super().__init__(FILE_HASSIO_CLI, SCHEMA_CLI_CONFIG) + 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 latest_version(self) -> str: + """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.""" + return self.version != self.latest_version + + @property + def supervisor_token(self) -> str: + """Return an access token for the Supervisor API.""" + return self._data.get(ATTR_ACCESS_TOKEN) + + @property + def in_progress(self) -> bool: + """Return True if a task is in progress.""" + return self.instance.in_progress + + async def load(self) -> None: + """Load cli setup.""" + # Check cli state + try: + # Evaluate Version if we lost this information + if not self.version: + self.version = await self.instance.get_latest_version(key=int) + + await self.instance.attach(tag=self.version) + except DockerAPIError: + _LOGGER.info("No Audio plugin Docker image %s found.", self.instance.image) + + # Install cli + with suppress(CliError): + await self.install() + else: + self.version = self.instance.version + self.save_data() + + # Run PulseAudio + with suppress(CliError): + if not await self.instance.is_running(): + await self.start() + + async def install(self) -> None: + """Install cli.""" + _LOGGER.info("Setup cli plugin") + while True: + # read audio tag and install it + if not self.latest_version: + await self.sys_updater.reload() + + if self.latest_version: + with suppress(DockerAPIError): + await self.instance.install(self.latest_version) + break + _LOGGER.warning("Error on install cli plugin. Retry in 30sec") + await asyncio.sleep(30) + + _LOGGER.info("cli plugin now installed") + self.version = self.instance.version + self.save_data() + + async def update(self, version: Optional[str] = None) -> None: + """Update local HA cli.""" + version = version or self.latest_version + + if version == self.version: + _LOGGER.warning("Version %s is already installed for cli", version) + return + + try: + await self.instance.update(version, latest=True) + except DockerAPIError: + _LOGGER.error("HA cli update fails") + raise CliUpdateError() from None + else: + # Cleanup + with suppress(DockerAPIError): + await self.instance.cleanup() + + async def start(self) -> None: + """Run cli.""" + # Create new API token + self._data[ATTR_ACCESS_TOKEN] = secrets.token_hex(56) + self.save_data() + + # Start Instance + _LOGGER.info("Start cli plugin") + try: + await self.instance.run() + except DockerAPIError: + _LOGGER.error("Can't start cli plugin") + raise CliError() from None + + async def stats(self) -> DockerStats: + """Return stats of cli.""" + try: + return await self.instance.stats() + except DockerAPIError: + raise CliError() from None + + def is_running(self) -> Awaitable[bool]: + """Return True if Docker container is running. + + Return a coroutine. + """ + return self.instance.is_running() + + async def repair(self) -> None: + """Repair cli container.""" + if await self.instance.exists(): + return + + _LOGGER.info("Repair HA cli %s", self.version) + try: + await self.instance.install(self.version, latest=True) + except DockerAPIError: + _LOGGER.error("Repairing of HA cli fails") diff --git a/supervisor/const.py b/supervisor/const.py index da5dac980..2f79ed49f 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -27,6 +27,7 @@ FILE_HASSIO_DISCOVERY = Path(SUPERVISOR_DATA, "discovery.json") FILE_HASSIO_INGRESS = Path(SUPERVISOR_DATA, "ingress.json") FILE_HASSIO_DNS = Path(SUPERVISOR_DATA, "dns.json") FILE_HASSIO_AUDIO = Path(SUPERVISOR_DATA, "audio.json") +FILE_HASSIO_CLI = Path(SUPERVISOR_DATA, "cli.json") SOCKET_DOCKER = Path("/run/docker.sock") @@ -189,9 +190,6 @@ ATTR_DEVICETREE = "devicetree" ATTR_CPE = "cpe" ATTR_BOARD = "board" ATTR_HASSOS = "hassos" -ATTR_HASSOS_CLI = "hassos_cli" -ATTR_VERSION_CLI = "version_cli" -ATTR_VERSION_CLI_LATEST = "version_cli_latest" ATTR_REFRESH_TOKEN = "refresh_token" ATTR_ACCESS_TOKEN = "access_token" ATTR_DOCKER_API = "docker_api" diff --git a/supervisor/core.py b/supervisor/core.py index bc7f62371..77d0ee5a1 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -41,7 +41,9 @@ class Core(CoreSysAttributes): await self.sys_host.load() # Load Plugins container - await asyncio.wait([self.sys_dns.load(), self.sys_audio.load()]) + await asyncio.wait( + [self.sys_dns.load(), self.sys_audio.load(), self.sys_cli.load()] + ) # Load Home Assistant await self.sys_homeassistant.load() @@ -203,13 +205,11 @@ class Core(CoreSysAttributes): # Restore core functionality await self.sys_dns.repair() + await self.sys_audio.repair() + await self.sys_cli.repair() await self.sys_addons.repair() await self.sys_homeassistant.repair() - # Fix HassOS specific - if self.sys_hassos.available: - await self.sys_hassos.repair_cli() - # Tag version for latest await self.sys_supervisor.repair() _LOGGER.info("Finished repairing of Supervisor Environment") diff --git a/supervisor/coresys.py b/supervisor/coresys.py index e4aae72b1..6015df87c 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from .audio import Audio from .auth import Auth from .core import Core + from .cli import HaCli from .dbus import DBusManager from .discovery import Discovery from .dns import CoreDNS @@ -62,6 +63,7 @@ class CoreSys: self._audio: Optional[Audio] = None self._auth: Optional[Auth] = None self._dns: Optional[CoreDNS] = None + self._cli: Optional[HaCli] = None self._homeassistant: Optional[HomeAssistant] = None self._supervisor: Optional[Supervisor] = None self._addons: Optional[AddonManager] = None @@ -143,6 +145,18 @@ class CoreSys: raise RuntimeError("Core already set!") self._core = value + @property + def cli(self) -> HaCli: + """Return HaCli object.""" + return self._cli + + @cli.setter + def cli(self, value: HaCli): + """Set a HaCli object.""" + if self._cli: + raise RuntimeError("HaCli already set!") + self._cli = value + @property def arch(self) -> CpuArch: """Return CpuArch object.""" @@ -449,6 +463,11 @@ class CoreSysAttributes: """Return core object.""" return self.coresys.core + @property + def sys_cli(self) -> HaCli: + """Return HaCli object.""" + return self.coresys.cli + @property def sys_arch(self) -> CpuArch: """Return CpuArch object.""" diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index a8e27d650..05dd420a3 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -117,8 +117,8 @@ class DockerAddon(DockerInterface): return { **addon_env, ENV_TIME: self.sys_timezone, - ENV_TOKEN: self.addon.hassio_token, - ENV_TOKEN_OLD: self.addon.hassio_token, + ENV_TOKEN: self.addon.supervisor_token, + ENV_TOKEN_OLD: self.addon.supervisor_token, } @property diff --git a/supervisor/docker/cli.py b/supervisor/docker/cli.py new file mode 100644 index 000000000..08ebca7d3 --- /dev/null +++ b/supervisor/docker/cli.py @@ -0,0 +1,64 @@ +"""HA Cli docker object.""" +from contextlib import suppress +import logging + +from ..coresys import CoreSysAttributes +from ..exceptions import DockerAPIError +from .interface import DockerInterface +from ..const import ENV_TIME, ENV_TOKEN + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +CLI_DOCKER_NAME: str = "hassio_cli" + + +class DockerCli(DockerInterface, CoreSysAttributes): + """Docker Supervisor wrapper for HA cli.""" + + @property + def image(self): + """Return name of HA cli image.""" + return f"homeassistant/{self.sys_arch.supervisor}-hassio-cli" + + @property + def name(self) -> str: + """Return name of Docker container.""" + return CLI_DOCKER_NAME + + def _run(self) -> None: + """Run Docker image. + + Need run inside executor. + """ + if self._is_running(): + return + + # Cleanup + with suppress(DockerAPIError): + self._stop() + + # Create & Run container + docker_container = self.sys_docker.run( + self.image, + entrypoint=["/init"], + command=["/bin/bash", "-c", "sleep infinity"], + version=self.sys_cli.version, + init=False, + ipv4=self.sys_docker.network.cli, + name=self.name, + hostname=self.name.replace("_", "-"), + detach=True, + extra_hosts={"supervisor": self.sys_docker.network.supervisor}, + environment={ + ENV_TIME: self.sys_timezone, + ENV_TOKEN: self.sys_cli.supervisor_token, + }, + ) + + self._meta = docker_container.attrs + _LOGGER.info( + "Start CLI %s with version %s - %s", + self.image, + self.version, + self.sys_docker.network.audio, + ) diff --git a/supervisor/docker/hassos_cli.py b/supervisor/docker/hassos_cli.py deleted file mode 100644 index 64b30c858..000000000 --- a/supervisor/docker/hassos_cli.py +++ /dev/null @@ -1,38 +0,0 @@ -"""HassOS Cli docker object.""" -import logging - -import docker - -from ..coresys import CoreSysAttributes -from .interface import DockerInterface - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -class DockerHassOSCli(DockerInterface, CoreSysAttributes): - """Docker Supervisor wrapper for HassOS Cli.""" - - @property - def image(self): - """Return name of HassOS CLI image.""" - return f"homeassistant/{self.sys_arch.supervisor}-hassio-cli" - - def _stop(self, remove_container=True): - """Don't need stop.""" - return True - - def _attach(self, tag: str): - """Attach to running Docker container. - Need run inside executor. - """ - try: - image = self.sys_docker.images.get(f"{self.image}:{tag}") - - except docker.errors.DockerException: - _LOGGER.warning("Can't find a HassOS CLI %s", self.image) - - else: - self._meta = image.attrs - _LOGGER.info( - "Found HassOS CLI %s with version %s", self.image, self.version - ) diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index 3e0eb2c89..95cc3e419 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -112,8 +112,8 @@ class DockerHomeAssistant(DockerInterface): "HASSIO": self.sys_docker.network.supervisor, "SUPERVISOR": self.sys_docker.network.supervisor, ENV_TIME: self.sys_timezone, - ENV_TOKEN: self.sys_homeassistant.hassio_token, - ENV_TOKEN_OLD: self.sys_homeassistant.hassio_token, + ENV_TOKEN: self.sys_homeassistant.supervisor_token, + ENV_TOKEN_OLD: self.sys_homeassistant.supervisor_token, }, ) diff --git a/supervisor/docker/network.py b/supervisor/docker/network.py index 1a0fbcf4e..de78aa273 100644 --- a/supervisor/docker/network.py +++ b/supervisor/docker/network.py @@ -53,6 +53,11 @@ class DockerNetwork: """Return audio of the network.""" return DOCKER_NETWORK_MASK[4] + @property + def cli(self) -> IPv4Address: + """Return cli of the network.""" + return DOCKER_NETWORK_MASK[5] + def _get_network(self) -> docker.models.networks.Network: """Get supervisor network.""" try: diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index 30b35c11e..c5484daff 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -54,6 +54,17 @@ class HassOSNotSupportedError(HassioNotSupportedError): """Function not supported by HassOS.""" +# HaCli + + +class CliError(HassioError): + """HA cli exception.""" + + +class CliUpdateError(HassOSError): + """Error on update of a HA cli.""" + + # DNS diff --git a/supervisor/hassos.py b/supervisor/hassos.py index a7f745251..8645a648b 100644 --- a/supervisor/hassos.py +++ b/supervisor/hassos.py @@ -1,6 +1,5 @@ """HassOS support on supervisor.""" import asyncio -from contextlib import suppress import logging from pathlib import Path from typing import Awaitable, Optional @@ -10,12 +9,10 @@ from cpe import CPE from .const import URL_HASSOS_OTA from .coresys import CoreSysAttributes, CoreSys -from .docker.hassos_cli import DockerHassOSCli from .exceptions import ( DBusError, HassOSNotSupportedError, HassOSUpdateError, - DockerAPIError, ) from .dbus.rauc import RaucState @@ -28,7 +25,6 @@ class HassOS(CoreSysAttributes): def __init__(self, coresys: CoreSys): """Initialize HassOS handler.""" self.coresys: CoreSys = coresys - self.instance: DockerHassOSCli = DockerHassOSCli(coresys) self._available: bool = False self._version: Optional[str] = None self._board: Optional[str] = None @@ -44,29 +40,14 @@ class HassOS(CoreSysAttributes): return self._version @property - def version_cli(self) -> Optional[str]: - """Return version of HassOS cli.""" - return self.instance.version - - @property - def version_latest(self) -> str: + def latest_version(self) -> str: """Return version of HassOS.""" return self.sys_updater.version_hassos - @property - def version_cli_latest(self) -> str: - """Return version of HassOS.""" - return self.sys_updater.version_cli - @property def need_update(self) -> bool: """Return true if a HassOS update is available.""" - return self.version != self.version_latest - - @property - def need_cli_update(self) -> bool: - """Return true if a HassOS cli update is available.""" - return self.version_cli != self.version_cli_latest + return self.version != self.latest_version @property def board(self) -> Optional[str]: @@ -134,8 +115,6 @@ class HassOS(CoreSysAttributes): _LOGGER.info( "Detect HassOS %s / BootSlot %s", self.version, self.sys_dbus.rauc.boot_slot ) - with suppress(DockerAPIError): - await self.instance.attach(tag="latest") def config_sync(self) -> Awaitable[None]: """Trigger a host config reload from usb. @@ -149,7 +128,7 @@ class HassOS(CoreSysAttributes): async def update(self, version: Optional[str] = None) -> None: """Update HassOS system.""" - version = version or self.version_latest + version = version or self.latest_version # Check installed version self._check_host() @@ -183,35 +162,6 @@ class HassOS(CoreSysAttributes): _LOGGER.error("HassOS update fails with: %s", self.sys_dbus.rauc.last_error) raise HassOSUpdateError() - async def update_cli(self, version: Optional[str] = None) -> None: - """Update local HassOS cli.""" - version = version or self.version_cli_latest - - if version == self.version_cli: - _LOGGER.warning("Version %s is already installed for CLI", version) - return - - try: - await self.instance.update(version, latest=True) - except DockerAPIError: - _LOGGER.error("HassOS CLI update fails") - raise HassOSUpdateError() from None - else: - # Cleanup - with suppress(DockerAPIError): - await self.instance.cleanup() - - async def repair_cli(self) -> None: - """Repair CLI container.""" - if await self.instance.exists(): - return - - _LOGGER.info("Repair HassOS CLI %s", self.version_cli) - try: - await self.instance.install(self.version_cli, latest=True) - except DockerAPIError: - _LOGGER.error("Repairing of HassOS CLI fails") - async def mark_healthy(self): """Set booted partition as good for rauc.""" try: diff --git a/supervisor/homeassistant.py b/supervisor/homeassistant.py index 30780269c..481c7355e 100644 --- a/supervisor/homeassistant.py +++ b/supervisor/homeassistant.py @@ -221,7 +221,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): return self._data[ATTR_UUID] @property - def hassio_token(self) -> str: + def supervisor_token(self) -> str: """Return an access token for the Supervisor API.""" return self._data.get(ATTR_ACCESS_TOKEN) diff --git a/supervisor/hwmon.py b/supervisor/hwmon.py index 6ce1d90bb..13b7baf3c 100644 --- a/supervisor/hwmon.py +++ b/supervisor/hwmon.py @@ -28,7 +28,7 @@ class HwMonitor(CoreSysAttributes): self.monitor = pyudev.Monitor.from_netlink(self.context) self.observer = pyudev.MonitorObserver(self.monitor, self._udev_events) except OSError: - _LOGGER.fatal("Not privileged to run udev. Update your installation!") + _LOGGER.fatal("Not privileged to run udev monitor!") else: self.observer.start() _LOGGER.info("Started Supervisor hardware monitor") diff --git a/supervisor/tasks.py b/supervisor/tasks.py index 444453ea8..a24f9b3f5 100644 --- a/supervisor/tasks.py +++ b/supervisor/tasks.py @@ -25,7 +25,8 @@ RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15 RUN_WATCHDOG_HOMEASSISTANT_API = 300 RUN_WATCHDOG_DNS_DOCKER = 20 -RUN_WATCHDOG_AUDIO_DOCKER = 20 +RUN_WATCHDOG_AUDIO_DOCKER = 30 +RUN_WATCHDOG_CLI_DOCKER = 40 class Tasks(CoreSysAttributes): @@ -102,6 +103,11 @@ class Tasks(CoreSysAttributes): self._watchdog_audio_docker, RUN_WATCHDOG_AUDIO_DOCKER ) ) + self.jobs.add( + self.sys_scheduler.register_task( + self._watchdog_cli_docker, RUN_WATCHDOG_CLI_DOCKER + ) + ) _LOGGER.info("All core tasks are scheduled") @@ -202,12 +208,12 @@ class Tasks(CoreSysAttributes): self._cache[HASS_WATCHDOG_API] = 0 async def _update_cli(self): - """Check and run update of CLI.""" - if not self.sys_hassos.need_cli_update: + """Check and run update of cli.""" + if not self.sys_cli.need_update: return - _LOGGER.info("Found new CLI version") - await self.sys_hassos.update_cli() + _LOGGER.info("Found new cli version") + await self.sys_cli.update() async def _update_dns(self): """Check and run update of CoreDNS plugin.""" @@ -254,3 +260,15 @@ class Tasks(CoreSysAttributes): await self.sys_audio.start() except CoreDNSError: _LOGGER.error("Watchdog PulseAudio reanimation fails!") + + async def _watchdog_cli_docker(self): + """Check running state of Docker and start if they is close.""" + # if cli plugin is active + if await self.sys_cli.is_running() or self.sys_cli.in_progress: + return + _LOGGER.warning("Watchdog found a problem with cli plugin!") + + try: + await self.sys_cli.start() + except CoreDNSError: + _LOGGER.error("Watchdog cli reanimation fails!") diff --git a/supervisor/utils/gdbus.py b/supervisor/utils/gdbus.py index 617fa3c83..62bf0ad74 100644 --- a/supervisor/utils/gdbus.py +++ b/supervisor/utils/gdbus.py @@ -208,7 +208,7 @@ class DBus: raise exception() # General - _LOGGER.error("DBus return error: %s", error) + _LOGGER.error("DBus return error: %s", error.strip()) raise DBusFatalError() def attach_signals(self, filters=None): diff --git a/supervisor/validate.py b/supervisor/validate.py index 7fb1df5b5..d4ca5835c 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -182,3 +182,12 @@ SCHEMA_DNS_CONFIG = vol.Schema( SCHEMA_AUDIO_CONFIG = vol.Schema( {vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str))}, extra=vol.REMOVE_EXTRA, ) + + +SCHEMA_CLI_CONFIG = vol.Schema( + { + vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str)), + vol.Optional(ATTR_ACCESS_TOKEN): token, + }, + extra=vol.REMOVE_EXTRA, +)