Support for installing add-ons from password protected registries (#2038)

This commit is contained in:
Halász Dávid 2020-10-05 12:19:25 +02:00 committed by GitHub
parent 998dd5387b
commit f6019b4e68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 218 additions and 3 deletions

28
API.md
View File

@ -1233,3 +1233,31 @@ We support:
"password": "new-password" "password": "new-password"
} }
``` ```
### Docker Registries
You can configure password-protected Docker registries that can be used as a
source when pulling docker images.
- GET `/docker/registries`
```json
{
"hostname": {
"username": "..."
}
}
```
- POST `/docker/registries`
```json
{
"{hostname}": {
"username": "...",
"password": "...",
}
}
```
- POST `/docker/registries/{hostname}/remove`

View File

@ -12,6 +12,7 @@ from .auth import APIAuth
from .cli import APICli from .cli import APICli
from .discovery import APIDiscovery from .discovery import APIDiscovery
from .dns import APICoreDNS from .dns import APICoreDNS
from .docker import APIDocker
from .hardware import APIHardware from .hardware import APIHardware
from .homeassistant import APIHomeAssistant from .homeassistant import APIHomeAssistant
from .host import APIHost from .host import APIHost
@ -71,6 +72,7 @@ class RestAPI(CoreSysAttributes):
self._register_auth() self._register_auth()
self._register_dns() self._register_dns()
self._register_audio() self._register_audio()
self._register_docker()
def _register_host(self) -> None: def _register_host(self) -> None:
"""Register hostcontrol functions.""" """Register hostcontrol functions."""
@ -408,6 +410,21 @@ class RestAPI(CoreSysAttributes):
panel_dir = Path(__file__).parent.joinpath("panel") panel_dir = Path(__file__).parent.joinpath("panel")
self.webapp.add_routes([web.static("/app", panel_dir)]) self.webapp.add_routes([web.static("/app", panel_dir)])
def _register_docker(self) -> None:
"""Register docker configuration functions."""
api_docker = APIDocker()
api_docker.coresys = self.coresys
self.webapp.add_routes(
[
web.get("/docker/registries", api_docker.registries),
web.post("/docker/registries", api_docker.create_registry),
web.post(
"/docker/registries/{hostname}/remove", api_docker.remove_registry
),
]
)
async def start(self) -> None: async def start(self) -> None:
"""Run RESTful API webserver.""" """Run RESTful API webserver."""
await self._runner.setup() await self._runner.setup()

53
supervisor/api/docker.py Normal file
View File

@ -0,0 +1,53 @@
"""Init file for Supervisor Home Assistant RESTful API."""
import logging
from typing import Any, Dict
from aiohttp import web
import voluptuous as vol
from ..const import ATTR_HOSTNAME, ATTR_PASSWORD, ATTR_REGISTRIES, ATTR_USERNAME
from ..coresys import CoreSysAttributes
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
SCHEMA_DOCKER_REGISTRY = vol.Schema(
{
vol.Coerce(str): {
vol.Required(ATTR_USERNAME): str,
vol.Required(ATTR_PASSWORD): str,
}
}
)
class APIDocker(CoreSysAttributes):
"""Handle RESTful API for Docker configuration."""
@api_process
async def registries(self, request) -> Dict[str, Any]:
"""Return the list of registries."""
data_registries = {}
for hostname, registry in self.sys_docker.config.registries.items():
data_registries[hostname] = {
ATTR_USERNAME: registry[ATTR_USERNAME],
}
return {ATTR_REGISTRIES: data_registries}
@api_process
async def create_registry(self, request: web.Request):
"""Create a new docker registry."""
body = await api_validate(SCHEMA_DOCKER_REGISTRY, request)
for hostname, registry in body.items():
self.sys_docker.config.registries[hostname] = registry
self.sys_docker.config.save_data()
@api_process
async def remove_registry(self, request: web.Request):
"""Delete a docker registry."""
hostname = request.match_info.get(ATTR_HOSTNAME)
del self.sys_docker.config.registries[hostname]
self.sys_docker.config.save_data()

View File

