mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-12 18:49:20 +00:00
Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7ae8dfe587 | ||
![]() |
c931a4c3e5 | ||
![]() |
c58fa816d9 | ||
![]() |
557f029aa0 | ||
![]() |
e8e3cc2f67 | ||
![]() |
b0e4983488 |
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -110,14 +110,14 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.publish == 'true'
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
20
.github/workflows/ci.yaml
vendored
20
.github/workflows/ci.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -271,7 +271,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -303,7 +303,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -347,7 +347,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -405,7 +405,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v2.4.0
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@v2.3.2
|
||||
uses: actions/setup-python@v3
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
@@ -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
|
||||
|
@@ -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."""
|
||||
|
@@ -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"
|
||||
|
@@ -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.
|
||||
|
@@ -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 = {
|
||||
CpuArch.ARMV7: "linux/arm/v7",
|
||||
CpuArch.ARMHF: "linux/arm/v6",
|
||||
CpuArch.AARCH64: "linux/arm64",
|
||||
CpuArch.I386: "linux/386",
|
||||
CpuArch.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.
|
||||
|
@@ -21,6 +21,7 @@ _CAS_CMD: str = (
|
||||
_CACHE: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
_ATTR_ERROR: Final = "error"
|
||||
_ATTR_STATUS: Final = "status"
|
||||
|
||||
|
||||
@@ -88,6 +89,9 @@ async def cas_validate(
|
||||
f"Can't parse CodeNotary output: {data!s} - {err!s}", _LOGGER.error
|
||||
) from err
|
||||
|
||||
if _ATTR_ERROR in data_json:
|
||||
raise CodeNotaryBackendError(data_json[_ATTR_ERROR], _LOGGER.warning)
|
||||
|
||||
if data_json[_ATTR_STATUS] == 0:
|
||||
_CACHE.add((checksum, signer))
|
||||
else:
|
||||
|
52
tests/docker/test_interface.py
Normal file
52
tests/docker/test_interface.py
Normal 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")
|
@@ -1,11 +1,51 @@
|
||||
"""Test CodeNotary."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from supervisor.exceptions import CodeNotaryUntrusted
|
||||
from supervisor.exceptions import (
|
||||
CodeNotaryBackendError,
|
||||
CodeNotaryError,
|
||||
CodeNotaryUntrusted,
|
||||
)
|
||||
from supervisor.utils.codenotary import calc_checksum, cas_validate
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubprocessResponse:
|
||||
"""Class for specifying subprocess exec response."""
|
||||
|
||||
returncode: int = 0
|
||||
data: str = ""
|
||||
error: str | None = None
|
||||
exception: Exception | None = None
|
||||
|
||||
|
||||
@pytest.fixture(name="subprocess_exec")
|
||||
def fixture_subprocess_exec(request):
|
||||
"""Mock subprocess exec with specific return."""
|
||||
response = request.param
|
||||
if response.exception:
|
||||
communicate_return = AsyncMock(side_effect=response.exception)
|
||||
else:
|
||||
err_return = None
|
||||
if response.error:
|
||||
err_return = Mock(decode=Mock(return_value=response.error))
|
||||
|
||||
communicate_return = AsyncMock(return_value=(response.data, err_return))
|
||||
|
||||
exec_return = Mock(returncode=response.returncode, communicate=communicate_return)
|
||||
|
||||
with patch(
|
||||
"supervisor.utils.codenotary.asyncio.create_subprocess_exec",
|
||||
return_value=exec_return,
|
||||
) as subprocess_exec:
|
||||
yield subprocess_exec
|
||||
|
||||
|
||||
def test_checksum_calc():
|
||||
"""Calc Checkusm as test."""
|
||||
assert calc_checksum("test") == calc_checksum(b"test")
|
||||
@@ -30,3 +70,46 @@ async def test_invalid_checksum():
|
||||
"notary@home-assistant.io",
|
||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subprocess_exec",
|
||||
[
|
||||
SubprocessResponse(returncode=1, error="test"),
|
||||
SubprocessResponse(returncode=0, data='{"error":"asn1: structure error"}'),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_cas_backend_error(subprocess_exec):
|
||||
"""Test backend error executing cas command."""
|
||||
with pytest.raises(CodeNotaryBackendError):
|
||||
await cas_validate(
|
||||
"notary@home-assistant.io",
|
||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subprocess_exec",
|
||||
[SubprocessResponse(returncode=0, data='{"status":1}')],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_cas_notarized_untrusted(subprocess_exec):
|
||||
"""Test cas found notarized but untrusted content."""
|
||||
with pytest.raises(CodeNotaryUntrusted):
|
||||
await cas_validate(
|
||||
"notary@home-assistant.io",
|
||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subprocess_exec", [SubprocessResponse(exception=OSError())], indirect=True
|
||||
)
|
||||
async def test_cas_exec_os_error(subprocess_exec):
|
||||
"""Test os error attempting to execute cas command."""
|
||||
with pytest.raises(CodeNotaryError):
|
||||
await cas_validate(
|
||||
"notary@home-assistant.io",
|
||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
)
|
||||
|
Reference in New Issue
Block a user