Passing platform arg on image pull (#3465)

* Passing platform arg on image pull

* Passing in addon arch to image pull

* Move sys_arch above sys_plugins in setup

* Default to supervisor arch

* Cleanup from feedback
This commit is contained in:
Mike Degatano 2022-03-01 03:38:58 -05:00 committed by GitHub
parent 205f3a74dd
commit b0e4983488
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 117 additions and 20 deletions

View File

@ -178,7 +178,7 @@ class AddonManager(CoreSysAttributes):
await addon.install_apparmor() await addon.install_apparmor()
try: try:
await addon.instance.install(store.version, store.image) await addon.instance.install(store.version, store.image, arch=addon.arch)
except DockerError as err: except DockerError as err:
self.data.uninstall(addon) self.data.uninstall(addon)
raise AddonsError() from err raise AddonsError() from err

View File

@ -503,6 +503,14 @@ class AddonModel(CoreSysAttributes, ABC):
"""Return list of supported machine.""" """Return list of supported machine."""
return self.data.get(ATTR_MACHINE, []) return self.data.get(ATTR_MACHINE, [])
@property
def arch(self) -> str:
"""Return architecture to use for the addon's image."""
if ATTR_IMAGE in self.data:
return self.sys_arch.match(self.data[ATTR_ARCH])
return self.sys_arch.default
@property @property
def image(self) -> Optional[str]: def image(self) -> Optional[str]:
"""Generate image name from data.""" """Generate image name from data."""
@ -618,11 +626,10 @@ class AddonModel(CoreSysAttributes, ABC):
"""Generate image name from data.""" """Generate image name from data."""
# Repository with Dockerhub images # Repository with Dockerhub images
if ATTR_IMAGE in config: if ATTR_IMAGE in config:
arch = self.sys_arch.match(config[ATTR_ARCH]) return config[ATTR_IMAGE].format(arch=self.arch)
return config[ATTR_IMAGE].format(arch=arch)
# local build # local build
return f"{config[ATTR_REPOSITORY]}/{self.sys_arch.default}-addon-{config[ATTR_SLUG]}" return f"{config[ATTR_REPOSITORY]}/{self.arch}-addon-{config[ATTR_SLUG]}"
def install(self) -> Awaitable[None]: def install(self) -> Awaitable[None]:
"""Install this add-on.""" """Install this add-on."""

View File

@ -454,3 +454,13 @@ class BusEvent(str, Enum):
HARDWARE_NEW_DEVICE = "hardware_new_device" HARDWARE_NEW_DEVICE = "hardware_new_device"
HARDWARE_REMOVE_DEVICE = "hardware_remove_device" HARDWARE_REMOVE_DEVICE = "hardware_remove_device"
class CpuArch(str, Enum):
"""Supported CPU architectures."""
ARMV7 = "armv7"
ARMHF = "armhf"
AARCH64 = "aarch64"
I386 = "i386"
AMD64 = "amd64"

View File

@ -31,6 +31,7 @@ from ..const import (
SYSTEMD_JOURNAL_PERSISTENT, SYSTEMD_JOURNAL_PERSISTENT,
SYSTEMD_JOURNAL_VOLATILE, SYSTEMD_JOURNAL_VOLATILE,
BusEvent, BusEvent,
CpuArch,
) )
from ..coresys import CoreSys from ..coresys import CoreSys
from ..exceptions import ( from ..exceptions import (
@ -515,7 +516,11 @@ class DockerAddon(DockerInterface):
) )
def _install( def _install(
self, version: AwesomeVersion, image: str | None = None, latest: bool = False self,
version: AwesomeVersion,
image: str | None = None,
latest: bool = False,
arch: CpuArch | None = None,
) -> None: ) -> None:
"""Pull Docker image or build it. """Pull Docker image or build it.
@ -524,7 +529,7 @@ class DockerAddon(DockerInterface):
if self.addon.need_build: if self.addon.need_build:
self._build(version) self._build(version)
else: else:
super()._install(version, image, latest) super()._install(version, image, latest, arch)
def _build(self, version: AwesomeVersion) -> None: def _build(self, version: AwesomeVersion) -> None:
"""Build a Docker container. """Build a Docker container.

