diff --git a/API.md b/API.md index fda9b6134..ca6a11073 100644 --- a/API.md +++ b/API.md @@ -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` diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index d777461c2..c0ed69c3b 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -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() diff --git a/supervisor/api/docker.py b/supervisor/api/docker.py new file mode 100644 index 000000000..d51730ac3 --- /dev/null +++ b/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) + + 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() diff --git a/supervisor/const.py b/supervisor/const.py index 73cae5a31..97f6aea8a 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -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" diff --git a/supervisor/docker/__init__.py b/supervisor/docker/__init__.py index e29001fae..da931a903 100644 --- a/supervisor/docker/__init__.py +++ b/supervisor/docker/__init__.py @@ -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: diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 7b87742e7..bcaa19c46 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -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) diff --git a/supervisor/snapshots/__init__.py b/supervisor/snapshots/__init__.py index 92452b86d..f049f639f 100644 --- a/supervisor/snapshots/__init__.py +++ b/supervisor/snapshots/__init__.py @@ -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() diff --git a/supervisor/snapshots/snapshot.py b/supervisor/snapshots/snapshot.py index 5f46db977..e8f6b89ec 100644 --- a/supervisor/snapshots/snapshot.py +++ b/supervisor/snapshots/snapshot.py @@ -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() diff --git a/supervisor/snapshots/validate.py b/supervisor/snapshots/validate.py index f6d81b8cf..158523768 100644 --- a/supervisor/snapshots/validate.py +++ b/supervisor/snapshots/validate.py @@ -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() ), diff --git a/supervisor/validate.py b/supervisor/validate.py index 0239c894c..48925c6ba 100644 --- a/supervisor/validate.py +++ b/supervisor/validate.py @@ -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[^#]+)(?:#(?P[\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})