From b0e49834880a5694dd281e0d247be0ff64c0961f Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 1 Mar 2022 03:38:58 -0500 Subject: [PATCH] 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 --- supervisor/addons/__init__.py | 2 +- supervisor/addons/model.py | 13 +++++++-- supervisor/const.py | 10 +++++++ supervisor/docker/addon.py | 9 ++++-- supervisor/docker/interface.py | 51 ++++++++++++++++++++++++--------- tests/docker/test_interface.py | 52 ++++++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 tests/docker/test_interface.py diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index 9087ab5db..6aa396b76 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -178,7 +178,7 @@ class AddonManager(CoreSysAttributes): await addon.install_apparmor() try: - await addon.instance.install(store.version, store.image) + await addon.instance.install(store.version, store.image, arch=addon.arch) except DockerError as err: self.data.uninstall(addon) raise AddonsError() from err diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index 19d87fd21..049057aaa 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -503,6 +503,14 @@ class AddonModel(CoreSysAttributes, ABC): """Return list of supported 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 def image(self) -> Optional[str]: """Generate image name from data.""" @@ -618,11 +626,10 @@ class AddonModel(CoreSysAttributes, ABC): """Generate image name from data.""" # Repository with Dockerhub images if ATTR_IMAGE in config: - arch = self.sys_arch.match(config[ATTR_ARCH]) - return config[ATTR_IMAGE].format(arch=arch) + return config[ATTR_IMAGE].format(arch=self.arch) # 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]: """Install this add-on.""" diff --git a/supervisor/const.py b/supervisor/const.py index b890d0177..3c35e2b87 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -454,3 +454,13 @@ class BusEvent(str, Enum): HARDWARE_NEW_DEVICE = "hardware_new_device" HARDWARE_REMOVE_DEVICE = "hardware_remove_device" + + +class CpuArch(str, Enum): + """Supported CPU architectures.""" + + ARMV7 = "armv7" + ARMHF = "armhf" + AARCH64 = "aarch64" + I386 = "i386" + AMD64 = "amd64" diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 78eee8e03..274cdbaea 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -31,6 +31,7 @@ from ..const import ( SYSTEMD_JOURNAL_PERSISTENT, SYSTEMD_JOURNAL_VOLATILE, BusEvent, + CpuArch, ) from ..coresys import CoreSys from ..exceptions import ( @@ -515,7 +516,11 @@ class DockerAddon(DockerInterface): ) 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: """Pull Docker image or build it. @@ -524,7 +529,7 @@ class DockerAddon(DockerInterface): if self.addon.need_build: self._build(version) else: - super()._install(version, image, latest) + super()._install(version, image, latest, arch) def _build(self, version: AwesomeVersion) -> None: """Build a Docker container. diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 0ebecc5de..7a340298c 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -1,9 +1,11 @@ """Interface class for Supervisor Docker object.""" +from __future__ import annotations + import asyncio from contextlib import suppress import logging import re -from typing import Any, Awaitable, Optional +from typing import Any, Awaitable from awesomeversion import AwesomeVersion from awesomeversion.strategy import AwesomeVersionStrategy @@ -17,6 +19,7 @@ from ..const import ( ATTR_USERNAME, LABEL_ARCH, LABEL_VERSION, + CpuArch, ) from ..coresys import CoreSys, CoreSysAttributes 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,})\/.+") 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): """Docker Supervisor interface.""" @@ -44,7 +55,7 @@ class DockerInterface(CoreSysAttributes): def __init__(self, coresys: CoreSys): """Initialize Docker base wrapper.""" self.coresys: CoreSys = coresys - self._meta: Optional[dict[str, Any]] = None + self._meta: dict[str, Any] | None = None self.lock: asyncio.Lock = asyncio.Lock() @property @@ -53,7 +64,7 @@ class DockerInterface(CoreSysAttributes): return 10 @property - def name(self) -> Optional[str]: + def name(self) -> str | None: """Return name of Docker container.""" return None @@ -77,7 +88,7 @@ class DockerInterface(CoreSysAttributes): return self.meta_config.get("Labels") or {} @property - def image(self) -> Optional[str]: + def image(self) -> str | None: """Return name of Docker image.""" try: return self.meta_config["Image"].partition(":")[0] @@ -85,14 +96,14 @@ class DockerInterface(CoreSysAttributes): return None @property - def version(self) -> Optional[AwesomeVersion]: + def version(self) -> AwesomeVersion | None: """Return version of Docker image.""" if LABEL_VERSION not in self.meta_labels: return None return AwesomeVersion(self.meta_labels[LABEL_VERSION]) @property - def arch(self) -> Optional[str]: + def arch(self) -> str | None: """Return arch of Docker image.""" return self.meta_labels.get(LABEL_ARCH) @@ -150,19 +161,28 @@ class DockerInterface(CoreSysAttributes): @process_lock 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.""" - 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( - 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: """Pull Docker image. Need run inside executor. """ image = image or self.image + arch = arch or self.sys_arch.supervisor _LOGGER.info("Downloading docker image %s with tag %s.", image, version) try: @@ -171,7 +191,10 @@ class DockerInterface(CoreSysAttributes): self._docker_login(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 try: @@ -378,13 +401,13 @@ class DockerInterface(CoreSysAttributes): @process_lock def update( - self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False + self, version: AwesomeVersion, image: str | None = None, latest: bool = False ) -> Awaitable[None]: """Update a Docker image.""" return self.sys_run_in_executor(self._update, version, image, latest) def _update( - self, version: AwesomeVersion, image: Optional[str] = None, latest: bool = False + self, version: AwesomeVersion, image: str | None = None, latest: bool = False ) -> None: """Update a docker image. @@ -428,11 +451,11 @@ class DockerInterface(CoreSysAttributes): return b"" @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.""" 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. Need run inside executor. diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py new file mode 100644 index 000000000..e6781bc59 --- /dev/null +++ b/tests/docker/test_interface.py @@ -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")