mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-18 22:56:31 +00:00
Add CoreDNS as DNS backend (#1195)
* Add CoreDNS / DNS configuration * Support get version * add version * add coresys * Add more logic * move forwareder into dns * Setup docker inside * add docker to env * Add more function * more interface * Update hosts template * Add DNS folder * Fix issues * Add more logic * Add handling for hosts * Fix setting * fix lint * Fix some issues * Fix issue * Run with no cache * Fix issue on validate * Fix bug * Allow to jump into dev mode * Fix permission * Fix issue * Add dns search * Add watchdog * Fix set issues * add API description * Add API endpoint * Add CLI support * Fix logs + add hostname * Add/remove DNS entry * Fix attribute * Fix style * Better shutdown * Remove ha from network mapping * Add more options * Fix env shutdown * Add support for new repair function * Start coreDNS faster after restart * remove options * Fix ha fix
This commit is contained in:
parent
9f3f47eb80
commit
1196788856
@ -1,8 +1,8 @@
|
||||
FROM python:3.7
|
||||
|
||||
WORKDIR /workspace
|
||||
WORKDIR /workspaces
|
||||
|
||||
# install Node/Yarn for Frontent
|
||||
# Install Node/Yarn for Frontent
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
git \
|
||||
@ -17,8 +17,24 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENV NVM_DIR /root/.nvm
|
||||
|
||||
# Install docker
|
||||
# https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
apt-transport-https \
|
||||
ca-certificates \
|
||||
curl \
|
||||
software-properties-common \
|
||||
gpg-agent \
|
||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - \
|
||||
&& add-apt-repository "deb https://download.docker.com/linux/debian $(lsb_release -cs) stable" \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
docker-ce \
|
||||
docker-ce-cli \
|
||||
containerd.io \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies from requirements.txt if it exists
|
||||
COPY requirements.txt requirements_tests.txt /workspace/
|
||||
COPY requirements.txt requirements_tests.txt /workspaces/
|
||||
RUN pip install -r requirements.txt \
|
||||
&& pip3 install -r requirements_tests.txt \
|
||||
&& pip install black tox
|
||||
|
@ -3,9 +3,11 @@
|
||||
"name": "Hass.io dev",
|
||||
"context": "..",
|
||||
"dockerFile": "Dockerfile",
|
||||
"appPort": "9123:8123",
|
||||
"runArgs": [
|
||||
"-e",
|
||||
"GIT_EDTIOR='code --wait'"
|
||||
"GIT_EDITOR='code --wait'",
|
||||
"--privileged"
|
||||
],
|
||||
"extensions": [
|
||||
"ms-python.python"
|
||||
|
@ -18,3 +18,6 @@ venv/
|
||||
home-assistant-polymer/*
|
||||
misc/*
|
||||
script/*
|
||||
|
||||
# Test ENV
|
||||
data/
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -95,3 +95,6 @@ ENV/
|
||||
.vscode/*
|
||||
!.vscode/cSpell.json
|
||||
!.vscode/tasks.json
|
||||
|
||||
# Test Env
|
||||
test_data/
|
73
.vscode/tasks.json
vendored
73
.vscode/tasks.json
vendored
@ -1,6 +1,34 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Run Testenv",
|
||||
"type": "shell",
|
||||
"command": "./script/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",
|
||||
@ -14,6 +42,51 @@
|
||||
"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 homeassistant tests",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true,
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Pylint",
|
||||
"type": "shell",
|
||||
"command": "pylint homeassistant",
|
||||
"dependsOn": [
|
||||
"Install all Requirements"
|
||||
],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true,
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
42
API.md
42
API.md
@ -473,6 +473,7 @@ Get all available addons.
|
||||
{
|
||||
"name": "xy bla",
|
||||
"slug": "xdssd_xybla",
|
||||
"hostname": "xdssd-xybla",
|
||||
"description": "description",
|
||||
"long_description": "null|markdown",
|
||||
"auto_update": "bool",
|
||||
@ -739,6 +740,47 @@ return:
|
||||
}
|
||||
```
|
||||
|
||||
### DNS
|
||||
|
||||
- GET `/dns/info`
|
||||
```json
|
||||
{
|
||||
"host": "ip-address",
|
||||
"version": "1",
|
||||
"latest_version": "2",
|
||||
"servers": ["dns://8.8.8.8"]
|
||||
}
|
||||
```
|
||||
|
||||
- POST `/dns/options`
|
||||
```json
|
||||
{
|
||||
"servers": ["dns://8.8.8.8"]
|
||||
}
|
||||
```
|
||||
|
||||
- POST `/dns/update`
|
||||
```json
|
||||
{
|
||||
"version": "VERSION"
|
||||
}
|
||||
```
|
||||
|
||||
- GET `/dns/logs`
|
||||
|
||||
- GET `/dns/stats`
|
||||
```json
|
||||
{
|
||||
"cpu_percent": 0.0,
|
||||
"memory_usage": 283123,
|
||||
"memory_limit": 329392,
|
||||
"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
|
||||
|
@ -109,6 +109,11 @@ class AddonModel(CoreSysAttributes):
|
||||
"""Return name of add-on."""
|
||||
return self.data[ATTR_NAME]
|
||||
|
||||
@property
|
||||
def hostname(self) -> str:
|
||||
"""Return slug/id of add-on."""
|
||||
return self.slug.replace("_", "-")
|
||||
|
||||
@property
|
||||
def timeout(self) -> int:
|
||||
"""Return timeout of addon for docker stop."""
|
||||
|
@ -9,6 +9,7 @@ from ..coresys import CoreSys, CoreSysAttributes
|
||||
from .addons import APIAddons
|
||||
from .auth import APIAuth
|
||||
from .discovery import APIDiscovery
|
||||
from .dns import APICoreDNS
|
||||
from .hardware import APIHardware
|
||||
from .hassos import APIHassOS
|
||||
from .homeassistant import APIHomeAssistant
|
||||
@ -55,6 +56,7 @@ class RestAPI(CoreSysAttributes):
|
||||
self._register_services()
|
||||
self._register_info()
|
||||
self._register_auth()
|
||||
self._register_dns()
|
||||
|
||||
def _register_host(self) -> None:
|
||||
"""Register hostcontrol functions."""
|
||||
@ -264,6 +266,21 @@ class RestAPI(CoreSysAttributes):
|
||||
]
|
||||
)
|
||||
|
||||
def _register_dns(self) -> None:
|
||||
"""Register DNS functions."""
|
||||
api_dns = APICoreDNS()
|
||||
api_dns.coresys = self.coresys
|
||||
|
||||
self.webapp.add_routes(
|
||||
[
|
||||
web.get("/dns/info", api_dns.info),
|
||||
web.get("/dns/stats", api_dns.stats),
|
||||
web.get("/dns/logs", api_dns.logs),
|
||||
web.post("/dns/update", api_dns.update),
|
||||
web.post("/dns/options", api_dns.options),
|
||||
]
|
||||
)
|
||||
|
||||
def _register_panel(self) -> None:
|
||||
"""Register panel for Home Assistant."""
|
||||
panel_dir = Path(__file__).parent.joinpath("panel")
|
||||
|
@ -41,6 +41,7 @@ from ..const import (
|
||||
ATTR_HOST_IPC,
|
||||
ATTR_HOST_NETWORK,
|
||||
ATTR_HOST_PID,
|
||||
ATTR_HOSTNAME,
|
||||
ATTR_ICON,
|
||||
ATTR_INGRESS,
|
||||
ATTR_INGRESS_ENTRY,
|
||||
@ -180,6 +181,7 @@ class APIAddons(CoreSysAttributes):
|
||||
data = {
|
||||
ATTR_NAME: addon.name,
|
||||
ATTR_SLUG: addon.slug,
|
||||
ATTR_HOSTNAME: addon.hostname,
|
||||
ATTR_DESCRIPTON: addon.description,
|
||||
ATTR_LONG_DESCRIPTION: addon.long_description,
|
||||
ATTR_AUTO_UPDATE: None,
|
||||
|
87
hassio/api/dns.py
Normal file
87
hassio/api/dns.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Init file for Hass.io DNS 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_HOST,
|
||||
ATTR_LATEST_VERSION,
|
||||
ATTR_MEMORY_LIMIT,
|
||||
ATTR_MEMORY_USAGE,
|
||||
ATTR_NETWORK_RX,
|
||||
ATTR_NETWORK_TX,
|
||||
ATTR_SERVERS,
|
||||
ATTR_VERSION,
|
||||
CONTENT_TYPE_BINARY,
|
||||
)
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import APIError
|
||||
from ..validate import DNS_SERVER_LIST
|
||||
from .utils import api_process, api_process_raw, api_validate
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_SERVERS): DNS_SERVER_LIST})
|
||||
|
||||
SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
|
||||
|
||||
|
||||
class APICoreDNS(CoreSysAttributes):
|
||||
"""Handle RESTful API for DNS functions."""
|
||||
|
||||
@api_process
|
||||
async def info(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return DNS information."""
|
||||
return {
|
||||
ATTR_VERSION: self.sys_dns.version,
|
||||
ATTR_LATEST_VERSION: self.sys_dns.latest_version,
|
||||
ATTR_HOST: str(self.sys_docker.network.dns),
|
||||
ATTR_SERVERS: self.sys_dns.servers,
|
||||
}
|
||||
|
||||
@api_process
|
||||
async def options(self, request: web.Request) -> None:
|
||||
"""Set DNS options."""
|
||||
body = await api_validate(SCHEMA_OPTIONS, request)
|
||||
|
||||
if ATTR_SERVERS in body:
|
||||
self.sys_dns.servers = body[ATTR_SERVERS]
|
||||
|
||||
self.sys_dns.save_data()
|
||||
|
||||
@api_process
|
||||
async def stats(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Return resource information."""
|
||||
stats = await self.sys_dns.stats()
|
||||
|
||||
return {
|
||||
ATTR_CPU_PERCENT: stats.cpu_percent,
|
||||
ATTR_MEMORY_USAGE: stats.memory_usage,
|
||||
ATTR_MEMORY_LIMIT: stats.memory_limit,
|
||||
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 DNS plugin."""
|
||||
body = await api_validate(SCHEMA_VERSION, request)
|
||||
version = body.get(ATTR_VERSION, self.sys_dns.latest_version)
|
||||
|
||||
if version == self.sys_dns.version:
|
||||
raise APIError("Version {} is already in use".format(version))
|
||||
await asyncio.shield(self.sys_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()
|
@ -10,6 +10,8 @@ from .utils.json import read_json_file
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ARCH_JSON: Path = Path(__file__).parent.joinpath("data/arch.json")
|
||||
|
||||
MAP_CPU = {
|
||||
"armv7": "armv7",
|
||||
"armv6": "armhf",
|
||||
@ -47,7 +49,7 @@ class CpuArch(CoreSysAttributes):
|
||||
async def load(self) -> None:
|
||||
"""Load data and initialize default arch."""
|
||||
try:
|
||||
arch_data = read_json_file(Path(__file__).parent.joinpath("arch.json"))
|
||||
arch_data = read_json_file(ARCH_JSON)
|
||||
except JsonFileError:
|
||||
_LOGGER.warning("Can't read arch json")
|
||||
return
|
||||
|
@ -11,11 +11,12 @@ from .addons import AddonManager
|
||||
from .api import RestAPI
|
||||
from .arch import CpuArch
|
||||
from .auth import Auth
|
||||
from .const import SOCKET_DOCKER
|
||||
from .const import CHANNEL_DEV, SOCKET_DOCKER
|
||||
from .core import HassIO
|
||||
from .coresys import CoreSys
|
||||
from .dbus import DBusManager
|
||||
from .discovery import Discovery
|
||||
from .dns import CoreDNS
|
||||
from .hassos import HassOS
|
||||
from .homeassistant import HomeAssistant
|
||||
from .host import HostManager
|
||||
@ -43,6 +44,7 @@ async def initialize_coresys():
|
||||
|
||||
# Initialize core objects
|
||||
coresys.core = HassIO(coresys)
|
||||
coresys.dns = CoreDNS(coresys)
|
||||
coresys.arch = CpuArch(coresys)
|
||||
coresys.auth = Auth(coresys)
|
||||
coresys.updater = Updater(coresys)
|
||||
@ -127,9 +129,19 @@ def initialize_system_data(coresys: CoreSys):
|
||||
_LOGGER.info("Create Hass.io Apparmor folder %s", config.path_apparmor)
|
||||
config.path_apparmor.mkdir()
|
||||
|
||||
# dns folder
|
||||
if not config.path_dns.is_dir():
|
||||
_LOGGER.info("Create Hass.io DNS folder %s", config.path_dns)
|
||||
config.path_dns.mkdir()
|
||||
|
||||
# Update log level
|
||||
coresys.config.modify_log_level()
|
||||
|
||||
# Check if ENV is in development mode
|
||||
if bool(os.environ.get("SUPERVISOR_DEV", 0)):
|
||||
_LOGGER.warning("SUPERVISOR_DEV is set")
|
||||
coresys.updater.channel = CHANNEL_DEV
|
||||
|
||||
|
||||
def migrate_system_env(coresys: CoreSys):
|
||||
"""Cleanup some stuff after update."""
|
||||
|
@ -34,6 +34,7 @@ BACKUP_DATA = PurePath("backup")
|
||||
SHARE_DATA = PurePath("share")
|
||||
TMP_DATA = PurePath("tmp")
|
||||
APPARMOR_DATA = PurePath("apparmor")
|
||||
DNS_DATA = PurePath("dns")
|
||||
|
||||
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
|
||||
|
||||
@ -211,6 +212,16 @@ class CoreConfig(JsonConfig):
|
||||
"""Return root share data folder external for Docker."""
|
||||
return PurePath(self.path_extern_hassio, SHARE_DATA)
|
||||
|
||||
@property
|
||||
def path_extern_dns(self):
|
||||
"""Return dns path external for Docker."""
|
||||
return str(PurePath(self.path_extern_hassio, DNS_DATA))
|
||||
|
||||
@property
|
||||
def path_dns(self):
|
||||
"""Return dns path inside supervisor."""
|
||||
return Path(HASSIO_DATA, DNS_DATA)
|
||||
|
||||
@property
|
||||
def addons_repositories(self):
|
||||
"""Return list of custom Add-on repositories."""
|
||||
|
@ -24,6 +24,7 @@ FILE_HASSIO_UPDATER = Path(HASSIO_DATA, "updater.json")
|
||||
FILE_HASSIO_SERVICES = Path(HASSIO_DATA, "services.json")
|
||||
FILE_HASSIO_DISCOVERY = Path(HASSIO_DATA, "discovery.json")
|
||||
FILE_HASSIO_INGRESS = Path(HASSIO_DATA, "ingress.json")
|
||||
FILE_HASSIO_DNS = Path(HASSIO_DATA, "dns.json")
|
||||
|
||||
SOCKET_DOCKER = Path("/var/run/docker.sock")
|
||||
|
||||
@ -31,6 +32,9 @@ 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://8.8.8.8", "dns://1.1.1.1"]
|
||||
DNS_SUFFIX = "local.hass.io"
|
||||
|
||||
LABEL_VERSION = "io.hass.version"
|
||||
LABEL_ARCH = "io.hass.arch"
|
||||
LABEL_TYPE = "io.hass.type"
|
||||
@ -86,6 +90,7 @@ ATTR_VERSION_LATEST = "version_latest"
|
||||
ATTR_AUTO_UART = "auto_uart"
|
||||
ATTR_LAST_BOOT = "last_boot"
|
||||
ATTR_LAST_VERSION = "last_version"
|
||||
ATTR_LATEST_VERSION = "latest_version"
|
||||
ATTR_CHANNEL = "channel"
|
||||
ATTR_NAME = "name"
|
||||
ATTR_SLUG = "slug"
|
||||
@ -210,6 +215,8 @@ ATTR_ADMIN = "admin"
|
||||
ATTR_PANELS = "panels"
|
||||
ATTR_DEBUG = "debug"
|
||||
ATTR_DEBUG_BLOCK = "debug_block"
|
||||
ATTR_DNS = "dns"
|
||||
ATTR_SERVERS = "servers"
|
||||
|
||||
PROVIDE_SERVICE = "provide"
|
||||
NEED_SERVICE = "need"
|
||||
|
@ -30,6 +30,9 @@ class HassIO(CoreSysAttributes):
|
||||
|
||||
async def setup(self):
|
||||
"""Setup HassIO orchestration."""
|
||||
# Load CoreDNS
|
||||
await self.sys_dns.load()
|
||||
|
||||
# Load DBus
|
||||
await self.sys_dbus.load()
|
||||
|
||||
@ -69,9 +72,6 @@ class HassIO(CoreSysAttributes):
|
||||
# Load ingress
|
||||
await self.sys_ingress.load()
|
||||
|
||||
# start dns forwarding
|
||||
self.sys_create_task(self.sys_dns.start())
|
||||
|
||||
async def start(self):
|
||||
"""Start Hass.io orchestration."""
|
||||
await self.sys_api.start()
|
||||
@ -142,10 +142,10 @@ class HassIO(CoreSysAttributes):
|
||||
await asyncio.wait(
|
||||
[
|
||||
self.sys_api.stop(),
|
||||
self.sys_dns.stop(),
|
||||
self.sys_websession.close(),
|
||||
self.sys_websession_ssl.close(),
|
||||
self.sys_ingress.unload(),
|
||||
self.sys_dns.unload(),
|
||||
]
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
@ -176,6 +176,7 @@ class HassIO(CoreSysAttributes):
|
||||
await self.sys_run_in_executor(self.sys_docker.repair)
|
||||
|
||||
# Restore core functionality
|
||||
await self.sys_dns.repair()
|
||||
await self.sys_addons.repair()
|
||||
await self.sys_homeassistant.repair()
|
||||
|
||||
|
@ -1,14 +1,13 @@
|
||||
"""Handle core shared data."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .config import CoreConfig
|
||||
from .const import CHANNEL_DEV
|
||||
from .docker import DockerAPI
|
||||
from .misc.dns import DNSForward
|
||||
from .misc.hardware import Hardware
|
||||
from .misc.scheduler import Scheduler
|
||||
|
||||
@ -20,6 +19,7 @@ if TYPE_CHECKING:
|
||||
from .core import HassIO
|
||||
from .dbus import DBusManager
|
||||
from .discovery import Discovery
|
||||
from .dns import CoreDNS
|
||||
from .hassos import HassOS
|
||||
from .homeassistant import HomeAssistant
|
||||
from .host import HostManager
|
||||
@ -52,26 +52,26 @@ class CoreSys:
|
||||
self._hardware: Hardware = Hardware()
|
||||
self._docker: DockerAPI = DockerAPI()
|
||||
self._scheduler: Scheduler = Scheduler()
|
||||
self._dns: DNSForward = DNSForward()
|
||||
|
||||
# Internal objects pointers
|
||||
self._core: HassIO = None
|
||||
self._arch: CpuArch = None
|
||||
self._auth: Auth = None
|
||||
self._homeassistant: HomeAssistant = None
|
||||
self._supervisor: Supervisor = None
|
||||
self._addons: AddonManager = None
|
||||
self._api: RestAPI = None
|
||||
self._updater: Updater = None
|
||||
self._snapshots: SnapshotManager = None
|
||||
self._tasks: Tasks = None
|
||||
self._host: HostManager = None
|
||||
self._ingress: Ingress = None
|
||||
self._dbus: DBusManager = None
|
||||
self._hassos: HassOS = None
|
||||
self._services: ServiceManager = None
|
||||
self._store: StoreManager = None
|
||||
self._discovery: Discovery = None
|
||||
self._core: Optional[HassIO] = None
|
||||
self._arch: Optional[CpuArch] = None
|
||||
self._auth: Optional[Auth] = None
|
||||
self._dns: Optional[CoreDNS] = None
|
||||
self._homeassistant: Optional[HomeAssistant] = None
|
||||
self._supervisor: Optional[Supervisor] = None
|
||||
self._addons: Optional[AddonManager] = None
|
||||
self._api: Optional[RestAPI] = None
|
||||
self._updater: Optional[Updater] = None
|
||||
self._snapshots: Optional[SnapshotManager] = None
|
||||
self._tasks: Optional[Tasks] = None
|
||||
self._host: Optional[HostManager] = None
|
||||
self._ingress: Optional[Ingress] = None
|
||||
self._dbus: Optional[DBusManager] = None
|
||||
self._hassos: Optional[HassOS] = None
|
||||
self._services: Optional[ServiceManager] = None
|
||||
self._store: Optional[StoreManager] = None
|
||||
self._discovery: Optional[Discovery] = None
|
||||
|
||||
@property
|
||||
def machine(self) -> str:
|
||||
@ -125,11 +125,6 @@ class CoreSys:
|
||||
"""Return Scheduler object."""
|
||||
return self._scheduler
|
||||
|
||||
@property
|
||||
def dns(self) -> DNSForward:
|
||||
"""Return DNSForward object."""
|
||||
return self._dns
|
||||
|
||||
@property
|
||||
def core(self) -> HassIO:
|
||||
"""Return HassIO object."""
|
||||
@ -298,6 +293,18 @@ 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."""
|
||||
@ -395,11 +402,6 @@ class CoreSysAttributes:
|
||||
"""Return Scheduler object."""
|
||||
return self.coresys.scheduler
|
||||
|
||||
@property
|
||||
def sys_dns(self) -> DNSForward:
|
||||
"""Return DNSForward object."""
|
||||
return self.coresys.dns
|
||||
|
||||
@property
|
||||
def sys_core(self) -> HassIO:
|
||||
"""Return HassIO object."""
|
||||
@ -470,6 +472,11 @@ 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."""
|
||||
|
9
hassio/data/coredns.tmpl
Normal file
9
hassio/data/coredns.tmpl
Normal file
@ -0,0 +1,9 @@
|
||||
.:53 {
|
||||
log
|
||||
hosts /config/hosts {
|
||||
fallthrough
|
||||
}
|
||||
forward . $servers {
|
||||
health_check 10s
|
||||
}
|
||||
}
|
2
hassio/data/hosts.tmpl
Normal file
2
hassio/data/hosts.tmpl
Normal file
@ -0,0 +1,2 @@
|
||||
$supervisor hassio supervisor.local.hass.io hassio.local.hass.io
|
||||
$homeassistant homeassistant homeassistant.local.hass.io home-assistant.local.hass.io
|
305
hassio/dns.py
Normal file
305
hassio/dns.py
Normal file
@ -0,0 +1,305 @@
|
||||
"""Home Assistant control object."""
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from ipaddress import IPv4Address, AddressValueError
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Awaitable, Dict, List, Optional
|
||||
|
||||
from .const import ATTR_SERVERS, ATTR_VERSION, DNS_SERVERS, FILE_HASSIO_DNS, DNS_SUFFIX
|
||||
from .coresys import CoreSys, CoreSysAttributes
|
||||
from .docker.dns import DockerDNS
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
COREDNS_TMPL: Path = Path(__file__).parents[0].joinpath("data/coredns.tmpl")
|
||||
|
||||
|
||||
class CoreDNS(JsonConfig, CoreSysAttributes):
|
||||
"""Home Assistant core object for handle it."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Initialize hass object."""
|
||||
super().__init__(FILE_HASSIO_DNS, SCHEMA_DNS_CONFIG)
|
||||
self.coresys: CoreSys = coresys
|
||||
self.instance: DockerDNS = DockerDNS(coresys)
|
||||
self.forwarder: DNSForward = DNSForward()
|
||||
|
||||
self._hosts: Dict[IPv4Address, List[str]] = {}
|
||||
|
||||
@property
|
||||
def corefile(self) -> Path:
|
||||
"""Return Path to corefile."""
|
||||
return Path(self.sys_config.path_dns, "corefile")
|
||||
|
||||
@property
|
||||
def hosts(self) -> Path:
|
||||
"""Return Path to corefile."""
|
||||
return Path(self.sys_config.path_dns, "hosts")
|
||||
|
||||
@property
|
||||
def servers(self) -> List[str]:
|
||||
"""Return list of DNS servers."""
|
||||
return self._data[ATTR_SERVERS]
|
||||
|
||||
@servers.setter
|
||||
def servers(self, value: List[str]) -> None:
|
||||
"""Return list of DNS servers."""
|
||||
self._data[ATTR_SERVERS] = value
|
||||
|
||||
@property
|
||||
def version(self) -> Optional[str]:
|
||||
"""Return current version of DNS."""
|
||||
return self._data.get(ATTR_VERSION)
|
||||
|
||||
@version.setter
|
||||
def version(self, value: str) -> None:
|
||||
"""Return current version of DNS."""
|
||||
self._data[ATTR_VERSION] = value
|
||||
|
||||
@property
|
||||
def latest_version(self) -> Optional[str]:
|
||||
"""Return latest version of CoreDNS."""
|
||||
return self.sys_updater.version_dns
|
||||
|
||||
@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 DNS setup."""
|
||||
with suppress(CoreDNSError):
|
||||
self._import_hosts()
|
||||
|
||||
# Check CoreDNS 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 CoreDNS plugin Docker image %s found.", self.instance.image
|
||||
)
|
||||
|
||||
# Install CoreDNS
|
||||
with suppress(CoreDNSError):
|
||||
await self.install()
|
||||
else:
|
||||
self.version = self.instance.version
|
||||
self.save_data()
|
||||
|
||||
# Start DNS forwarder
|
||||
self.sys_create_task(self.forwarder.start(self.sys_docker.network.dns))
|
||||
|
||||
# Start is not Running
|
||||
if await self.instance.is_running():
|
||||
return
|
||||
await self.start()
|
||||
|
||||
async def unload(self) -> None:
|
||||
"""Unload DNS forwarder."""
|
||||
await self.forwarder.stop()
|
||||
|
||||
async def install(self) -> None:
|
||||
"""Install CoreDNS."""
|
||||
_LOGGER.info("Setup CoreDNS 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)
|
||||
break
|
||||
_LOGGER.warning("Error on install CoreDNS plugin. Retry in 30sec")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
_LOGGER.info("CoreDNS plugin now installed")
|
||||
self.version = self.instance.version
|
||||
self.save_data()
|
||||
|
||||
await self.start()
|
||||
|
||||
async def update(self, version: Optional[str] = None) -> None:
|
||||
"""Update CoreDNS plugin."""
|
||||
version = version or self.latest_version
|
||||
|
||||
if version == self.version:
|
||||
_LOGGER.warning("Version %s is already installed for CoreDNS", version)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.instance.update(version)
|
||||
except DockerAPIError:
|
||||
_LOGGER.error("CoreDNS update fails")
|
||||
raise CoreDNSUpdateError() from None
|
||||
else:
|
||||
# Cleanup
|
||||
with suppress(DockerAPIError):
|
||||
await self.instance.cleanup()
|
||||
|
||||
self.version = version
|
||||
self.save_data()
|
||||
|
||||
# Start CoreDNS
|
||||
await self.start()
|
||||
|
||||
async def restart(self) -> None:
|
||||
"""Restart CoreDNS plugin."""
|
||||
with suppress(DockerAPIError):
|
||||
await self.instance.stop()
|
||||
await self.start()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Run CoreDNS."""
|
||||
self._write_corefile()
|
||||
|
||||
# Start Instance
|
||||
_LOGGER.info("Start CoreDNS plugin")
|
||||
try:
|
||||
await self.instance.run()
|
||||
except DockerAPIError:
|
||||
_LOGGER.error("Can't start CoreDNS plugin")
|
||||
raise CoreDNSError() from None
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset Config / Hosts."""
|
||||
self.servers = DNS_SERVERS
|
||||
|
||||
with suppress(OSError):
|
||||
self.hosts.unlink()
|
||||
self._import_hosts()
|
||||
|
||||
def _write_corefile(self) -> None:
|
||||
"""Write CoreDNS config."""
|
||||
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
|
||||
|
||||
# Generate config file
|
||||
dns_servers = self.servers + list(set(DNS_SERVERS) - set(self.servers))
|
||||
data = corefile_template.safe_substitute(servers=" ".join(dns_servers))
|
||||
|
||||
try:
|
||||
self.corefile.write_text(data)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't update corefile: %s", err)
|
||||
raise CoreDNSError() from None
|
||||
|
||||
def _import_hosts(self) -> None:
|
||||
"""Import hosts entry."""
|
||||
# Generate Default
|
||||
if not self.hosts.exists():
|
||||
self.add_host(self.sys_docker.network.supervisor, ["hassio", "supervisor"])
|
||||
self.add_host(
|
||||
self.sys_docker.network.gateway, ["homeassistant", "home-assistant"]
|
||||
)
|
||||
return
|
||||
|
||||
# Import Exists host table
|
||||
try:
|
||||
with self.hosts.open("r") as hosts:
|
||||
for line in hosts.readlines():
|
||||
try:
|
||||
data = line.split(" ")
|
||||
self._hosts[IPv4Address(data[0])] = data[1:]
|
||||
except AddressValueError:
|
||||
_LOGGER.warning("Fails to read %s", line)
|
||||
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't read hosts file: %s", err)
|
||||
raise CoreDNSError() from None
|
||||
|
||||
def _write_hosts(self) -> None:
|
||||
"""Write hosts from memory to file."""
|
||||
try:
|
||||
with self.hosts.open("w") as hosts:
|
||||
for address, hostnames in self._hosts.items():
|
||||
host = " ".join(hostnames)
|
||||
hosts.write(f"{address!s} {host}")
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write hosts file: %s", err)
|
||||
raise CoreDNSError() from None
|
||||
|
||||
def add_host(self, ipv4: IPv4Address, names: List[str]) -> None:
|
||||
"""Add a new host entry."""
|
||||
hostnames: List[str] = []
|
||||
for name in names:
|
||||
hostnames.append(name)
|
||||
hostnames.append(f"{name}.{DNS_SUFFIX}")
|
||||
|
||||
self._hosts[ipv4] = hostnames
|
||||
_LOGGER.debug("Add Host entry %s -> %s", ipv4, hostnames)
|
||||
|
||||
self._write_hosts()
|
||||
|
||||
def delete_host(
|
||||
self, ipv4: Optional[IPv4Address] = None, host: Optional[str] = None
|
||||
) -> None:
|
||||
"""Remove a entry from hosts."""
|
||||
if host:
|
||||
for address, hostnames in self._hosts.items():
|
||||
if host not in hostnames:
|
||||
continue
|
||||
ipv4 = address
|
||||
break
|
||||
|
||||
# Remove entry
|
||||
if ipv4:
|
||||
_LOGGER.debug("Remove Host entry %s", ipv4)
|
||||
self._hosts.pop(ipv4, None)
|
||||
|
||||
self._write_hosts()
|
||||
else:
|
||||
_LOGGER.warning("Can't remove Host entry: %s/%s", ipv4, host)
|
||||
|
||||
def logs(self) -> Awaitable[bytes]:
|
||||
"""Get CoreDNS docker logs.
|
||||
|
||||
Return Coroutine.
|
||||
"""
|
||||
return self.instance.logs()
|
||||
|
||||
async def stats(self) -> DockerStats:
|
||||
"""Return stats of CoreDNS."""
|
||||
try:
|
||||
return await self.instance.stats()
|
||||
except DockerAPIError:
|
||||
raise CoreDNSError() 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):
|
||||
"""Repair CoreDNS plugin."""
|
||||
if await self.instance.exists():
|
||||
return
|
||||
|
||||
_LOGGER.info("Repair CoreDNS %s", self.version)
|
||||
try:
|
||||
await self.instance.install(self.version)
|
||||
except DockerAPIError:
|
||||
_LOGGER.error("Repairing of CoreDNS fails")
|
@ -1,12 +1,13 @@
|
||||
"""Init file for Hass.io Docker object."""
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import attr
|
||||
import docker
|
||||
|
||||
from ..const import SOCKET_DOCKER
|
||||
from ..const import SOCKET_DOCKER, DNS_SUFFIX
|
||||
from ..exceptions import DockerAPIError
|
||||
from .network import DockerNetwork
|
||||
|
||||
@ -50,7 +51,11 @@ class DockerAPI:
|
||||
return self.docker.api
|
||||
|
||||
def run(
|
||||
self, image: str, version: str = "latest", **kwargs: Dict[str, Any]
|
||||
self,
|
||||
image: str,
|
||||
version: str = "latest",
|
||||
ipv4: Optional[IPv4Address] = None,
|
||||
**kwargs: Dict[str, Any],
|
||||
) -> docker.models.containers.Container:
|
||||
""""Create a Docker container and run it.
|
||||
|
||||
@ -60,12 +65,13 @@ class DockerAPI:
|
||||
network_mode: str = kwargs.get("network_mode")
|
||||
hostname: str = kwargs.get("hostname")
|
||||
|
||||
# Setup DNS
|
||||
kwargs["dns"] = [str(self.network.dns)]
|
||||
kwargs["dns_search"] = [DNS_SUFFIX]
|
||||
kwargs["domainname"] = DNS_SUFFIX
|
||||
|
||||
# Setup network
|
||||
kwargs["dns_search"] = ["."]
|
||||
if network_mode:
|
||||
kwargs["dns"] = [str(self.network.supervisor)]
|
||||
kwargs["dns_opt"] = ["ndots:0"]
|
||||
else:
|
||||
if not network_mode:
|
||||
kwargs["network"] = None
|
||||
|
||||
# Create container
|
||||
@ -81,7 +87,7 @@ class DockerAPI:
|
||||
if not network_mode:
|
||||
alias = [hostname] if hostname else None
|
||||
try:
|
||||
self.network.attach_container(container, alias=alias)
|
||||
self.network.attach_container(container, alias=alias, ipv4=ipv4)
|
||||
except DockerAPIError:
|
||||
_LOGGER.warning("Can't attach %s to hassio-net!", name)
|
||||
else:
|
||||
|
@ -35,6 +35,7 @@ if TYPE_CHECKING:
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm"
|
||||
NO_ADDDRESS = ip_address("0.0.0.0")
|
||||
|
||||
|
||||
class DockerAddon(DockerInterface):
|
||||
@ -62,7 +63,7 @@ class DockerAddon(DockerInterface):
|
||||
self._meta["NetworkSettings"]["Networks"]["hassio"]["IPAddress"]
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return ip_address("0.0.0.0")
|
||||
return NO_ADDDRESS
|
||||
|
||||
@property
|
||||
def timeout(self) -> int:
|
||||
@ -100,11 +101,6 @@ class DockerAddon(DockerInterface):
|
||||
"""Return True if full access is enabled."""
|
||||
return not self.addon.protected and self.addon.with_full_access
|
||||
|
||||
@property
|
||||
def hostname(self) -> str:
|
||||
"""Return slug/id of add-on."""
|
||||
return self.addon.slug.replace("_", "-")
|
||||
|
||||
@property
|
||||
def environment(self) -> Dict[str, str]:
|
||||
"""Return environment for Docker add-on."""
|
||||
@ -186,10 +182,7 @@ class DockerAddon(DockerInterface):
|
||||
@property
|
||||
def network_mapping(self) -> Dict[str, str]:
|
||||
"""Return hosts mapping."""
|
||||
return {
|
||||
"homeassistant": self.sys_docker.network.gateway,
|
||||
"hassio": self.sys_docker.network.supervisor,
|
||||
}
|
||||
return {"hassio": self.sys_docker.network.supervisor}
|
||||
|
||||
@property
|
||||
def network_mode(self) -> Optional[str]:
|
||||
@ -329,7 +322,7 @@ class DockerAddon(DockerInterface):
|
||||
self.image,
|
||||
version=self.addon.version,
|
||||
name=self.name,
|
||||
hostname=self.hostname,
|
||||
hostname=self.addon.hostname,
|
||||
detach=True,
|
||||
init=True,
|
||||
privileged=self.full_access,
|
||||
@ -350,6 +343,9 @@ class DockerAddon(DockerInterface):
|
||||
self._meta = docker_container.attrs
|
||||
_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])
|
||||
|
||||
def _install(
|
||||
self, tag: str, image: Optional[str] = None, latest: bool = False
|
||||
) -> None:
|
||||
@ -467,3 +463,12 @@ class DockerAddon(DockerInterface):
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write to %s stdin: %s", self.name, err)
|
||||
raise DockerAPIError() from None
|
||||
|
||||
def _stop(self, remove_container=True) -> None:
|
||||
"""Stop/remove Docker container.
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
if self.ip_address != NO_ADDDRESS:
|
||||
self.sys_dns.delete_host(ipv4=self.ip_address)
|
||||
super()._stop(remove_container)
|
||||
|
56
hassio/docker/dns.py
Normal file
56
hassio/docker/dns.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""HassOS Cli docker object."""
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
|
||||
from ..const import ENV_TIME
|
||||
from ..coresys import CoreSysAttributes
|
||||
from ..exceptions import DockerAPIError
|
||||
from .interface import DockerInterface
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DNS_DOCKER_NAME: str = "hassio_dns"
|
||||
|
||||
|
||||
class DockerDNS(DockerInterface, CoreSysAttributes):
|
||||
"""Docker Hass.io wrapper for Hass.io DNS."""
|
||||
|
||||
@property
|
||||
def image(self) -> str:
|
||||
"""Return name of Hass.io DNS image."""
|
||||
return f"homeassistant/{self.sys_arch.supervisor}-hassio-dns"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return name of Docker container."""
|
||||
return DNS_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_dns.version,
|
||||
ipv4=self.sys_docker.network.dns,
|
||||
name=self.name,
|
||||
hostname=self.name.replace("_", "-"),
|
||||
detach=True,
|
||||
init=True,
|
||||
environment={ENV_TIME: self.sys_timezone},
|
||||
volumes={
|
||||
str(self.sys_config.path_extern_dns): {"bind": "/config", "mode": "ro"}
|
||||
},
|
||||
)
|
||||
|
||||
self._meta = docker_container.attrs
|
||||
_LOGGER.info("Start DNS %s with version %s", self.image, self.version)
|
@ -1,10 +1,8 @@
|
||||
"""Init file for Hass.io Docker object."""
|
||||
from distutils.version import StrictVersion
|
||||
from contextlib import suppress
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
import re
|
||||
from typing import Awaitable, List, Optional
|
||||
from typing import Awaitable, Optional
|
||||
|
||||
import docker
|
||||
|
||||
@ -15,7 +13,6 @@ from .interface import CommandReturn, DockerInterface
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HASS_DOCKER_NAME = "homeassistant"
|
||||
RE_VERSION = re.compile(r"(?P<version>\d+\.\d+\.\d+(?:b\d+|d\d+)?)")
|
||||
|
||||
|
||||
class DockerHomeAssistant(DockerInterface):
|
||||
@ -139,33 +136,3 @@ class DockerHomeAssistant(DockerInterface):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_latest_version(self) -> Awaitable[str]:
|
||||
"""Return latest version of local Home Asssistant image."""
|
||||
return self.sys_run_in_executor(self._get_latest_version)
|
||||
|
||||
def _get_latest_version(self) -> str:
|
||||
"""Return latest version of local Home Asssistant image.
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
available_version: List[str] = []
|
||||
try:
|
||||
for image in self.sys_docker.images.list(self.image):
|
||||
for tag in image.tags:
|
||||
match = RE_VERSION.search(tag)
|
||||
if not match:
|
||||
continue
|
||||
available_version.append(match.group("version"))
|
||||
|
||||
assert available_version
|
||||
|
||||
except (docker.errors.DockerException, AssertionError):
|
||||
_LOGGER.warning("No local HA version found")
|
||||
raise DockerAPIError()
|
||||
else:
|
||||
_LOGGER.debug("Found HA versions: %s", available_version)
|
||||
|
||||
# Sort version and return latest version
|
||||
available_version.sort(key=StrictVersion, reverse=True)
|
||||
return available_version[0]
|
||||
|
@ -2,16 +2,16 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Awaitable
|
||||
from typing import Any, Awaitable, Dict, List, Optional
|
||||
|
||||
import docker
|
||||
|
||||
from . import CommandReturn
|
||||
from ..const import LABEL_ARCH, LABEL_VERSION
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import DockerAPIError
|
||||
from ..utils import process_lock
|
||||
from .stats import DockerStats
|
||||
from . import CommandReturn
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -50,7 +50,10 @@ class DockerInterface(CoreSysAttributes):
|
||||
@property
|
||||
def image(self) -> Optional[str]:
|
||||
"""Return name of Docker image."""
|
||||
return self.meta_config.get("Image")
|
||||
try:
|
||||
return self.meta_config["Image"].partition(":")[0]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def version(self) -> Optional[str]:
|
||||
@ -80,7 +83,6 @@ class DockerInterface(CoreSysAttributes):
|
||||
Need run inside executor.
|
||||
"""
|
||||
image = image or self.image
|
||||
image = image.partition(":")[0] # remove potential tag
|
||||
|
||||
_LOGGER.info("Pull image %s tag %s.", image, tag)
|
||||
try:
|
||||
@ -397,3 +399,35 @@ class DockerInterface(CoreSysAttributes):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_latest_version(self, key: Any = int) -> Awaitable[str]:
|
||||
"""Return latest version of local Home Asssistant image."""
|
||||
return self.sys_run_in_executor(self._get_latest_version, key)
|
||||
|
||||
def _get_latest_version(self, key: Any = int) -> str:
|
||||
"""Return latest version of local Home Asssistant image.
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
available_version: List[str] = []
|
||||
try:
|
||||
for image in self.sys_docker.images.list(self.image):
|
||||
for tag in image.tags:
|
||||
version = tag.partition(":")[2]
|
||||
try:
|
||||
key(version)
|
||||
except (AttributeError, ValueError):
|
||||
continue
|
||||
available_version.append(version)
|
||||
|
||||
assert available_version
|
||||
|
||||
except (docker.errors.DockerException, AssertionError):
|
||||
_LOGGER.debug("No version found for %s", self.image)
|
||||
raise DockerAPIError()
|
||||
else:
|
||||
_LOGGER.debug("Found HA versions: %s", available_version)
|
||||
|
||||
# Sort version and return latest version
|
||||
available_version.sort(key=key, reverse=True)
|
||||
return available_version[0]
|
||||
|
@ -42,6 +42,11 @@ class DockerNetwork:
|
||||
"""Return supervisor of the network."""
|
||||
return DOCKER_NETWORK_MASK[2]
|
||||
|
||||
@property
|
||||
def dns(self) -> IPv4Address:
|
||||
"""Return dns of the network."""
|
||||
return DOCKER_NETWORK_MASK[3]
|
||||
|
||||
def _get_network(self) -> docker.models.networks.Network:
|
||||
"""Get HassIO network."""
|
||||
try:
|
||||
|
@ -38,7 +38,9 @@ class DockerSupervisor(DockerInterface, CoreSysAttributes):
|
||||
|
||||
self._meta = docker_container.attrs
|
||||
_LOGGER.info(
|
||||
"Attach to Supervisor %s with version %s", self.image, self.version
|
||||
"Attach to Supervisor %s with version %s",
|
||||
self.image,
|
||||
self.sys_supervisor.version,
|
||||
)
|
||||
|
||||
# If already attach
|
||||
|
@ -54,6 +54,17 @@ class HassOSNotSupportedError(HassioNotSupportedError):
|
||||
"""Function not supported by HassOS."""
|
||||
|
||||
|
||||
# DNS
|
||||
|
||||
|
||||
class CoreDNSError(HassioError):
|
||||
"""CoreDNS exception."""
|
||||
|
||||
|
||||
class CoreDNSUpdateError(CoreDNSError):
|
||||
"""Error on update of a CoreDNS."""
|
||||
|
||||
|
||||
# Addons
|
||||
|
||||
|
||||
|
@ -188,13 +188,13 @@ class HassOS(CoreSysAttributes):
|
||||
|
||||
try:
|
||||
await self.instance.update(version, latest=True)
|
||||
|
||||
# Cleanup
|
||||
with suppress(DockerAPIError):
|
||||
await self.instance.cleanup()
|
||||
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."""
|
||||
|
@ -2,6 +2,7 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from datetime import datetime, timedelta
|
||||
from distutils.version import StrictVersion
|
||||
from ipaddress import IPv4Address
|
||||
import logging
|
||||
import os
|
||||
@ -79,7 +80,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
||||
try:
|
||||
# Evaluate Version if we lost this information
|
||||
if not self.version:
|
||||
self.version = await self.instance.get_latest_version()
|
||||
self.version = await self.instance.get_latest_version(key=StrictVersion)
|
||||
|
||||
await self.instance.attach(tag=self.version)
|
||||
except DockerAPIError:
|
||||
|
@ -15,6 +15,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DefaultConfig = attr.make_class("DefaultConfig", ["input", "output"])
|
||||
|
||||
|
||||
AUDIODB_JSON: Path = Path(__file__).parents[1].joinpath("data/audiodb.json")
|
||||
ASOUND_TMPL: Path = Path(__file__).parents[1].joinpath("data/asound.tmpl")
|
||||
|
||||
|
||||
class AlsaAudio(CoreSysAttributes):
|
||||
"""Handle Audio ALSA host data."""
|
||||
|
||||
@ -82,12 +86,8 @@ class AlsaAudio(CoreSysAttributes):
|
||||
@staticmethod
|
||||
def _audio_database():
|
||||
"""Read local json audio data into dict."""
|
||||
json_file = Path(__file__).parent.joinpath("data/audiodb.json")
|
||||
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
with json_file.open("r") as database:
|
||||
return json.loads(database.read())
|
||||
return json.loads(AUDIODB_JSON.read_text())
|
||||
except (ValueError, OSError) as err:
|
||||
_LOGGER.warning("Can't read audio DB: %s", err)
|
||||
|
||||
@ -122,11 +122,8 @@ class AlsaAudio(CoreSysAttributes):
|
||||
alsa_output = alsa_output or self.default.output
|
||||
|
||||
# Read Template
|
||||
asound_file = Path(__file__).parent.joinpath("data/asound.tmpl")
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
with asound_file.open("r") as asound:
|
||||
asound_data = asound.read()
|
||||
asound_data = ASOUND_TMPL.read_text()
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't read asound.tmpl: %s", err)
|
||||
return ""
|
||||
|
@ -2,12 +2,14 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Optional
|
||||
|
||||
import async_timeout
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
COMMAND = "socat UDP-RECVFROM:53,fork UDP-SENDTO:127.0.0.11:53"
|
||||
COMMAND = "socat UDP-RECVFROM:53,fork UDP-SENDTO:{!s}"
|
||||
|
||||
|
||||
class DNSForward:
|
||||
@ -15,13 +17,13 @@ class DNSForward:
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize DNS forwarding."""
|
||||
self.proc = None
|
||||
self.proc: Optional[asyncio.Process] = None
|
||||
|
||||
async def start(self):
|
||||
async def start(self, dns_server: IPv4Address) -> None:
|
||||
"""Start DNS forwarding."""
|
||||
try:
|
||||
self.proc = await asyncio.create_subprocess_exec(
|
||||
*shlex.split(COMMAND),
|
||||
*shlex.split(COMMAND.format(dns_server)),
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
@ -29,9 +31,9 @@ class DNSForward:
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't start DNS forwarding: %s", err)
|
||||
else:
|
||||
_LOGGER.info("Start DNS port forwarding for host add-ons")
|
||||
_LOGGER.info("Start DNS port forwarding to %s", dns_server)
|
||||
|
||||
async def stop(self):
|
||||
async def stop(self) -> None:
|
||||
"""Stop DNS forwarding."""
|
||||
if not self.proc:
|
||||
_LOGGER.warning("DNS forwarding is not running!")
|
@ -3,7 +3,7 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from .coresys import CoreSysAttributes
|
||||
from .exceptions import HomeAssistantError
|
||||
from .exceptions import HomeAssistantError, CoreDNSError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -22,6 +22,8 @@ RUN_RELOAD_INGRESS = 930
|
||||
RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15
|
||||
RUN_WATCHDOG_HOMEASSISTANT_API = 300
|
||||
|
||||
RUN_WATCHDOG_DNS_DOCKER = 20
|
||||
|
||||
|
||||
class Tasks(CoreSysAttributes):
|
||||
"""Handle Tasks inside Hass.io."""
|
||||
@ -83,6 +85,11 @@ class Tasks(CoreSysAttributes):
|
||||
self._watchdog_homeassistant_api, RUN_WATCHDOG_HOMEASSISTANT_API
|
||||
)
|
||||
)
|
||||
self.jobs.add(
|
||||
self.sys_scheduler.register_task(
|
||||
self._watchdog_dns_docker, RUN_WATCHDOG_DNS_DOCKER
|
||||
)
|
||||
)
|
||||
|
||||
_LOGGER.info("All core tasks are scheduled")
|
||||
|
||||
@ -194,3 +201,19 @@ class Tasks(CoreSysAttributes):
|
||||
|
||||
_LOGGER.info("Found new HassOS CLI version")
|
||||
await self.sys_hassos.update_cli()
|
||||
|
||||
async def _watchdog_dns_docker(self):
|
||||
"""Check running state of Docker and start if they is close."""
|
||||
# if Home Assistant is active
|
||||
if await self.sys_dns.is_running():
|
||||
return
|
||||
_LOGGER.warning("Watchdog found a problem with CoreDNS plugin!")
|
||||
|
||||
if await self.sys_dns.is_fails():
|
||||
_LOGGER.warning("CoreDNS plugin is in fails state / Reset config")
|
||||
self.sys_dns.reset()
|
||||
|
||||
try:
|
||||
await self.sys_dns.start()
|
||||
except CoreDNSError:
|
||||
_LOGGER.error("Watchdog CoreDNS reanimation fails!")
|
||||
|
@ -4,23 +4,25 @@ from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .const import (
|
||||
URL_HASSIO_VERSION,
|
||||
FILE_HASSIO_UPDATER,
|
||||
ATTR_HOMEASSISTANT,
|
||||
ATTR_HASSIO,
|
||||
ATTR_CHANNEL,
|
||||
ATTR_DNS,
|
||||
ATTR_HASSIO,
|
||||
ATTR_HASSOS,
|
||||
ATTR_HASSOS_CLI,
|
||||
ATTR_HOMEASSISTANT,
|
||||
FILE_HASSIO_UPDATER,
|
||||
URL_HASSIO_VERSION,
|
||||
)
|
||||
from .coresys import CoreSysAttributes
|
||||
from .exceptions import HassioUpdaterError
|
||||
from .utils import AsyncThrottle
|
||||
from .utils.json import JsonConfig
|
||||
from .validate import SCHEMA_UPDATER_CONFIG
|
||||
from .exceptions import HassioUpdaterError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -33,43 +35,48 @@ class Updater(JsonConfig, CoreSysAttributes):
|
||||
super().__init__(FILE_HASSIO_UPDATER, SCHEMA_UPDATER_CONFIG)
|
||||
self.coresys = coresys
|
||||
|
||||
async def load(self):
|
||||
async def load(self) -> None:
|
||||
"""Update internal data."""
|
||||
with suppress(HassioUpdaterError):
|
||||
await self.fetch_data()
|
||||
|
||||
async def reload(self):
|
||||
async def reload(self) -> None:
|
||||
"""Update internal data."""
|
||||
with suppress(HassioUpdaterError):
|
||||
await self.fetch_data()
|
||||
|
||||
@property
|
||||
def version_homeassistant(self):
|
||||
"""Return last version of Home Assistant."""
|
||||
def version_homeassistant(self) -> Optional[str]:
|
||||
"""Return latest version of Home Assistant."""
|
||||
return self._data.get(ATTR_HOMEASSISTANT)
|
||||
|
||||
@property
|
||||
def version_hassio(self):
|
||||
"""Return last version of Hass.io."""
|
||||
def version_hassio(self) -> Optional[str]:
|
||||
"""Return latest version of Hass.io."""
|
||||
return self._data.get(ATTR_HASSIO)
|
||||
|
||||
@property
|
||||
def version_hassos(self):
|
||||
"""Return last version of HassOS."""
|
||||
def version_hassos(self) -> Optional[str]:
|
||||
"""Return latest version of HassOS."""
|
||||
return self._data.get(ATTR_HASSOS)
|
||||
|
||||
@property
|
||||
def version_hassos_cli(self):
|
||||
"""Return last version of HassOS cli."""
|
||||
def version_hassos_cli(self) -> Optional[str]:
|
||||
"""Return latest version of HassOS cli."""
|
||||
return self._data.get(ATTR_HASSOS_CLI)
|
||||
|
||||
@property
|
||||
def channel(self):
|
||||
def version_dns(self) -> Optional[str]:
|
||||
"""Return latest version of Hass.io DNS."""
|
||||
return self._data.get(ATTR_DNS)
|
||||
|
||||
@property
|
||||
def channel(self) -> str:
|
||||
"""Return upstream channel of Hass.io instance."""
|
||||
return self._data[ATTR_CHANNEL]
|
||||
|
||||
@channel.setter
|
||||
def channel(self, value):
|
||||
def channel(self, value: str):
|
||||
"""Set upstream mode."""
|
||||
self._data[ATTR_CHANNEL] = value
|
||||
|
||||
@ -104,6 +111,7 @@ class Updater(JsonConfig, CoreSysAttributes):
|
||||
try:
|
||||
# update supervisor version
|
||||
self._data[ATTR_HASSIO] = data["supervisor"]
|
||||
self._data[ATTR_DNS] = data["dns"]
|
||||
|
||||
# update Home Assistant version
|
||||
self._data[ATTR_HOMEASSISTANT] = data["homeassistant"][machine]
|
||||
|
@ -11,6 +11,7 @@ from .const import (
|
||||
ATTR_CHANNEL,
|
||||
ATTR_DEBUG,
|
||||
ATTR_DEBUG_BLOCK,
|
||||
ATTR_DNS,
|
||||
ATTR_HASSIO,
|
||||
ATTR_HASSOS,
|
||||
ATTR_HASSOS_CLI,
|
||||
@ -23,6 +24,7 @@ from .const import (
|
||||
ATTR_PORT,
|
||||
ATTR_PORTS,
|
||||
ATTR_REFRESH_TOKEN,
|
||||
ATTR_SERVERS,
|
||||
ATTR_SESSION,
|
||||
ATTR_SSL,
|
||||
ATTR_TIMEZONE,
|
||||
@ -33,11 +35,13 @@ from .const import (
|
||||
CHANNEL_BETA,
|
||||
CHANNEL_DEV,
|
||||
CHANNEL_STABLE,
|
||||
DNS_SERVERS,
|
||||
)
|
||||
from .utils.validate import validate_timezone
|
||||
|
||||
RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
|
||||
WAIT_BOOT = vol.All(vol.Coerce(int), vol.Range(min=1, max=60))
|
||||
DOCKER_IMAGE = vol.Match(r"^[\w{}]+/[\-\w{}]+$")
|
||||
@ -47,6 +51,7 @@ UUID_MATCH = vol.Match(r"^[0-9a-f]{32}$")
|
||||
SHA256 = vol.Match(r"^[0-9a-f]{64}$")
|
||||
TOKEN = vol.Match(r"^[0-9a-f]{32,256}$")
|
||||
LOG_LEVEL = vol.In(["debug", "info", "warning", "error", "critical"])
|
||||
DNS_SERVER_LIST = vol.All([vol.Url()], vol.Length(max=8))
|
||||
|
||||
|
||||
def validate_repository(repository):
|
||||
@ -108,6 +113,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema(
|
||||
vol.Optional(ATTR_HASSIO): vol.Coerce(str),
|
||||
vol.Optional(ATTR_HASSOS): vol.Coerce(str),
|
||||
vol.Optional(ATTR_HASSOS_CLI): vol.Coerce(str),
|
||||
vol.Optional(ATTR_DNS): vol.Coerce(str),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
@ -145,3 +151,12 @@ 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_SERVERS, default=DNS_SERVERS): DNS_SERVER_LIST,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
102
script/test_env.sh
Executable file
102
script/test_env.sh
Executable file
@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
set -eE
|
||||
|
||||
DOCKER_TIMEOUT=30
|
||||
DOCKER_PID=0
|
||||
|
||||
|
||||
function start_docker() {
|
||||
local starttime
|
||||
local endtime
|
||||
|
||||
echo "Starting docker."
|
||||
dockerd 2> /dev/null &
|
||||
DOCKER_PID=$!
|
||||
|
||||
echo "Waiting for docker to initialize..."
|
||||
starttime="$(date +%s)"
|
||||
endtime="$(date +%s)"
|
||||
until docker info >/dev/null 2>&1; do
|
||||
if [ $((endtime - starttime)) -le $DOCKER_TIMEOUT ]; then
|
||||
sleep 1
|
||||
endtime=$(date +%s)
|
||||
else
|
||||
echo "Timeout while waiting for docker to come up"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Docker was initialized"
|
||||
}
|
||||
|
||||
|
||||
function stop_docker() {
|
||||
local starttime
|
||||
local endtime
|
||||
|
||||
echo "Stopping in container docker..."
|
||||
if [ "$DOCKER_PID" -gt 0 ] && kill -0 "$DOCKER_PID" 2> /dev/null; then
|
||||
starttime="$(date +%s)"
|
||||
endtime="$(date +%s)"
|
||||
|
||||
# Now wait for it to die
|
||||
kill "$DOCKER_PID"
|
||||
while kill -0 "$DOCKER_PID" 2> /dev/null; do
|
||||
if [ $((endtime - starttime)) -le $DOCKER_TIMEOUT ]; then
|
||||
sleep 1
|
||||
endtime=$(date +%s)
|
||||
else
|
||||
echo "Timeout while waiting for container docker to die"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "Your host might have been left with unreleased resources"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
function build_supervisor() {
|
||||
docker pull homeassistant/amd64-builder:dev
|
||||
|
||||
docker run --rm --privileged \
|
||||
-v /run/docker.sock:/run/docker.sock -v "$(pwd):/data" \
|
||||
homeassistant/amd64-builder:dev \
|
||||
--supervisor 3.7-alpine3.10 --version dev \
|
||||
-t /data --test --amd64 \
|
||||
--no-cache --docker-hub homeassistant
|
||||
}
|
||||
|
||||
|
||||
function install_cli() {
|
||||
docker pull homeassistant/amd64-hassio-cli:dev
|
||||
}
|
||||
|
||||
|
||||
function setup_test_env() {
|
||||
mkdir -p test_data
|
||||
|
||||
docker run --rm --privileged \
|
||||
--name hassio_supervisor \
|
||||
--security-opt seccomp=unconfined \
|
||||
--security-opt apparmor:unconfined \
|
||||
-v /run/docker.sock:/run/docker.sock \
|
||||
-v /run/dbus:/run/dbus \
|
||||
-v "$(pwd)/test_data":/data \
|
||||
-v /etc/machine-id:/etc/machine-id:ro \
|
||||
-e SUPERVISOR_SHARE="$(pwd)/test_data" \
|
||||
-e SUPERVISOR_NAME=hassio_supervisor \
|
||||
-e SUPERVISOR_DEV=1 \
|
||||
-e HOMEASSISTANT_REPOSITORY="homeassistant/qemux86-64-homeassistant" \
|
||||
homeassistant/amd64-hassio-supervisor:latest
|
||||
}
|
||||
|
||||
echo "Start Test-Env"
|
||||
|
||||
start_docker
|
||||
trap "stop_docker" ERR
|
||||
|
||||
|
||||
build_supervisor
|
||||
install_cli
|
||||
setup_test_env
|
||||
stop_docker
|
0
script/update-frontend.sh
Normal file → Executable file
0
script/update-frontend.sh
Normal file → Executable file
Loading…
x
Reference in New Issue
Block a user