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"
}
```
### 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 .discovery import APIDiscovery
from .dns import APICoreDNS
from .docker import APIDocker
from .hardware import APIHardware
from .homeassistant import APIHomeAssistant
from .host import APIHost
@ -71,6 +72,7 @@ class RestAPI(CoreSysAttributes):
self._register_auth()
self._register_dns()
self._register_audio()
self._register_docker()
def _register_host(self) -> None:
"""Register hostcontrol functions."""
@ -408,6 +410,21 @@ class RestAPI(CoreSysAttributes):
panel_dir = Path(__file__).parent.joinpath("panel")
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:
"""Run RESTful API webserver."""
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_CONFIG = Path(SUPERVISOR_DATA, "config.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_INGRESS = Path(SUPERVISOR_DATA, "ingress.json")
FILE_HASSIO_SERVICES = Path(SUPERVISOR_DATA, "services.json")
@ -222,6 +223,7 @@ ATTR_PROTECTED = "protected"
ATTR_PROVIDERS = "providers"
ATTR_RATING = "rating"
ATTR_REFRESH_TOKEN = "refresh_token"
ATTR_REGISTRIES = "registries"
ATTR_REPOSITORIES = "repositories"
ATTR_REPOSITORY = "repository"
ATTR_SCHEMA = "schema"

View File

@ -10,8 +10,16 @@ import docker
from packaging import version as pkg_version
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 ..utils.json import JsonConfig
from ..validate import SCHEMA_DOCKER_CONFIG
from .network import DockerNetwork
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -64,6 +72,19 @@ class DockerInfo:
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:
"""Docker Supervisor wrapper.
@ -77,6 +98,7 @@ class DockerAPI:
)
self.network: DockerNetwork = DockerNetwork(self.docker)
self._info: DockerInfo = DockerInfo.new(self.docker.info())
self.config: DockerConfig = DockerConfig()
@property
def images(self) -> docker.models.images.ImageCollection:

View File

@ -2,6 +2,7 @@
import asyncio
from contextlib import suppress
import logging
import re
from typing import Any, Awaitable, Dict, List, Optional
import docker
@ -9,7 +10,7 @@ from packaging import version as pkg_version
import requests
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 ..exceptions import DockerAPIError, DockerError, DockerNotFound, DockerRequestError
from ..utils import process_lock
@ -17,6 +18,8 @@ from .stats import DockerStats
_LOGGER: logging.Logger = logging.getLogger(__name__)
IMAGE_WITH_HOST = re.compile(r"^((?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,})\/.+")
class DockerInterface(CoreSysAttributes):
"""Docker Supervisor interface."""
@ -84,6 +87,17 @@ class DockerInterface(CoreSysAttributes):
"""Pull docker image."""
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(
self, tag: str, image: Optional[str] = None, latest: bool = False
) -> None:
@ -95,6 +109,10 @@ class DockerInterface(CoreSysAttributes):
_LOGGER.info("Pull image %s tag %s.", image, tag)
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}")
if latest:
_LOGGER.info("Tag image %s with version %s as latest", image, tag)

View File

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

View File

@ -21,18 +21,22 @@ from ..const import (
ATTR_BOOT,
ATTR_CRYPTO,
ATTR_DATE,
ATTR_DOCKER,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_IMAGE,
ATTR_NAME,
ATTR_PASSWORD,
ATTR_PORT,
ATTR_PROTECTED,
ATTR_REFRESH_TOKEN,
ATTR_REGISTRIES,
ATTR_REPOSITORIES,
ATTR_SIZE,
ATTR_SLUG,
ATTR_SSL,
ATTR_TYPE,
ATTR_USERNAME,
ATTR_VERSION,
ATTR_WAIT_BOOT,
ATTR_WATCHDOG,
@ -131,6 +135,16 @@ class Snapshot(CoreSysAttributes):
"""Return snapshot Home Assistant data."""
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
def size(self):
"""Return snapshot size."""
@ -481,3 +495,29 @@ class Snapshot(CoreSysAttributes):
Return a coroutine.
"""
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_CRYPTO,
ATTR_DATE,
ATTR_DOCKER,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_IMAGE,
@ -32,7 +33,13 @@ from ..const import (
SNAPSHOT_FULL,
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 = [
FOLDER_HOMEASSISTANT,
@ -84,6 +91,7 @@ SCHEMA_SNAPSHOT = vol.Schema(
},
extra=vol.REMOVE_EXTRA,
),
vol.Optional(ATTR_DOCKER, default=dict): SCHEMA_DOCKER_CONFIG,
vol.Optional(ATTR_FOLDERS, default=list): vol.All(
[vol.In(ALL_FOLDERS)], vol.Unique()
),

View File

@ -27,13 +27,16 @@ from .const import (
ATTR_LOGGING,
ATTR_MULTICAST,
ATTR_OBSERVER,
ATTR_PASSWORD,
ATTR_PORT,
ATTR_PORTS,
ATTR_REFRESH_TOKEN,
ATTR_REGISTRIES,
ATTR_SESSION,
ATTR_SSL,
ATTR_SUPERVISOR,
ATTR_TIMEZONE,
ATTR_USERNAME,
ATTR_UUID,
ATTR_VERSION,
ATTR_WAIT_BOOT,
@ -45,6 +48,7 @@ from .const import (
from .utils.validate import validate_timezone
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=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})