mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-10 02:36:29 +00:00
Support for installing add-ons from password protected registries (#2038)
This commit is contained in:
parent
998dd5387b
commit
f6019b4e68
28
API.md
28
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`
|
||||
|
@ -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
53
supervisor/api/docker.py
Normal 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()
|
@ -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"
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
),
|
||||
|
@ -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})
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user