@ -20,6 +20,7 @@ FILE_HASSIO_ADDONS = Path(SUPERVISOR_DATA, "addons.json")
FILE_HASSIO_AUTH = Path(SUPERVISOR_DATA, "auth.json") FILE_HASSIO_AUTH = Path(SUPERVISOR_DATA, "auth.json")
FILE_HASSIO_CONFIG = Path(SUPERVISOR_DATA, "config.json") FILE_HASSIO_CONFIG = Path(SUPERVISOR_DATA, "config.json")
FILE_HASSIO_DISCOVERY = Path(SUPERVISOR_DATA, "discovery.json") FILE_HASSIO_DISCOVERY = Path(SUPERVISOR_DATA, "discovery.json")
FILE_HASSIO_DOCKER = Path(SUPERVISOR_DATA, "docker.json")
FILE_HASSIO_HOMEASSISTANT = Path(SUPERVISOR_DATA, "homeassistant.json") FILE_HASSIO_HOMEASSISTANT = Path(SUPERVISOR_DATA, "homeassistant.json")
FILE_HASSIO_INGRESS = Path(SUPERVISOR_DATA, "ingress.json") FILE_HASSIO_INGRESS = Path(SUPERVISOR_DATA, "ingress.json")
FILE_HASSIO_SERVICES = Path(SUPERVISOR_DATA, "services.json") FILE_HASSIO_SERVICES = Path(SUPERVISOR_DATA, "services.json")
@ -222,6 +223,7 @@ ATTR_PROTECTED = "protected"
ATTR_PROVIDERS = "providers" ATTR_PROVIDERS = "providers"
ATTR_RATING = "rating" ATTR_RATING = "rating"
ATTR_REFRESH_TOKEN = "refresh_token" ATTR_REFRESH_TOKEN = "refresh_token"
ATTR_REGISTRIES = "registries"
ATTR_REPOSITORIES = "repositories" ATTR_REPOSITORIES = "repositories"
ATTR_REPOSITORY = "repository" ATTR_REPOSITORY = "repository"
ATTR_SCHEMA = "schema" ATTR_SCHEMA = "schema"

View File

@ -10,8 +10,16 @@ import docker
from packaging import version as pkg_version from packaging import version as pkg_version
import requests import requests
from ..const import DNS_SUFFIX, DOCKER_IMAGE_DENYLIST, SOCKET_DOCKER from ..const import (
ATTR_REGISTRIES,
DNS_SUFFIX,
DOCKER_IMAGE_DENYLIST,
FILE_HASSIO_DOCKER,
SOCKET_DOCKER,
)
from ..exceptions import DockerAPIError, DockerError, DockerNotFound, DockerRequestError from ..exceptions import DockerAPIError, DockerError, DockerNotFound, DockerRequestError
from ..utils.json import JsonConfig
from ..validate import SCHEMA_DOCKER_CONFIG
from .network import DockerNetwork from .network import DockerNetwork
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -64,6 +72,19 @@ class DockerInfo:
return self.storage != "overlay2" or self.logging != "journald" return self.storage != "overlay2" or self.logging != "journald"
class DockerConfig(JsonConfig):
"""Home Assistant core object for Docker configuration."""
def __init__(self):
"""Initialize the JSON configuration."""
super().__init__(FILE_HASSIO_DOCKER, SCHEMA_DOCKER_CONFIG)
@property
def registries(self) -> Dict[str, Any]:
"""Return credentials for docker registries."""
return self._data.get(ATTR_REGISTRIES, {})
class DockerAPI: class DockerAPI:
"""Docker Supervisor wrapper. """Docker Supervisor wrapper.
@ -77,6 +98,7 @@ class DockerAPI:
) )
self.network: DockerNetwork = DockerNetwork(self.docker) self.network: DockerNetwork = DockerNetwork(self.docker)
self._info: DockerInfo = DockerInfo.new(self.docker.info()) self._info: DockerInfo = DockerInfo.new(self.docker.info())
self.config: DockerConfig = DockerConfig()
@property @property
def images(self) -> docker.models.images.ImageCollection: def images(self) -> docker.models.images.ImageCollection:

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
from contextlib import suppress from contextlib import suppress
import logging import logging
import re
from typing import Any, Awaitable, Dict, List, Optional from typing import Any, Awaitable, Dict, List, Optional
import docker import docker
@ -9,7 +10,7 @@ from packaging import version as pkg_version
import requests import requests
from . import CommandReturn from . import CommandReturn
from ..const import LABEL_ARCH, LABEL_VERSION from ..const import ATTR_PASSWORD, ATTR_USERNAME, LABEL_ARCH, LABEL_VERSION
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import DockerAPIError, DockerError, DockerNotFound, DockerRequestError from ..exceptions import DockerAPIError, DockerError, DockerNotFound, DockerRequestError
from ..utils import process_lock from ..utils import process_lock
@ -17,6 +18,8 @@ from .stats import DockerStats
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
IMAGE_WITH_HOST = re.compile(r"^((?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,})\/.+")
class DockerInterface(CoreSysAttributes): class DockerInterface(CoreSysAttributes):
"""Docker Supervisor interface.""" """Docker Supervisor interface."""
@ -84,6 +87,17 @@ class DockerInterface(CoreSysAttributes):
"""Pull docker image.""" """Pull docker image."""
return self.sys_run_in_executor(self._install, tag, image, latest) return self.sys_run_in_executor(self._install, tag, image, latest)
def _docker_login(self, hostname: str) -> None:
"""Try to log in to the registry if there are credentials available."""
if hostname in self.sys_docker.config.registries:
credentials = self.sys_docker.config.registries[hostname]
self.sys_docker.docker.login(
registry=hostname,
username=credentials[ATTR_USERNAME],
password=credentials[ATTR_PASSWORD],
)
def _install( def _install(
self, tag: str, image: Optional[str] = None, latest: bool = False self, tag: str, image: Optional[str] = None, latest: bool = False
) -> None: ) -> None:
@ -95,6 +109,10 @@ class DockerInterface(CoreSysAttributes):
_LOGGER.info("Pull image %s tag %s.", image, tag) _LOGGER.info("Pull image %s tag %s.", image, tag)
try: try:
# If the image name contains a path to a registry, try to log in
path = IMAGE_WITH_HOST.match(image)
if path:
self._docker_login(path.group(1))
docker_image = self.sys_docker.images.pull(f"{image}:{tag}") docker_image = self.sys_docker.images.pull(f"{image}:{tag}")
if latest: if latest:
_LOGGER.info("Tag image %s with version %s as latest", image, tag) _LOGGER.info("Tag image %s with version %s as latest", image, tag)

