Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 8 commits into from Oct 5, 2020
28 changes: 28 additions & 0 deletions API.md
Expand Up @@ -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`
17 changes: 17 additions & 0 deletions supervisor/api/__init__.py
Expand Up @@ -12,6 +12,7 @@
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
Expand Down Expand Up @@ -71,6 +72,7 @@ async def load(self) -> None:
self._register_auth()
self._register_dns()
self._register_audio()
self._register_docker()

def _register_host(self) -> None:
"""Register hostcontrol functions."""
Expand Down Expand Up @@ -408,6 +410,21 @@ def _register_panel(self) -> None:
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()
Expand Down
53 changes: 53 additions & 0 deletions supervisor/api/docker.py
@@ -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)
skateman marked this conversation as resolved.
Show resolved Hide resolved

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()
2 changes: 2 additions & 0 deletions supervisor/const.py
Expand Up @@ -20,6 +20,7 @@
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")
Expand Down Expand Up @@ -222,6 +223,7 @@
ATTR_PROVIDERS = "providers"
ATTR_RATING = "rating"
ATTR_REFRESH_TOKEN = "refresh_token"
ATTR_REGISTRIES = "registries"
ATTR_REPOSITORIES = "repositories"
ATTR_REPOSITORY = "repository"
ATTR_SCHEMA = "schema"
Expand Down
24 changes: 23 additions & 1 deletion supervisor/docker/__init__.py
Expand Up @@ -10,8 +10,16 @@
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__)
Expand Down Expand Up @@ -64,6 +72,19 @@ def check_requirements(self) -> None:
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.

Expand All @@ -77,6 +98,7 @@ def __init__(self):
)
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:
Expand Down
20 changes: 19 additions & 1 deletion supervisor/docker/interface.py
Expand Up @@ -2,21 +2,24 @@
import asyncio
from contextlib import suppress
import logging
import re
from typing import Any, Awaitable, Dict, List, Optional

import docker
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
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."""
Expand Down Expand Up @@ -84,6 +87,17 @@ def install(self, tag: str, image: Optional[str] = None, latest: bool = False):
"""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:
Expand All @@ -95,6 +109,10 @@ def _install(

_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)
Expand Down
9 changes: 9 additions & 0 deletions supervisor/snapshots/__init__.py
Expand Up @@ -44,6 +44,7 @@ def _create_snapshot(self, name, sys_type, password):
# set general data
snapshot.store_homeassistant()
snapshot.store_repositories()
snapshot.store_dockerconfig()

return snapshot

Expand Down Expand Up @@ -227,6 +228,10 @@ async def do_restore_full(self, snapshot, password=None):
_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()
Expand Down Expand Up @@ -293,6 +298,10 @@ async def do_restore_partial(
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()
Expand Down
40 changes: 40 additions & 0 deletions supervisor/snapshots/snapshot.py
Expand Up @@ -21,18 +21,22 @@
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,
Expand Down Expand Up @@ -131,6 +135,16 @@ def homeassistant(self):
"""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."""
Expand Down Expand Up @@ -481,3 +495,29 @@ def restore_repositories(self):
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],
ludeeus marked this conversation as resolved.
Show resolved Hide resolved
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()
10 changes: 9 additions & 1 deletion supervisor/snapshots/validate.py
Expand Up @@ -8,6 +8,7 @@
ATTR_BOOT,
ATTR_CRYPTO,
ATTR_DATE,
ATTR_DOCKER,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_IMAGE,
Expand All @@ -32,7 +33,13 @@
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,
Expand Down Expand Up @@ -84,6 +91,7 @@ def unique_addons(addons_list):
},
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()
),
Expand Down