Multicast support on Hass.io Network (#1634)

* Add multicast layer to docker

* support network forward

* add pluginmanager

* finish multicast plugin

* fix lint

* Add shutdown for plugins

* Add API

* Add watchdog

* Fix black

* Fix path
This commit is contained in:
Pascal Vizeli 2020-04-05 23:26:22 +02:00 committed by GitHub
parent 2364e1e652
commit f0ed2eba2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 730 additions and 171 deletions

36
API.md
View File

@ -935,6 +935,42 @@ return:
}
```
### Multicast
- GET `/multicast/info`
```json
{
"version": "1",
"version_latest": "2"
}
```
- POST `/multicast/update`
```json
{
"version": "VERSION"
}
```
- POST `/multicast/restart`
- GET `/multicast/stats`
```json
{
"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
}
```
### Auth / SSO API
You can use the user system on homeassistant. We handle this auth system on

View File

@ -325,10 +325,10 @@ class AddonManager(CoreSysAttributes):
for addon in self.installed:
if not await addon.instance.is_running():
continue
self.sys_dns.add_host(
self.sys_plugins.dns.add_host(
ipv4=addon.ip_address, names=[addon.hostname], write=False
)
# Write hosts files
with suppress(CoreDNSError):
self.sys_dns.write_hosts()
self.sys_plugins.dns.write_hosts()

View File

@ -389,7 +389,7 @@ class Addon(AddonModel):
def write_pulse(self):
"""Write asound config to file and return True on success."""
pulse_config = self.sys_audio.pulse_client(
pulse_config = self.sys_plugins.audio.pulse_client(
input_profile=self.audio_input, output_profile=self.audio_output
)

View File

@ -23,6 +23,7 @@ from .security import SecurityMiddleware
from .services import APIServices
from .snapshots import APISnapshots
from .supervisor import APISupervisor
from .multicast import APIMulticast
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -52,6 +53,7 @@ class RestAPI(CoreSysAttributes):
self._register_host()
self._register_os()
self._register_cli()
self._register_multicast()
self._register_hardware()
self._register_homeassistant()
self._register_proxy()
@ -113,6 +115,20 @@ class RestAPI(CoreSysAttributes):
]
)
def _register_multicast(self) -> None:
"""Register Multicast functions."""
api_multicast = APIMulticast()
api_multicast.coresys = self.coresys
self.webapp.add_routes(
[
web.get("/multicast/info", api_multicast.info),
web.get("/multicast/stats", api_multicast.stats),
web.post("/multicast/update", api_multicast.update),
web.post("/multicast/restart", api_multicast.restart),
]
)
def _register_hardware(self) -> None:
"""Register hardware functions."""
api_hardware = APIHardware()

View File

@ -68,8 +68,8 @@ class APIAudio(CoreSysAttributes):
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return Audio information."""
return {
ATTR_VERSION: self.sys_audio.version,
ATTR_VERSION_LATEST: self.sys_audio.latest_version,
ATTR_VERSION: self.sys_plugins.audio.version,
ATTR_VERSION_LATEST: self.sys_plugins.audio.latest_version,
ATTR_HOST: str(self.sys_docker.network.audio),
ATTR_AUDIO: {
ATTR_CARD: [attr.asdict(card) for card in self.sys_host.sound.cards],
@ -88,7 +88,7 @@ class APIAudio(CoreSysAttributes):
@api_process
async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information."""
stats = await self.sys_audio.stats()
stats = await self.sys_plugins.audio.stats()
return {
ATTR_CPU_PERCENT: stats.cpu_percent,
@ -105,21 +105,21 @@ class APIAudio(CoreSysAttributes):
async def update(self, request: web.Request) -> None:
"""Update Audio plugin."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_audio.latest_version)
version = body.get(ATTR_VERSION, self.sys_plugins.audio.latest_version)
if version == self.sys_audio.version:
if version == self.sys_plugins.audio.version:
raise APIError("Version {} is already in use".format(version))
await asyncio.shield(self.sys_audio.update(version))
await asyncio.shield(self.sys_plugins.audio.update(version))
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return Audio Docker logs."""
return self.sys_audio.logs()
return self.sys_plugins.audio.logs()
@api_process
def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart Audio plugin."""
return asyncio.shield(self.sys_audio.restart())
return asyncio.shield(self.sys_plugins.audio.restart())
@api_process
def reload(self, request: web.Request) -> Awaitable[None]:

View File

@ -33,14 +33,14 @@ class APICli(CoreSysAttributes):
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,
ATTR_VERSION: self.sys_plugins.cli.version,
ATTR_VERSION_LATEST: self.sys_plugins.cli.latest_version,
}
@api_process
async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information."""
stats = await self.sys_cli.stats()
stats = await self.sys_plugins.cli.stats()
return {
ATTR_CPU_PERCENT: stats.cpu_percent,
@ -57,6 +57,6 @@ class APICli(CoreSysAttributes):
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)
version = body.get(ATTR_VERSION, self.sys_plugins.cli.latest_version)
await asyncio.shield(self.sys_cli.update(version))
await asyncio.shield(self.sys_plugins.cli.update(version))

View File

@ -42,10 +42,10 @@ class APICoreDNS(CoreSysAttributes):
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return DNS information."""
return {
ATTR_VERSION: self.sys_dns.version,
ATTR_VERSION_LATEST: self.sys_dns.latest_version,
ATTR_VERSION: self.sys_plugins.dns.version,
ATTR_VERSION_LATEST: self.sys_plugins.dns.latest_version,
ATTR_HOST: str(self.sys_docker.network.dns),
ATTR_SERVERS: self.sys_dns.servers,
ATTR_SERVERS: self.sys_plugins.dns.servers,
ATTR_LOCALS: self.sys_host.network.dns_servers,
}
@ -55,15 +55,15 @@ class APICoreDNS(CoreSysAttributes):
body = await api_validate(SCHEMA_OPTIONS, request)
if ATTR_SERVERS in body:
self.sys_dns.servers = body[ATTR_SERVERS]
self.sys_create_task(self.sys_dns.restart())
self.sys_plugins.dns.servers = body[ATTR_SERVERS]
self.sys_create_task(self.sys_plugins.dns.restart())
self.sys_dns.save_data()
self.sys_plugins.dns.save_data()
@api_process
async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information."""
stats = await self.sys_dns.stats()
stats = await self.sys_plugins.dns.stats()
return {
ATTR_CPU_PERCENT: stats.cpu_percent,
@ -80,23 +80,23 @@ class APICoreDNS(CoreSysAttributes):
async def update(self, request: web.Request) -> None:
"""Update DNS plugin."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_dns.latest_version)
version = body.get(ATTR_VERSION, self.sys_plugins.dns.latest_version)
if version == self.sys_dns.version:
if version == self.sys_plugins.dns.version:
raise APIError("Version {} is already in use".format(version))
await asyncio.shield(self.sys_dns.update(version))
await asyncio.shield(self.sys_plugins.dns.update(version))
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return DNS Docker logs."""
return self.sys_dns.logs()
return self.sys_plugins.dns.logs()
@api_process
def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart CoreDNS plugin."""
return asyncio.shield(self.sys_dns.restart())
return asyncio.shield(self.sys_plugins.dns.restart())
@api_process
def reset(self, request: web.Request) -> Awaitable[None]:
"""Reset CoreDNS plugin."""
return asyncio.shield(self.sys_dns.reset())
return asyncio.shield(self.sys_plugins.dns.reset())

View File

@ -0,0 +1,76 @@
"""Init file for Supervisor Multicast RESTful API."""
import asyncio
import logging
from typing import Any, Awaitable, Dict
from aiohttp import web
import voluptuous as vol
from ..const import (
ATTR_BLK_READ,
ATTR_BLK_WRITE,
ATTR_CPU_PERCENT,
ATTR_VERSION_LATEST,
ATTR_MEMORY_LIMIT,
ATTR_MEMORY_PERCENT,
ATTR_MEMORY_USAGE,
ATTR_NETWORK_RX,
ATTR_NETWORK_TX,
ATTR_VERSION,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError
from .utils import api_process, api_process_raw, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
class APIMulticast(CoreSysAttributes):
"""Handle RESTful API for Multicast functions."""
@api_process
async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return Multicast information."""
return {
ATTR_VERSION: self.sys_plugins.multicast.version,
ATTR_VERSION_LATEST: self.sys_plugins.multicast.latest_version,
}
@api_process
async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information."""
stats = await self.sys_plugins.multicast.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 Multicast plugin."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_plugins.multicast.latest_version)
if version == self.sys_plugins.multicast.version:
raise APIError("Version {} is already in use".format(version))
await asyncio.shield(self.sys_plugins.multicast.update(version))
@api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return Multicast Docker logs."""
return self.sys_plugins.multicast.logs()
@api_process
def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart Multicast plugin."""
return asyncio.shield(self.sys_plugins.multicast.restart())

View File

@ -130,7 +130,7 @@ class SecurityMiddleware(CoreSysAttributes):
# Host
# Remove machine_id handling later if all use new CLI
if supervisor_token in (self.sys_machine_id, self.sys_cli.supervisor_token):
if supervisor_token in (self.sys_machine_id, self.sys_plugins.cli.supervisor_token):
_LOGGER.debug("%s access from Host", request.path)
request_from = self.sys_host

View File

@ -35,10 +35,8 @@ from .supervisor import Supervisor
from .tasks import Tasks
from .updater import Updater
from .secrets import SecretsManager
from .plugins import PluginManager
from .utils.dt import fetch_timezone
from .plugins.dns import CoreDNS
from .plugins.cli import HaCli
from .plugins.audio import Audio
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -52,9 +50,8 @@ async def initialize_coresys():
# Initialize core objects
coresys.core = Core(coresys)
coresys.dns = CoreDNS(coresys)
coresys.plugins = PluginManager(coresys)
coresys.arch = CpuArch(coresys)
coresys.audio = Audio(coresys)
coresys.auth = Auth(coresys)
coresys.updater = Updater(coresys)
coresys.api = RestAPI(coresys)
@ -72,7 +69,6 @@ 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)

View File

@ -28,6 +28,7 @@ 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")
FILE_HASSIO_MULTICAST = Path(SUPERVISOR_DATA, "multicast.json")
SOCKET_DOCKER = Path("/run/docker.sock")
@ -67,6 +68,7 @@ HEADER_TOKEN_OLD = "X-Hassio-Key"
ENV_TOKEN_OLD = "HASSIO_TOKEN"
ENV_TOKEN = "SUPERVISOR_TOKEN"
ENV_TIME = "TZ"
ENV_HASSIO_NETWORK = "HASSIO_NETWORK"
ENV_HOMEASSISTANT_REPOSITORY = "HOMEASSISTANT_REPOSITORY"
ENV_SUPERVISOR_SHARE = "SUPERVISOR_SHARE"
@ -77,6 +79,7 @@ REQUEST_FROM = "HASSIO_FROM"
ATTR_SUPERVISOR = "supervisor"
ATTR_MACHINE = "machine"
ATTR_MULTICAST = "multicast"
ATTR_WAIT_BOOT = "wait_boot"
ATTR_DEPLOYMENT = "deployment"
ATTR_WATCHDOG = "watchdog"

View File

@ -41,9 +41,7 @@ class Core(CoreSysAttributes):
await self.sys_host.load()
# Load Plugins container
await asyncio.wait(
[self.sys_dns.load(), self.sys_audio.load(), self.sys_cli.load()]
)
await self.sys_plugins.load()
# Load Home Assistant
await self.sys_homeassistant.load()
@ -172,7 +170,7 @@ class Core(CoreSysAttributes):
self.sys_websession.close(),
self.sys_websession_ssl.close(),
self.sys_ingress.unload(),
self.sys_dns.unload(),
self.sys_plugins.unload(),
self.sys_hwmonitor.unload(),
]
)
@ -193,6 +191,9 @@ class Core(CoreSysAttributes):
await self.sys_addons.shutdown(STARTUP_SYSTEM)
await self.sys_addons.shutdown(STARTUP_INITIALIZE)
# Shutdown all Plugins
await self.sys_plugins.shutdown()
def _update_last_boot(self):
"""Update last boot time."""
self.sys_config.last_boot = self.sys_hardware.last_boot
@ -204,9 +205,7 @@ class Core(CoreSysAttributes):
await self.sys_run_in_executor(self.sys_docker.repair)
# Fix plugins
await asyncio.wait(
[self.sys_dns.repair(), self.sys_audio.repair(), self.sys_cli.repair()]
)
await self.sys_plugins.repair()
# Restore core functionality
await self.sys_addons.repair()

View File

@ -31,9 +31,7 @@ if TYPE_CHECKING:
from .store import StoreManager
from .tasks import Tasks
from .updater import Updater
from .plugins.cli import HaCli
from .plugins.audio import Audio
from .plugins.dns import CoreDNS
from .plugins import PluginManager
class CoreSys:
@ -61,10 +59,7 @@ class CoreSys:
# Internal objects pointers
self._core: Optional[Core] = None
self._arch: Optional[CpuArch] = None
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
@ -81,6 +76,7 @@ class CoreSys:
self._store: Optional[StoreManager] = None
self._discovery: Optional[Discovery] = None
self._hwmonitor: Optional[HwMonitor] = None
self._plugins: Optional[PluginManager] = None
@property
def dev(self) -> bool:
@ -140,16 +136,16 @@ class CoreSys:
self._core = value
@property
def cli(self) -> HaCli:
"""Return HaCli object."""
return self._cli
def plugins(self) -> PluginManager:
"""Return PluginManager object."""
return self._plugins
@cli.setter
def cli(self, value: HaCli):
"""Set a HaCli object."""
if self._cli:
raise RuntimeError("HaCli already set!")
self._cli = value
@plugins.setter
def plugins(self, value: PluginManager):
"""Set a PluginManager object."""
if self._plugins:
raise RuntimeError("PluginManager already set!")
self._plugins = value
@property
def arch(self) -> CpuArch:
@ -175,18 +171,6 @@ class CoreSys:
raise RuntimeError("Auth already set!")
self._auth = value
@property
def audio(self) -> Audio:
"""Return Audio object."""
return self._audio
@audio.setter
def audio(self, value: Audio):
"""Set a Audio object."""
if self._audio:
raise RuntimeError("Audio already set!")
self._audio = value
@property
def homeassistant(self) -> HomeAssistant:
"""Return Home Assistant object."""
@ -331,18 +315,6 @@ class CoreSys:
raise RuntimeError("DBusManager already set!")
self._dbus = value
@property
def dns(self) -> CoreDNS:
"""Return CoreDNS object."""
return self._dns
@dns.setter
def dns(self, value: CoreDNS):
"""Set a CoreDNS object."""
if self._dns:
raise RuntimeError("CoreDNS already set!")
self._dns = value
@property
def host(self) -> HostManager:
"""Return HostManager object."""
@ -482,9 +454,9 @@ class CoreSysAttributes:
return self.coresys.core
@property
def sys_cli(self) -> HaCli:
"""Return HaCli object."""
return self.coresys.cli
def sys_plugins(self) -> PluginManager:
"""Return PluginManager object."""
return self.coresys.plugins
@property
def sys_arch(self) -> CpuArch:
@ -496,11 +468,6 @@ class CoreSysAttributes:
"""Return Auth object."""
return self.coresys.auth
@property
def sys_audio(self) -> Audio:
"""Return Audio object."""
return self.coresys.audio
@property
def sys_homeassistant(self) -> HomeAssistant:
"""Return Home Assistant object."""
@ -561,11 +528,6 @@ class CoreSysAttributes:
"""Return DBusManager object."""
return self.coresys.dbus
@property
def sys_dns(self) -> CoreDNS:
"""Return CoreDNS object."""
return self.coresys.dns
@property
def sys_host(self) -> HostManager:
"""Return HostManager object."""

View File

@ -308,11 +308,11 @@ class DockerAddon(DockerInterface):
"bind": "/etc/pulse/client.conf",
"mode": "ro",
},
str(self.sys_audio.path_extern_pulse): {
str(self.sys_plugins.audio.path_extern_pulse): {
"bind": "/run/audio",
"mode": "ro",
},
str(self.sys_audio.path_extern_asound): {
str(self.sys_plugins.audio.path_extern_asound): {
"bind": "/etc/asound.conf",
"mode": "ro",
},
@ -364,7 +364,7 @@ class DockerAddon(DockerInterface):
_LOGGER.info("Start Docker add-on %s with version %s", self.image, self.version)
# Write data to DNS server
self.sys_dns.add_host(ipv4=self.ip_address, names=[self.addon.hostname])
self.sys_plugins.dns.add_host(ipv4=self.ip_address, names=[self.addon.hostname])
def _install(
self, tag: str, image: Optional[str] = None, latest: bool = False
@ -490,5 +490,5 @@ class DockerAddon(DockerInterface):
Need run inside executor.
"""
if self.ip_address != NO_ADDDRESS:
self.sys_dns.delete_host(self.addon.hostname)
self.sys_plugins.dns.delete_host(self.addon.hostname)
super()._stop(remove_container)

View File

@ -20,7 +20,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
@property
def image(self) -> str:
"""Return name of Supervisor Audio image."""
return self.sys_audio.image
return self.sys_plugins.audio.image
@property
def name(self) -> str:
@ -58,7 +58,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
# Create & Run container
docker_container = self.sys_docker.run(
self.image,
version=self.sys_audio.version,
version=self.sys_plugins.audio.version,
init=False,
ipv4=self.sys_docker.network.audio,
name=self.name,

View File

@ -18,7 +18,7 @@ class DockerCli(DockerInterface, CoreSysAttributes):
@property
def image(self):
"""Return name of HA cli image."""
return self.sys_cli.image
return self.sys_plugins.cli.image
@property
def name(self) -> str:
@ -42,7 +42,7 @@ class DockerCli(DockerInterface, CoreSysAttributes):
self.image,
entrypoint=["/init"],
command=["/bin/bash", "-c", "sleep infinity"],
version=self.sys_cli.version,
version=self.sys_plugins.cli.version,
init=False,
ipv4=self.sys_docker.network.cli,
name=self.name,
@ -51,7 +51,7 @@ class DockerCli(DockerInterface, CoreSysAttributes):
extra_hosts={"supervisor": self.sys_docker.network.supervisor},
environment={
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.sys_cli.supervisor_token,
ENV_TOKEN: self.sys_plugins.cli.supervisor_token,
},
)

View File

@ -18,7 +18,7 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
@property
def image(self) -> str:
"""Return name of Supervisor DNS image."""
return self.sys_dns.image
return self.sys_plugins.dns.image
@property
def name(self) -> str:
@ -40,7 +40,7 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
# Create & Run container
docker_container = self.sys_docker.run(
self.image,
version=self.sys_dns.version,
version=self.sys_plugins.dns.version,
init=False,
dns=False,
ipv4=self.sys_docker.network.dns,

View File

@ -72,11 +72,11 @@ class DockerHomeAssistant(DockerInterface):
"bind": "/etc/pulse/client.conf",
"mode": "ro",
},
str(self.sys_audio.path_extern_pulse): {
str(self.sys_plugins.audio.path_extern_pulse): {
"bind": "/run/audio",
"mode": "ro",
},
str(self.sys_audio.path_extern_asound): {
str(self.sys_plugins.audio.path_extern_asound): {
"bind": "/etc/asound.conf",
"mode": "ro",
},

View File

@ -0,0 +1,59 @@
"""HA Cli docker object."""
from contextlib import suppress
import logging
from ..const import DOCKER_NETWORK_MASK, ENV_HASSIO_NETWORK, ENV_TIME
from ..coresys import CoreSysAttributes
from ..exceptions import DockerAPIError
from .interface import DockerInterface
_LOGGER: logging.Logger = logging.getLogger(__name__)
MULTICAST_DOCKER_NAME: str = "hassio_multicast"
class DockerMulticast(DockerInterface, CoreSysAttributes):
"""Docker Supervisor wrapper for HA multicast."""
@property
def image(self):
"""Return name of HA multicast image."""
return self.sys_plugins.multicast.image
@property
def name(self) -> str:
"""Return name of Docker container."""
return MULTICAST_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,
version=self.sys_plugins.multicast.version,
init=False,
name=self.name,
hostname=self.name.replace("_", "-"),
network_mode="host",
detach=True,
extra_hosts={"supervisor": self.sys_docker.network.supervisor},
environment={
ENV_TIME: self.sys_timezone,
ENV_HASSIO_NETWORK: str(DOCKER_NETWORK_MASK),
},
)
self._meta = docker_container.attrs
_LOGGER.info(
"Start Multicast %s with version %s - Host", self.image, self.version
)

View File

@ -61,10 +61,21 @@ class CliError(HassioError):
"""HA cli exception."""
class CliUpdateError(HassOSError):
class CliUpdateError(CliError):
"""Error on update of a HA cli."""
# Multicast
class MulticastError(HassioError):
"""Multicast exception."""
class MulticastUpdateError(MulticastError):
"""Error on update of a multicast."""
# DNS

View File

@ -636,7 +636,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
def write_pulse(self):
"""Write asound config to file and return True on success."""
pulse_config = self.sys_audio.pulse_client(
pulse_config = self.sys_plugins.audio.pulse_client(
input_profile=self.audio_input, output_profile=self.audio_output
)

View File

@ -1 +1,76 @@
"""Plugin for Supervisor backend."""
import asyncio
import logging
from ..coresys import CoreSys, CoreSysAttributes
from .audio import Audio
from .cli import HaCli
from .dns import CoreDNS
from .multicast import Multicast
_LOGGER: logging.Logger = logging.getLogger(__name__)
class PluginManager(CoreSysAttributes):
"""Manage supported function for plugins."""
def __init__(self, coresys: CoreSys):
"""Initialize plugin manager."""
self.coresys: CoreSys = coresys
self._cli: HaCli = HaCli(coresys)
self._dns: CoreDNS = CoreDNS(coresys)
self._audio: Audio = Audio(coresys)
self._multicast: Multicast = Multicast(coresys)
@property
def cli(self) -> HaCli:
"""Return cli handler."""
return self._cli
@property
def dns(self) -> CoreDNS:
"""Return dns handler."""
return self._dns
@property
def audio(self) -> Audio:
"""Return audio handler."""
return self._audio
@property
def multicast(self) -> Multicast:
"""Return multicast handler."""
return self._multicast
async def load(self):
"""Load Supervisor plugins."""
await asyncio.wait(
[self.dns.load(), self.audio.load(), self.cli.load(), self.multicast.load()]
)
async def repair(self):
"""Repair Supervisor plugins."""
await asyncio.wait(
[
self.dns.repair(),
self.audio.repair(),
self.cli.repair(),
self.multicast.repair(),
]
)
async def unload(self) -> None:
"""Unload Supervisor plugin."""
await asyncio.wait([self.dns.unload()])
async def shutdown(self) -> None:
"""Shutdown Supervisor plugin."""
await asyncio.wait(
[
self.dns.stop(),
self.audio.stop(),
self.cli.stop(),
self.multicast.stop(),
]
)

View File

@ -1,4 +1,7 @@
"""Home Assistant control object."""
"""Home Assistant audio plugin.
Code: https://github.com/home-assistant/plugin-audio
"""
import asyncio
from contextlib import suppress
import logging
@ -14,12 +17,12 @@ from ..docker.audio import DockerAudio
from ..docker.stats import DockerStats
from ..exceptions import AudioError, AudioUpdateError, DockerAPIError
from ..utils.json import JsonConfig
from ..validate import SCHEMA_AUDIO_CONFIG
from .validate import SCHEMA_AUDIO_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
PULSE_CLIENT_TMPL: Path = Path(__file__).parents[0].joinpath("data/pulse-client.tmpl")
ASOUND_TMPL: Path = Path(__file__).parents[0].joinpath("data/asound.tmpl")
PULSE_CLIENT_TMPL: Path = Path(__file__).parents[1].joinpath("data/pulse-client.tmpl")
ASOUND_TMPL: Path = Path(__file__).parents[1].joinpath("data/asound.tmpl")
class Audio(JsonConfig, CoreSysAttributes):
@ -177,7 +180,6 @@ class Audio(JsonConfig, CoreSysAttributes):
async def start(self) -> None:
"""Run CoreDNS."""
# Start Instance
_LOGGER.info("Start Audio plugin")
try:
await self.instance.run()
@ -185,6 +187,15 @@ class Audio(JsonConfig, CoreSysAttributes):
_LOGGER.error("Can't start Audio plugin")
raise AudioError() from None
async def stop(self) -> None:
"""Stop CoreDNS."""
_LOGGER.info("Stop Audio plugin")
try:
await self.instance.stop()
except DockerAPIError:
_LOGGER.error("Can't stop Audio plugin")
raise AudioError() from None
def logs(self) -> Awaitable[bytes]:
"""Get CoreDNS docker logs.

View File

@ -1,4 +1,7 @@
"""CLI support on supervisor."""
"""Home Assistant cli plugin.
Code: https://github.com/home-assistant/plugin-cli
"""
import asyncio
from contextlib import suppress
import logging
@ -11,7 +14,7 @@ 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
from .validate import SCHEMA_CLI_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -158,6 +161,15 @@ class HaCli(CoreSysAttributes, JsonConfig):
_LOGGER.error("Can't start cli plugin")
raise CliError() from None
async def stop(self) -> None:
"""Stop cli."""
_LOGGER.info("Stop cli plugin")
try:
await self.instance.stop()
except DockerAPIError:
_LOGGER.error("Can't stop cli plugin")
raise CliError() from None
async def stats(self) -> DockerStats:
"""Return stats of cli."""
try:

View File

@ -1,4 +1,7 @@
"""Home Assistant control object."""
"""Home Assistant dns plugin.
Code: https://github.com/home-assistant/plugin-dns
"""
import asyncio
from contextlib import suppress
from ipaddress import IPv4Address
@ -17,12 +20,13 @@ from ..docker.stats import DockerStats
from ..exceptions import CoreDNSError, CoreDNSUpdateError, DockerAPIError
from ..misc.forwarder import DNSForward
from ..utils.json import JsonConfig
from ..validate import SCHEMA_DNS_CONFIG, dns_url
from .validate import SCHEMA_DNS_CONFIG
from ..validate import dns_url
_LOGGER: logging.Logger = logging.getLogger(__name__)
COREDNS_TMPL: Path = Path(__file__).parents[0].joinpath("data/coredns.tmpl")
RESOLV_TMPL: Path = Path(__file__).parents[0].joinpath("data/resolv.tmpl")
COREDNS_TMPL: Path = Path(__file__).parents[1].joinpath("data/coredns.tmpl")
RESOLV_TMPL: Path = Path(__file__).parents[1].joinpath("data/resolv.tmpl")
HOST_RESOLV: Path = Path("/etc/resolv.conf")
@ -212,8 +216,12 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
async def restart(self) -> None:
"""Restart CoreDNS plugin."""
self._write_corefile()
with suppress(DockerAPIError):
_LOGGER.info("Restart CoreDNS plugin")
try:
await self.instance.restart()
except DockerAPIError:
_LOGGER.error("Can't start CoreDNS plugin")
raise CoreDNSError()
async def start(self) -> None:
"""Run CoreDNS."""
@ -227,6 +235,15 @@ class CoreDNS(JsonConfig, CoreSysAttributes):
_LOGGER.error("Can't start CoreDNS plugin")
raise CoreDNSError() from None
async def stop(self) -> None:
"""Stop CoreDNS."""
_LOGGER.info("Stop CoreDNS plugin")
try:
await self.instance.stop()
except DockerAPIError:
_LOGGER.error("Can't stop CoreDNS plugin")
raise CoreDNSError() from None
async def reset(self) -> None:
"""Reset DNS and hosts."""
# Reset manually defined DNS

View File

@ -0,0 +1,208 @@
"""Home Assistant multicast plugin.
Code: https://github.com/home-assistant/plugin-multicast
"""
import asyncio
from contextlib import suppress
import logging
from typing import Awaitable, Optional
from ..const import ATTR_IMAGE, ATTR_VERSION, FILE_HASSIO_MULTICAST
from ..coresys import CoreSys, CoreSysAttributes
from ..docker.multicast import DockerMulticast
from ..docker.stats import DockerStats
from ..exceptions import DockerAPIError, MulticastError, MulticastUpdateError
from ..utils.json import JsonConfig
from .validate import SCHEMA_MULTICAST_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
class Multicast(JsonConfig, CoreSysAttributes):
"""Home Assistant core object for handle it."""
def __init__(self, coresys: CoreSys):
"""Initialize hass object."""
super().__init__(FILE_HASSIO_MULTICAST, SCHEMA_MULTICAST_CONFIG)
self.coresys: CoreSys = coresys
self.instance: DockerMulticast = DockerMulticast(coresys)
@property
def version(self) -> Optional[str]:
"""Return current version of Multicast."""
return self._data.get(ATTR_VERSION)
@version.setter
def version(self, value: str) -> None:
"""Return current version of Multicast."""
self._data[ATTR_VERSION] = value
@property
def image(self) -> str:
"""Return current image of Multicast."""
if self._data.get(ATTR_IMAGE):
return self._data[ATTR_IMAGE]
return f"homeassistant/{self.sys_arch.supervisor}-hassio-multicast"
@image.setter
def image(self, value: str) -> None:
"""Return current image of Multicast."""
self._data[ATTR_IMAGE] = value
@property
def latest_version(self) -> Optional[str]:
"""Return latest version of Multicast."""
return self.sys_updater.version_multicast
@property
def in_progress(self) -> bool:
"""Return True if a task is in progress."""
return self.instance.in_progress
@property
def need_update(self) -> bool:
"""Return True if an update is available."""
return self.version != self.latest_version
async def load(self) -> None:
"""Load multicast setup."""
# Check Multicast 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 Multicast plugin Docker image %s found.", self.instance.image
)
# Install Multicast plugin
with suppress(MulticastError):
await self.install()
else:
self.version = self.instance.version
self.image = self.instance.image
self.save_data()
# Run Multicast plugin
with suppress(MulticastError):
if await self.instance.is_running():
await self.restart()
else:
await self.start()
async def install(self) -> None:
"""Install Multicast."""
_LOGGER.info("Setup Multicast plugin")
while True:
# read homeassistant tag and install it
if not self.latest_version:
await self.sys_updater.reload()
if self.latest_version:
with suppress(DockerAPIError):
await self.instance.install(
self.latest_version, image=self.sys_updater.image_multicast
)
break
_LOGGER.warning("Error on install Multicast plugin. Retry in 30sec")
await asyncio.sleep(30)
_LOGGER.info("Multicast plugin now installed")
self.version = self.instance.version
self.image = self.sys_updater.image_multicast
self.save_data()
async def update(self, version: Optional[str] = None) -> None:
"""Update Multicast plugin."""
version = version or self.latest_version
old_image = self.image
if version == self.version:
_LOGGER.warning("Version %s is already installed for Multicast", version)
return
# Update
try:
await self.instance.update(version, image=self.sys_updater.image_multicast)
except DockerAPIError:
_LOGGER.error("Multicast update fails")
raise MulticastUpdateError() from None
else:
self.version = version
self.image = self.sys_updater.image_multicast
self.save_data()
# Cleanup
with suppress(DockerAPIError):
await self.instance.cleanup(old_image=old_image)
# Start Multicast plugin
await self.start()
async def restart(self) -> None:
"""Restart Multicast plugin."""
_LOGGER.info("Restart Multicast plugin")
try:
await self.instance.restart()
except DockerAPIError:
_LOGGER.error("Can't start Multicast plugin")
raise MulticastError()
async def start(self) -> None:
"""Run Multicast."""
_LOGGER.info("Start Multicast plugin")
try:
await self.instance.run()
except DockerAPIError:
_LOGGER.error("Can't start Multicast plugin")
raise MulticastError()
async def stop(self) -> None:
"""Stop Multicast."""
_LOGGER.info("Stop Multicast plugin")
try:
await self.instance.stop()
except DockerAPIError:
_LOGGER.error("Can't stop Multicast plugin")
raise MulticastError()
def logs(self) -> Awaitable[bytes]:
"""Get Multicast docker logs.
Return Coroutine.
"""
return self.instance.logs()
async def stats(self) -> DockerStats:
"""Return stats of Multicast."""
try:
return await self.instance.stats()
except DockerAPIError:
raise MulticastError() from None
def is_running(self) -> Awaitable[bool]:
"""Return True if Docker container is running.
Return a coroutine.
"""
return self.instance.is_running()
def is_fails(self) -> Awaitable[bool]:
"""Return True if a Docker container is fails state.
Return a coroutine.
"""
return self.instance.is_fails()
async def repair(self) -> None:
"""Repair Multicast plugin."""
if await self.instance.exists():
return
_LOGGER.info("Repair Multicast %s", self.version)
try:
await self.instance.install(self.version)
except DockerAPIError:
_LOGGER.error("Repairing of Multicast fails")

View File

@ -0,0 +1,44 @@
"""Validate functions."""
import voluptuous as vol
from ..const import ATTR_ACCESS_TOKEN, ATTR_IMAGE, ATTR_SERVERS, ATTR_VERSION
from ..validate import dns_server_list, docker_image, token
SCHEMA_DNS_CONFIG = vol.Schema(
{
vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_IMAGE): docker_image,
vol.Optional(ATTR_SERVERS, default=list): dns_server_list,
},
extra=vol.REMOVE_EXTRA,
)
SCHEMA_AUDIO_CONFIG = vol.Schema(
{
vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_IMAGE): docker_image,
},
extra=vol.REMOVE_EXTRA,
)
SCHEMA_CLI_CONFIG = vol.Schema(
{
vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_IMAGE): docker_image,
vol.Optional(ATTR_ACCESS_TOKEN): token,
},
extra=vol.REMOVE_EXTRA,
)
SCHEMA_MULTICAST_CONFIG = vol.Schema(
{
vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_IMAGE): docker_image,
},
extra=vol.REMOVE_EXTRA,
)

View File

@ -3,7 +3,13 @@ import asyncio
import logging
from .coresys import CoreSysAttributes
from .exceptions import AudioError, CliError, CoreDNSError, HomeAssistantError
from .exceptions import (
AudioError,
CliError,
CoreDNSError,
HomeAssistantError,
MulticastError,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -14,6 +20,7 @@ RUN_UPDATE_ADDONS = 57600
RUN_UPDATE_CLI = 28100
RUN_UPDATE_DNS = 30100
RUN_UPDATE_AUDIO = 30200
RUN_UPDATE_MULTICAST = 30300
RUN_RELOAD_ADDONS = 10800
RUN_RELOAD_SNAPSHOTS = 72000
@ -27,6 +34,7 @@ RUN_WATCHDOG_HOMEASSISTANT_API = 300
RUN_WATCHDOG_DNS_DOCKER = 20
RUN_WATCHDOG_AUDIO_DOCKER = 30
RUN_WATCHDOG_CLI_DOCKER = 40
RUN_WATCHDOG_MULTICAST_DOCKER = 50
class Tasks(CoreSysAttributes):
@ -58,6 +66,11 @@ class Tasks(CoreSysAttributes):
self.jobs.add(
self.sys_scheduler.register_task(self._update_audio, RUN_UPDATE_AUDIO)
)
self.jobs.add(
self.sys_scheduler.register_task(
self._update_multicast, RUN_UPDATE_MULTICAST
)
)
# Reload
self.jobs.add(
@ -108,6 +121,11 @@ class Tasks(CoreSysAttributes):
self._watchdog_cli_docker, RUN_WATCHDOG_CLI_DOCKER
)
)
self.jobs.add(
self.sys_scheduler.register_task(
self._watchdog_multicast_docker, RUN_WATCHDOG_MULTICAST_DOCKER
)
)
_LOGGER.info("All core tasks are scheduled")
@ -209,66 +227,92 @@ class Tasks(CoreSysAttributes):
async def _update_cli(self):
"""Check and run update of cli."""
if not self.sys_cli.need_update:
if not self.sys_plugins.cli.need_update:
return
_LOGGER.info("Found new cli version")
await self.sys_cli.update()
await self.sys_plugins.cli.update()
async def _update_dns(self):
"""Check and run update of CoreDNS plugin."""
if not self.sys_dns.need_update:
if not self.sys_plugins.dns.need_update:
return
_LOGGER.info("Found new CoreDNS plugin version")
await self.sys_dns.update()
await self.sys_plugins.dns.update()
async def _update_audio(self):
"""Check and run update of PulseAudio plugin."""
if not self.sys_audio.need_update:
if not self.sys_plugins.audio.need_update:
return
_LOGGER.info("Found new PulseAudio plugin version")
await self.sys_audio.update()
await self.sys_plugins.audio.update()
async def _update_multicast(self):
"""Check and run update of multicast."""
if not self.sys_plugins.multicast.need_update:
return
_LOGGER.info("Found new Multicast version")
await self.sys_plugins.multicast.update()
async def _watchdog_dns_docker(self):
"""Check running state of Docker and start if they is close."""
# if CoreDNS is active
if await self.sys_dns.is_running() or self.sys_dns.in_progress:
if await self.sys_plugins.dns.is_running() or self.sys_plugins.dns.in_progress:
return
_LOGGER.warning("Watchdog found a problem with CoreDNS plugin!")
# Reset of fails
if await self.sys_dns.is_fails():
if await self.sys_plugins.dns.is_fails():
_LOGGER.error("CoreDNS plugin is in fails state / Reset config")
await self.sys_dns.reset()
await self.sys_dns.loop_detection()
await self.sys_plugins.dns.reset()
await self.sys_plugins.dns.loop_detection()
try:
await self.sys_dns.start()
await self.sys_plugins.dns.start()
except CoreDNSError:
_LOGGER.error("Watchdog CoreDNS reanimation fails!")
async def _watchdog_audio_docker(self):
"""Check running state of Docker and start if they is close."""
# if PulseAudio plugin is active
if await self.sys_audio.is_running() or self.sys_audio.in_progress:
if (
await self.sys_plugins.audio.is_running()
or self.sys_plugins.audio.in_progress
):
return
_LOGGER.warning("Watchdog found a problem with PulseAudio plugin!")
try:
await self.sys_audio.start()
await self.sys_plugins.audio.start()
except AudioError:
_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:
if await self.sys_plugins.cli.is_running() or self.sys_plugins.cli.in_progress:
return
_LOGGER.warning("Watchdog found a problem with cli plugin!")
try:
await self.sys_cli.start()
await self.sys_plugins.cli.start()
except CliError:
_LOGGER.error("Watchdog cli reanimation fails!")
async def _watchdog_multicast_docker(self):
"""Check running state of Docker and start if they is close."""
# if multicast plugin is active
if (
await self.sys_plugins.multicast.is_running()
or self.sys_plugins.multicast.in_progress
):
return
_LOGGER.warning("Watchdog found a problem with Multicast plugin!")
try:
await self.sys_plugins.multicast.start()
except MulticastError:
_LOGGER.error("Watchdog Multicast reanimation fails!")

View File

@ -13,10 +13,11 @@ from .const import (
ATTR_CHANNEL,
ATTR_CLI,
ATTR_DNS,
ATTR_SUPERVISOR,
ATTR_HASSOS,
ATTR_HOMEASSISTANT,
ATTR_IMAGE,
ATTR_MULTICAST,
ATTR_SUPERVISOR,
FILE_HASSIO_UPDATER,
URL_HASSIO_VERSION,
UpdateChannels,
@ -78,6 +79,11 @@ class Updater(JsonConfig, CoreSysAttributes):
"""Return latest version of Audio."""
return self._data.get(ATTR_AUDIO)
@property
def version_multicast(self) -> Optional[str]:
"""Return latest version of Multicast."""
return self._data.get(ATTR_MULTICAST)
@property
def image_homeassistant(self) -> Optional[str]:
"""Return latest version of Home Assistant."""
@ -123,6 +129,15 @@ class Updater(JsonConfig, CoreSysAttributes):
.format(arch=self.sys_arch.supervisor)
)
@property
def image_multicast(self) -> Optional[str]:
"""Return latest version of Multicast."""
return (
self._data[ATTR_IMAGE]
.get(ATTR_MULTICAST, "")
.format(arch=self.sys_arch.supervisor)
)
@property
def channel(self) -> UpdateChannels:
"""Return upstream channel of Supervisor instance."""
@ -171,10 +186,11 @@ class Updater(JsonConfig, CoreSysAttributes):
if self.sys_hassos.board:
self._data[ATTR_HASSOS] = data["hassos"][self.sys_hassos.board]
# Update Home Assistant services
# Update Home Assistant plugins
self._data[ATTR_CLI] = data["cli"]
self._data[ATTR_DNS] = data["dns"]
self._data[ATTR_AUDIO] = data["audio"]
self._data[ATTR_MULTICAST] = data["multicast"]
# Update images for that versions
self._data[ATTR_IMAGE][ATTR_HOMEASSISTANT] = data["image"]["core"]
@ -182,6 +198,7 @@ class Updater(JsonConfig, CoreSysAttributes):
self._data[ATTR_IMAGE][ATTR_AUDIO] = data["image"]["audio"]
self._data[ATTR_IMAGE][ATTR_CLI] = data["image"]["cli"]
self._data[ATTR_IMAGE][ATTR_DNS] = data["image"]["dns"]
self._data[ATTR_IMAGE][ATTR_MULTICAST] = data["image"]["multicast"]
except KeyError as err:
_LOGGER.warning("Can't process version data: %s", err)

View File

@ -17,18 +17,18 @@ from .const import (
ATTR_DEBUG,
ATTR_DEBUG_BLOCK,
ATTR_DNS,
ATTR_SUPERVISOR,
ATTR_HASSOS,
ATTR_HOMEASSISTANT,
ATTR_IMAGE,
ATTR_LAST_BOOT,
ATTR_LOGGING,
ATTR_MULTICAST,
ATTR_PORT,
ATTR_PORTS,
ATTR_REFRESH_TOKEN,
ATTR_SERVERS,
ATTR_SESSION,
ATTR_SSL,
ATTR_SUPERVISOR,
ATTR_TIMEZONE,
ATTR_UUID,
ATTR_VERSION,
@ -129,6 +129,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema(
vol.Optional(ATTR_CLI): vol.Coerce(str),
vol.Optional(ATTR_DNS): vol.Coerce(str),
vol.Optional(ATTR_AUDIO): vol.Coerce(str),
vol.Optional(ATTR_MULTICAST): vol.Coerce(str),
vol.Optional(ATTR_IMAGE, default=dict): vol.Schema(
{
vol.Optional(ATTR_HOMEASSISTANT): docker_image,
@ -136,6 +137,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema(
vol.Optional(ATTR_CLI): docker_image,
vol.Optional(ATTR_DNS): docker_image,
vol.Optional(ATTR_AUDIO): docker_image,
vol.Optional(ATTR_MULTICAST): docker_image,
},
extra=vol.REMOVE_EXTRA,
),
@ -176,32 +178,3 @@ SCHEMA_INGRESS_CONFIG = vol.Schema(
},
extra=vol.REMOVE_EXTRA,
)
SCHEMA_DNS_CONFIG = vol.Schema(
{
vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_IMAGE): docker_image,
vol.Optional(ATTR_SERVERS, default=list): dns_server_list,
},
extra=vol.REMOVE_EXTRA,
)
SCHEMA_AUDIO_CONFIG = vol.Schema(
{
vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_IMAGE): docker_image,
},
extra=vol.REMOVE_EXTRA,
)
SCHEMA_CLI_CONFIG = vol.Schema(
{
vol.Optional(ATTR_VERSION): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_IMAGE): docker_image,
vol.Optional(ATTR_ACCESS_TOKEN): token,
},
extra=vol.REMOVE_EXTRA,
)