View File

@ -44,6 +44,7 @@ class SnapshotManager(CoreSysAttributes):
# set general data # set general data
snapshot.store_homeassistant() snapshot.store_homeassistant()
snapshot.store_repositories() snapshot.store_repositories()
snapshot.store_dockerconfig()
return snapshot return snapshot
@ -227,6 +228,10 @@ class SnapshotManager(CoreSysAttributes):
_LOGGER.info("Restore %s run folders", snapshot.slug) _LOGGER.info("Restore %s run folders", snapshot.slug)
await snapshot.restore_folders() await snapshot.restore_folders()
# Restore docker config
_LOGGER.info("Restore %s run Docker Config", snapshot.slug)
snapshot.restore_dockerconfig()
# Start homeassistant restore # Start homeassistant restore
_LOGGER.info("Restore %s run Home-Assistant", snapshot.slug) _LOGGER.info("Restore %s run Home-Assistant", snapshot.slug)
snapshot.restore_homeassistant() snapshot.restore_homeassistant()
@ -293,6 +298,10 @@ class SnapshotManager(CoreSysAttributes):
await self.lock.acquire() await self.lock.acquire()
async with snapshot: async with snapshot:
# Restore docker config
_LOGGER.info("Restore %s run Docker Config", snapshot.slug)
snapshot.restore_dockerconfig()
# Stop Home-Assistant for config restore # Stop Home-Assistant for config restore
if FOLDER_HOMEASSISTANT in folders: if FOLDER_HOMEASSISTANT in folders:
await self.sys_homeassistant.core.stop() await self.sys_homeassistant.core.stop()

View File