View File

@ -1,9 +1,11 @@
"""Interface class for Supervisor Docker object.""" """Interface class for Supervisor Docker object."""
from __future__ import annotations
import asyncio import asyncio
from contextlib import suppress from contextlib import suppress
import logging import logging
import re import re
from typing import Any, Awaitable, Optional from typing import Any, Awaitable
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from awesomeversion.strategy import AwesomeVersionStrategy from awesomeversion.strategy import AwesomeVersionStrategy
@ -17,6 +19,7 @@ from ..const import (
ATTR_USERNAME, ATTR_USERNAME,
LABEL_ARCH, LABEL_ARCH,
LABEL_VERSION, LABEL_VERSION,
CpuArch,
) )
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ( from ..exceptions import (
@ -37,6 +40,14 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
IMAGE_WITH_HOST = re.compile(r"^((?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,})\/.+") IMAGE_WITH_HOST = re.compile(r"^((?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,})\/.+")
DOCKER_HUB = "hub.docker.com" DOCKER_HUB = "hub.docker.com"
MAP_ARCH = {
"armv7": "linux/arm/v7",
"armhf": "linux/arm/v6",
"aarch64": "linux/arm64",
"i386": "linux/386",
"amd64": "linux/amd64",
}
class DockerInterface(CoreSysAttributes): class DockerInterface(CoreSysAttributes):
"""Docker Supervisor interface.""" """Docker Supervisor interface."""
@ -44,7 +55,7 @@ class DockerInterface(CoreSysAttributes):
def __init__(self, coresys: CoreSys): def __init__(self, coresys: CoreSys):
"""Initialize Docker base wrapper.""" """Initialize Docker base wrapper."""
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
self._meta: Optional[dict[str, Any]] = None self._meta: dict[str, Any] | None = None
self.lock: asyncio.Lock = asyncio.Lock() self.lock: asyncio.Lock = asyncio.Lock()
@property @property
@ -53,7 +64,7 @@ class DockerInterface(CoreSysAttributes):
return 10 return 10
@property @property
def name(self) -> Optional[str]: def name(self) -> str | None:
"""Return name of Docker container.""" """Return name of Docker container."""
return None return None
@ -77,7 +88,7 @@ class DockerInterface(CoreSysAttributes):
return self.meta_config.get("Labels") or {} return self.meta_config.get("Labels") or {}
@property @property
def image(self) -> Optional[str]: def image(self) -> str | None:
"""Return name of Docker image.""" """Return name of Docker image."""
try: try:
return self.meta_config["Image"].partition(":")[0] return self.meta_config["Image"].partition(":")[0]
@ -85,14 +96,14 @@ class DockerInterface(CoreSysAttributes):
return None return None
@property @property
def version(self) -> Optional[AwesomeVersion]: def version(self) -> AwesomeVersion | None:
"""Return version of Docker image.""" """Return version of Docker image."""
if LABEL_VERSION not in self.meta_labels: if LABEL_VERSION not in self.meta_labels:
return None return None
return AwesomeVersion(self.meta_labels[LABEL_VERSION]) return AwesomeVersion(self.meta_labels[LABEL_VERSION])
@property @property
def arch(self) -> Optional[str]: def arch(self) -> str | None:
"""Return arch of Docker image.""" """Return arch of Docker image."""
return self.meta_labels.get(LABEL_ARCH) return self.meta_labels.get(LABEL_ARCH)
@ -150,19 +161,28 @@ class DockerInterface(CoreSysAttributes):
@process_lock @process_lock
def install( def install(
self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False self,
version: AwesomeVersion,
image: str | None = None,
latest: bool = False,
arch: CpuArch | None = None,
): ):
"""Pull docker image.""" """Pull docker image."""
return self.sys_run_in_executor(self._install, version, image, latest) return self.sys_run_in_executor(self._install, version, image, latest, arch)
def _install( def _install(
self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False self,
version: AwesomeVersion,
image: str | None = None,
latest: bool = False,
arch: CpuArch | None = None,
) -> None: ) -> None:
"""Pull Docker image. """Pull Docker image.
Need run inside executor. Need run inside executor.
""" """
image = image or self.image image = image or self.image
arch = arch or self.sys_arch.supervisor
_LOGGER.info("Downloading docker image %s with tag %s.", image, version) _LOGGER.info("Downloading docker image %s with tag %s.", image, version)
try: try:
@ -171,7 +191,10 @@ class DockerInterface(CoreSysAttributes):
self._docker_login(image) self._docker_login(image)
# Pull new image # Pull new image
docker_image = self.sys_docker.images.pull(f"{image}:{version!s}") docker_image = self.sys_docker.images.pull(
f"{image}:{version!s}",
platform=MAP_ARCH[arch],
)
# Validate content # Validate content
try: try:
@ -378,13 +401,13 @@ class DockerInterface(CoreSysAttributes):
@process_lock @process_lock
def update( def update(
self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False self, version: AwesomeVersion, image: str | None = None, latest: bool = False
) -> Awaitable[None]: ) -> Awaitable[None]:
"""Update a Docker image.""" """Update a Docker image."""
return self.sys_run_in_executor(self._update, version, image, latest) return self.sys_run_in_executor(self._update, version, image, latest)
def _update( def _update(
self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False self, version: AwesomeVersion, image: str | None = None, latest: bool = False
) -> None: ) -> None:
"""Update a docker image. """Update a docker image.
@ -428,11 +451,11 @@ class DockerInterface(CoreSysAttributes):
return b"" return b""
@process_lock @process_lock
def cleanup(self, old_image: Optional[str] = None) -> Awaitable[None]: def cleanup(self, old_image: str | None = None) -> Awaitable[None]:
"""Check if old version exists and cleanup.""" """Check if old version exists and cleanup."""
return self.sys_run_in_executor(self._cleanup, old_image) return self.sys_run_in_executor(self._cleanup, old_image)
def _cleanup(self, old_image: Optional[str] = None) -> None: def _cleanup(self, old_image: str | None = None) -> None:
"""Check if old version exists and cleanup. """Check if old version exists and cleanup.
Need run inside executor. Need run inside executor.

View File

@ -0,0 +1,52 @@
"""Test Docker interface."""
from unittest.mock import Mock, PropertyMock, call, patch
from awesomeversion import AwesomeVersion
import pytest
from supervisor.const import CpuArch
from supervisor.coresys import CoreSys
from supervisor.docker.interface import DockerInterface
@pytest.fixture(autouse=True)
def mock_verify_content(coresys: CoreSys):
"""Mock verify_content utility during tests."""
with patch.object(
coresys.security, "verify_content", return_value=None
) as verify_content:
yield verify_content
@pytest.mark.parametrize(
"cpu_arch, platform",
[
(CpuArch.ARMV7, "linux/arm/v7"),
(CpuArch.ARMHF, "linux/arm/v6"),
(CpuArch.AARCH64, "linux/arm64"),
(CpuArch.I386, "linux/386"),
(CpuArch.AMD64, "linux/amd64"),
],
)
async def test_docker_image_platform(coresys: CoreSys, cpu_arch: str, platform: str):
"""Test platform set correctly from arch."""
with patch.object(
coresys.docker.images, "pull", return_value=Mock(id="test:1.2.3")
) as pull:
instance = DockerInterface(coresys)
await instance.install(AwesomeVersion("1.2.3"), "test", arch=cpu_arch)
assert pull.call_count == 1
assert pull.call_args == call("test:1.2.3", platform=platform)
async def test_docker_image_default_platform(coresys: CoreSys):
"""Test platform set using supervisor arch when omitted."""
with patch.object(
type(coresys.supervisor), "arch", PropertyMock(return_value="i386")
), patch.object(
coresys.docker.images, "pull", return_value=Mock(id="test:1.2.3")
) as pull:
instance = DockerInterface(coresys)
await instance.install(AwesomeVersion("1.2.3"), "test")
assert pull.call_count == 1
assert pull.call_args == call("test:1.2.3", platform="linux/386")