Merge pull request #1602 from home-assistant/dev

Release 210
This commit is contained in:
Pascal Vizeli 2020-03-27 17:46:51 +01:00 committed by GitHub
commit fa783a0d2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 617 additions and 506 deletions

16
.vscode/tasks.json vendored
View File

@ -7,7 +7,7 @@
"command": "./scripts/test_env.sh",
"group": {
"kind": "test",
"isDefault": true,
"isDefault": true
},
"presentation": {
"reveal": "always",
@ -18,10 +18,10 @@
{
"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",
"command": "docker exec -ti hassio_cli /usr/bin/cli.sh",
"group": {
"kind": "test",
"isDefault": true,
"isDefault": true
},
"presentation": {
"reveal": "always",
@ -49,7 +49,7 @@
"command": "pytest --timeout=10 tests",
"group": {
"kind": "test",
"isDefault": true,
"isDefault": true
},
"presentation": {
"reveal": "always",
@ -63,7 +63,7 @@
"command": "flake8 hassio tests",
"group": {
"kind": "test",
"isDefault": true,
"isDefault": true
},
"presentation": {
"reveal": "always",
@ -75,12 +75,10 @@
"label": "Pylint",
"type": "shell",
"command": "pylint hassio",
"dependsOn": [
"Install all Requirements"
],
"dependsOn": ["Install all Requirements"],
"group": {
"kind": "test",
"isDefault": true,
"isDefault": true
},
"presentation": {
"reveal": "always",

182
API.md
View File

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

View File

@ -8,53 +8,19 @@ trigger:
pr: none
variables:
- name: versionWheels
value: "1.6-3.7-alpine3.11"
- group: wheels
value: '1.6.1-3.7-alpine3.11'
resources:
repositories:
- repository: azure
type: github
name: 'home-assistant/ci-azure'
endpoint: 'home-assistant'
jobs:
- job: "Wheels"
timeoutInMinutes: 360
pool:
vmImage: "ubuntu-latest"
strategy:
maxParallel: 5
matrix:
amd64:
buildArch: "amd64"
i386:
buildArch: "i386"
armhf:
buildArch: "armhf"
armv7:
buildArch: "armv7"
aarch64:
buildArch: "aarch64"
steps:
- script: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
qemu-user-static \
binfmt-support \
curl
sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc
sudo update-binfmts --enable qemu-arm
sudo update-binfmts --enable qemu-aarch64
displayName: "Initial cross build"
- script: |
mkdir -p .ssh
echo -e "-----BEGIN RSA PRIVATE KEY-----\n$(wheelsSSH)\n-----END RSA PRIVATE KEY-----" >> .ssh/id_rsa
ssh-keyscan -H $(wheelsHost) >> .ssh/known_hosts
chmod 600 .ssh/*
displayName: "Install ssh key"
- script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels)
displayName: "Install wheels builder"
- script: |
sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \
homeassistant/$(buildArch)-wheels:$(versionWheels) \
--apk "build-base;libffi-dev;openssl-dev" \
--index $(wheelsIndex) \
--requirement requirements.txt \
--upload rsync \
--remote wheels@$(wheelsHost):/opt/wheels
displayName: "Run wheels build"
- template: templates/azp-job-wheels.yaml@azure
parameters:
builderVersion: '$(versionWheels)'
builderApk: 'build-base;libffi-dev;openssl-dev'
builderPip: 'Cython'
wheelsRequirement: 'requirements.txt'

View File

@ -1,7 +1,7 @@
aiohttp==3.6.1
async_timeout==3.0.1
attrs==19.3.0
cchardet==2.1.5
cchardet==2.1.6
colorlog==4.1.0
cpe==1.2.1
cryptography==2.8

View File

@ -1,6 +1,6 @@
flake8==3.7.9
pylint==2.4.4
pytest==5.3.5
pytest==5.4.1
pytest-timeout==1.3.4
pytest-aiohttp==0.3.0
black==19.10b0

View File

@ -2,4 +2,6 @@
# ==============================================================================
# Start Service service
# ==============================================================================
export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2"
exec python3 -m supervisor

View File

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

View File

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

View File

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

View File

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

View File

@ -261,7 +261,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema(
),
vol.Optional(ATTR_IMAGE): vol.Match(RE_DOCKER_IMAGE),
vol.Optional(ATTR_TIMEOUT, default=10): vol.All(
vol.Coerce(int), vol.Range(min=10, max=120)
vol.Coerce(int), vol.Range(min=10, max=300)
),
},
extra=vol.REMOVE_EXTRA,

View File

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

View File

@ -166,5 +166,5 @@ class APIAudio(CoreSysAttributes):
body = await api_validate(SCHEMA_PROFILE, request)
await asyncio.shield(
self.sys_host.sound.set_profile(body[ATTR_CARD], body[ATTR_NAME])
self.sys_host.sound.ativate_profile(body[ATTR_CARD], body[ATTR_NAME])
)

62
supervisor/api/cli.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

156
supervisor/cli.py Normal file
View File

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

View File

@ -3,7 +3,7 @@ from enum import Enum
from ipaddress import ip_network
from pathlib import Path
SUPERVISOR_VERSION = "209"
SUPERVISOR_VERSION = "210"
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
@ -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")
@ -34,7 +35,6 @@ DOCKER_NETWORK = "hassio"
DOCKER_NETWORK_MASK = ip_network("172.30.32.0/23")
DOCKER_NETWORK_RANGE = ip_network("172.30.33.0/24")
DNS_SERVERS = ["dns://1.1.1.1", "dns://9.9.9.9"]
DNS_SUFFIX = "local.hass.io"
LABEL_VERSION = "io.hass.version"
@ -190,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"

View File

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

View File

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

View File

@ -1,15 +1,31 @@
.:53 {
log
errors
loop
hosts /config/hosts {
fallthrough
}
template ANY AAAA local.hass.io hassio {
rcode NOERROR
}
forward . $servers {
forward . {{ locals | join(" ") }} dns://127.0.0.1:5353 {
except local.hass.io
policy sequential
health_check 5s
}
fallback REFUSED . dns://127.0.0.1:5353
fallback SERVFAIL . dns://127.0.0.1:5353
fallback NXDOMAIN . dns://127.0.0.1:5353
cache 10
}
.:5353 {
log
errors
forward . tls://1.1.1.1 tls://1.0.0.1 {
tls_servername cloudflare-dns.com
except local.hass.io
health_check 10s
}
cache 30
}

View File

@ -1,11 +0,0 @@
"""Discovery service for Home Panel."""
import voluptuous as vol
from supervisor.validate import network_port
from ..const import ATTR_HOST, ATTR_PORT
SCHEMA = vol.Schema(
{vol.Required(ATTR_HOST): vol.Coerce(str), vol.Required(ATTR_PORT): network_port}
)

View File

@ -4,13 +4,13 @@ from contextlib import suppress
from ipaddress import IPv4Address
import logging
from pathlib import Path
from string import Template
from typing import Awaitable, List, Optional
import attr
import jinja2
import voluptuous as vol
from .const import ATTR_SERVERS, ATTR_VERSION, DNS_SERVERS, DNS_SUFFIX, FILE_HASSIO_DNS
from .const import ATTR_SERVERS, ATTR_VERSION, DNS_SUFFIX, FILE_HASSIO_DNS
from .coresys import CoreSys, CoreSysAttributes
from .docker.dns import DockerDNS
from .docker.stats import DockerStats
@ -42,8 +42,10 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
self.coresys: CoreSys = coresys
self.instance: DockerDNS = DockerDNS(coresys)
self.forwarder: DNSForward = DNSForward()
self.coredns_template: Optional[jinja2.Template] = None
self._hosts: List[HostEntry] = []
self._loop: bool = False
@property
def corefile(self) -> Path:
@ -116,6 +118,12 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
# Start DNS forwarder
self.sys_create_task(self.forwarder.start(self.sys_docker.network.dns))
# Initialize CoreDNS Template
try:
self.coredns_template = jinja2.Template(COREDNS_TMPL.read_text())
except OSError as err:
_LOGGER.error("Can't read coredns.tmpl: %s", err)
# Run CoreDNS
with suppress(CoreDNSError):
if await self.instance.is_running():
@ -202,30 +210,43 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
self.hosts.unlink()
self._init_hosts()
# Reset loop protection
self._loop = False
await self.sys_addons.sync_dns()
async def loop_detection(self) -> None:
"""Check if there was a loop found."""
log = await self.instance.logs()
# Check the log for loop plugin output
if b"plugin/loop: Loop" in log:
_LOGGER.error("Detect a DNS loop in local Network!")
self._loop = True
else:
self._loop = False
def _write_corefile(self) -> None:
"""Write CoreDNS config."""
dns_servers: List[str] = []
# Load Template
try:
corefile_template: Template = Template(COREDNS_TMPL.read_text())
except OSError as err:
_LOGGER.error("Can't read coredns template file: %s", err)
raise CoreDNSError() from None
local_dns: List[str] = []
servers: List[str] = []
# Prepare DNS serverlist: Prio 1 Manual, Prio 2 Local, Prio 3 Fallback
local_dns: List[str] = self.sys_host.network.dns_servers or ["dns://127.0.0.11"]
servers: List[str] = self.servers + local_dns + DNS_SERVERS
if not self._loop:
local_dns = self.sys_host.network.dns_servers or ["dns://127.0.0.11"]
servers = self.servers + local_dns
else:
_LOGGER.warning("Ignore user DNS settings because of loop")
# Print some usefully debug data
_LOGGER.debug(
"config-dns = %s, local-dns = %s , backup-dns = %s",
"config-dns = %s, local-dns = %s , backup-dns = CloudFlare DoT",
self.servers,
local_dns,
DNS_SERVERS,
)
# Make sure, they are valid
for server in servers:
try:
dns_url(server)
@ -235,7 +256,7 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
_LOGGER.warning("Ignore invalid DNS Server: %s", server)
# Generate config file
data = corefile_template.safe_substitute(servers=" ".join(dns_servers))
data = self.coredns_template.render(locals=dns_servers)
try:
self.corefile.write_text(data)
@ -339,7 +360,6 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
def is_fails(self) -> Awaitable[bool]:
"""Return True if a Docker container is fails state.
Return a coroutine.
"""
return self.instance.is_fails()

View File

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

64
supervisor/docker/cli.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
@ -465,13 +465,16 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
"python3 -m homeassistant -c /config --script check_config"
)
# if not valid
# If not valid
if result.exit_code is None:
_LOGGER.error("Fatal error on config check!")
raise HomeAssistantError()
# parse output
# Convert output
log = convert_to_ascii(result.output)
_LOGGER.debug("Result config check: %s", log.strip())
# Parse output
if result.exit_code != 0 or RE_YAML_ERROR.search(log):
_LOGGER.error("Invalid Home Assistant config found!")
return ConfigResult(False, log)

View File

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

View File

@ -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."""
@ -232,9 +238,11 @@ class Tasks(CoreSysAttributes):
return
_LOGGER.warning("Watchdog found a problem with CoreDNS plugin!")
# Reset of fails
if await self.sys_dns.is_fails():
_LOGGER.warning("CoreDNS plugin is in fails state / Reset config")
_LOGGER.error("CoreDNS plugin is in fails state / Reset config")
await self.sys_dns.reset()
await self.sys_dns.loop_detection()
try:
await self.sys_dns.start()
@ -252,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!")

View File

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

View File

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

View File

@ -1,19 +0,0 @@
"""Test adguard discovery."""
import voluptuous as vol
import pytest
from supervisor.discovery.validate import valid_discovery_config
def test_good_config():
"""Test good deconz config."""
valid_discovery_config("home_panel", {"host": "test", "port": 3812})
def test_bad_config():
"""Test good adguard config."""
with pytest.raises(vol.Invalid):
valid_discovery_config("home_panel", {"host": "test"})