@ -21,18 +21,22 @@ from ..const import (
ATTR_BOOT, ATTR_BOOT,
ATTR_CRYPTO, ATTR_CRYPTO,
ATTR_DATE, ATTR_DATE,
ATTR_DOCKER,
ATTR_FOLDERS, ATTR_FOLDERS,
ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT,
ATTR_IMAGE, ATTR_IMAGE,
ATTR_NAME, ATTR_NAME,
ATTR_PASSWORD,
ATTR_PORT, ATTR_PORT,
ATTR_PROTECTED, ATTR_PROTECTED,
ATTR_REFRESH_TOKEN, ATTR_REFRESH_TOKEN,
ATTR_REGISTRIES,
ATTR_REPOSITORIES, ATTR_REPOSITORIES,
ATTR_SIZE, ATTR_SIZE,
ATTR_SLUG, ATTR_SLUG,
ATTR_SSL, ATTR_SSL,
ATTR_TYPE, ATTR_TYPE,
ATTR_USERNAME,
ATTR_VERSION, ATTR_VERSION,
ATTR_WAIT_BOOT, ATTR_WAIT_BOOT,
ATTR_WATCHDOG, ATTR_WATCHDOG,
@ -131,6 +135,16 @@ class Snapshot(CoreSysAttributes):
"""Return snapshot Home Assistant data.""" """Return snapshot Home Assistant data."""
return self._data[ATTR_HOMEASSISTANT] return self._data[ATTR_HOMEASSISTANT]
@property
def docker(self):
"""Return snapshot Docker config data."""
return self._data.get(ATTR_DOCKER, {})
@docker.setter
def docker(self, value):
"""Set the Docker config data."""
self._data[ATTR_DOCKER] = value
@property @property
def size(self): def size(self):
"""Return snapshot size.""" """Return snapshot size."""
@ -481,3 +495,29 @@ class Snapshot(CoreSysAttributes):
Return a coroutine. Return a coroutine.
""" """
return self.sys_store.update_repositories(self.repositories) return self.sys_store.update_repositories(self.repositories)
def store_dockerconfig(self):
"""Store the configuration for Docker."""
self.docker = {
ATTR_REGISTRIES: {
registry: {
ATTR_USERNAME: credentials[ATTR_USERNAME],
ATTR_PASSWORD: self._encrypt_data(credentials[ATTR_PASSWORD]),
}
for registry, credentials in self.sys_docker.config.registries.items()
}
}
def restore_dockerconfig(self):
"""Restore the configuration for Docker."""
if ATTR_REGISTRIES in self.docker:
self.sys_docker.config.registries.update(
{
registry: {
ATTR_USERNAME: credentials[ATTR_USERNAME],
ATTR_PASSWORD: self._decrypt_data(credentials[ATTR_PASSWORD]),
}
for registry, credentials in self.docker[ATTR_REGISTRIES].items()
}
)
self.sys_docker.config.save_data()

View File

@ -8,6 +8,7 @@ from ..const import (
ATTR_BOOT, ATTR_BOOT,
ATTR_CRYPTO, ATTR_CRYPTO,
ATTR_DATE, ATTR_DATE,
ATTR_DOCKER,
ATTR_FOLDERS, ATTR_FOLDERS,
ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT,
ATTR_IMAGE, ATTR_IMAGE,
@ -32,7 +33,13 @@ from ..const import (
SNAPSHOT_FULL, SNAPSHOT_FULL,
SNAPSHOT_PARTIAL, SNAPSHOT_PARTIAL,
) )
from ..validate import docker_image, network_port, repositories, version_tag from ..validate import (
SCHEMA_DOCKER_CONFIG,
docker_image,
network_port,
repositories,
version_tag,
)
ALL_FOLDERS = [ ALL_FOLDERS = [
FOLDER_HOMEASSISTANT, FOLDER_HOMEASSISTANT,
@ -84,6 +91,7 @@ SCHEMA_SNAPSHOT = vol.Schema(
}, },
extra=vol.REMOVE_EXTRA, extra=vol.REMOVE_EXTRA,
), ),
vol.Optional(ATTR_DOCKER, default=dict): SCHEMA_DOCKER_CONFIG,
vol.Optional(ATTR_FOLDERS, default=list): vol.All( vol.Optional(ATTR_FOLDERS, default=list): vol.All(
[vol.In(ALL_FOLDERS)], vol.Unique() [vol.In(ALL_FOLDERS)], vol.Unique()
), ),

View File

@ -27,13 +27,16 @@ from .const import (
ATTR_LOGGING, ATTR_LOGGING,
ATTR_MULTICAST, ATTR_MULTICAST,
ATTR_OBSERVER, ATTR_OBSERVER,
ATTR_PASSWORD,
ATTR_PORT, ATTR_PORT,
ATTR_PORTS, ATTR_PORTS,
ATTR_REFRESH_TOKEN, ATTR_REFRESH_TOKEN,
ATTR_REGISTRIES,
ATTR_SESSION, ATTR_SESSION,
ATTR_SSL, ATTR_SSL,
ATTR_SUPERVISOR, ATTR_SUPERVISOR,
ATTR_TIMEZONE, ATTR_TIMEZONE,
ATTR_USERNAME,
ATTR_UUID, ATTR_UUID,
ATTR_VERSION, ATTR_VERSION,
ATTR_WAIT_BOOT, ATTR_WAIT_BOOT,
@ -45,6 +48,7 @@ from .const import (
from .utils.validate import validate_timezone from .utils.validate import validate_timezone
RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$") RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
RE_REGISTRY = re.compile(r"^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$")
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
# pylint: disable=invalid-name # pylint: disable=invalid-name
@ -181,6 +185,20 @@ SCHEMA_SUPERVISOR_CONFIG = vol.Schema(
) )
SCHEMA_DOCKER_CONFIG = vol.Schema(
{
vol.Optional(ATTR_REGISTRIES, default=dict): vol.Schema(
{
vol.All(str, vol.Match(RE_REGISTRY)): {
vol.Required(ATTR_USERNAME): str,
vol.Required(ATTR_PASSWORD): str,
}
}
)
}
)
SCHEMA_AUTH_CONFIG = vol.Schema({sha256: sha256}) SCHEMA_AUTH_CONFIG = vol.Schema({sha256: sha256})