mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-02-15 07:27:13 +00:00
Migrate images from dockerpy to aiodocker (#6252)
* Migrate images from dockerpy to aiodocker * Add missing coverage and fix bug in repair * Bind libraries to different files and refactor images.pull * Use the same socket again Try using the same socket again. * Fix pytest --------- Co-authored-by: Stefan Agner <stefan@agner.ch>
This commit is contained in:
@@ -3,22 +3,25 @@
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import errno
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from unittest.mock import MagicMock, PropertyMock, call, patch
|
||||
|
||||
import aiodocker
|
||||
from awesomeversion import AwesomeVersion
|
||||
from docker.errors import DockerException, ImageNotFound, NotFound
|
||||
from docker.errors import APIError, DockerException, NotFound
|
||||
import pytest
|
||||
from securetar import SecureTarFile
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.addons.const import AddonBackupMode
|
||||
from supervisor.addons.model import AddonModel
|
||||
from supervisor.config import CoreConfig
|
||||
from supervisor.const import AddonBoot, AddonState, BusEvent
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.addon import DockerAddon
|
||||
from supervisor.docker.const import ContainerState
|
||||
from supervisor.docker.manager import CommandReturn
|
||||
from supervisor.docker.manager import CommandReturn, DockerAPI
|
||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||
from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError
|
||||
from supervisor.hardware.helper import HwHelper
|
||||
@@ -861,16 +864,14 @@ async def test_addon_loads_wrong_image(
|
||||
|
||||
container.remove.assert_called_with(force=True, v=True)
|
||||
# one for removing the addon, one for removing the addon builder
|
||||
assert coresys.docker.images.remove.call_count == 2
|
||||
assert coresys.docker.images.delete.call_count == 2
|
||||
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": "local/aarch64-addon-ssh:latest",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": "local/aarch64-addon-ssh:9.2.1",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
||||
"local/aarch64-addon-ssh:latest", force=True
|
||||
)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
||||
"local/aarch64-addon-ssh:9.2.1", force=True
|
||||
)
|
||||
mock_run_command.assert_called_once()
|
||||
assert mock_run_command.call_args.args[0] == "docker.io/library/docker"
|
||||
assert mock_run_command.call_args.kwargs["version"] == "1.0.0-cli"
|
||||
@@ -894,7 +895,9 @@ async def test_addon_loads_missing_image(
|
||||
mock_amd64_arch_supported,
|
||||
):
|
||||
"""Test addon corrects a missing image on load."""
|
||||
coresys.docker.images.get.side_effect = ImageNotFound("missing")
|
||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("pathlib.Path.is_file", return_value=True),
|
||||
@@ -926,41 +929,51 @@ async def test_addon_loads_missing_image(
|
||||
assert install_addon_ssh.image == "local/amd64-addon-ssh"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pull_image_exc",
|
||||
[APIError("error"), aiodocker.DockerError(400, {"message": "error"})],
|
||||
)
|
||||
@pytest.mark.usefixtures("container", "mock_amd64_arch_supported")
|
||||
async def test_addon_load_succeeds_with_docker_errors(
|
||||
coresys: CoreSys,
|
||||
install_addon_ssh: Addon,
|
||||
container: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_amd64_arch_supported,
|
||||
pull_image_exc: Exception,
|
||||
):
|
||||
"""Docker errors while building/pulling an image during load should not raise and fail setup."""
|
||||
# Build env invalid failure
|
||||
coresys.docker.images.get.side_effect = ImageNotFound("missing")
|
||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
)
|
||||
caplog.clear()
|
||||
await install_addon_ssh.load()
|
||||
assert "Invalid build environment" in caplog.text
|
||||
|
||||
# Image build failure
|
||||
coresys.docker.images.build.side_effect = DockerException()
|
||||
caplog.clear()
|
||||
with (
|
||||
patch("pathlib.Path.is_file", return_value=True),
|
||||
patch.object(
|
||||
type(coresys.config),
|
||||
"local_to_extern_path",
|
||||
return_value="/addon/path/on/host",
|
||||
CoreConfig, "local_to_extern_path", return_value="/addon/path/on/host"
|
||||
),
|
||||
patch.object(
|
||||
DockerAPI,
|
||||
"run_command",
|
||||
return_value=MagicMock(exit_code=1, output=b"error"),
|
||||
),
|
||||
):
|
||||
await install_addon_ssh.load()
|
||||
assert "Can't build local/amd64-addon-ssh:9.2.1" in caplog.text
|
||||
assert (
|
||||
"Can't build local/amd64-addon-ssh:9.2.1: Docker build failed for local/amd64-addon-ssh:9.2.1 (exit code 1). Build output:\nerror"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
# Image pull failure
|
||||
install_addon_ssh.data["image"] = "test/amd64-addon-ssh"
|
||||
coresys.docker.images.build.reset_mock(side_effect=True)
|
||||
coresys.docker.pull_image.side_effect = DockerException()
|
||||
caplog.clear()
|
||||
await install_addon_ssh.load()
|
||||
assert "Unknown error with test/amd64-addon-ssh:9.2.1" in caplog.text
|
||||
with patch.object(DockerAPI, "pull_image", side_effect=pull_image_exc):
|
||||
await install_addon_ssh.load()
|
||||
assert "Can't install test/amd64-addon-ssh:9.2.1:" in caplog.text
|
||||
|
||||
|
||||
async def test_addon_manual_only_boot(coresys: CoreSys, install_addon_example: Addon):
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, call, patch
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
@@ -514,19 +514,13 @@ async def test_shared_image_kept_on_uninstall(
|
||||
latest = f"{install_addon_example.image}:latest"
|
||||
|
||||
await coresys.addons.uninstall("local_example2")
|
||||
coresys.docker.images.remove.assert_not_called()
|
||||
coresys.docker.images.delete.assert_not_called()
|
||||
assert not coresys.addons.get("local_example2", local_only=True)
|
||||
|
||||
await coresys.addons.uninstall("local_example")
|
||||
assert coresys.docker.images.remove.call_count == 2
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": latest,
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": image,
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.delete.call_count == 2
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(latest, force=True)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(image, force=True)
|
||||
assert not coresys.addons.get("local_example", local_only=True)
|
||||
|
||||
|
||||
@@ -554,19 +548,17 @@ async def test_shared_image_kept_on_update(
|
||||
assert example_2.version == "1.2.0"
|
||||
assert install_addon_example_image.version == "1.2.0"
|
||||
|
||||
image_new = MagicMock()
|
||||
image_new.id = "image_new"
|
||||
image_old = MagicMock()
|
||||
image_old.id = "image_old"
|
||||
docker.images.get.side_effect = [image_new, image_old]
|
||||
image_new = {"Id": "image_new", "RepoTags": ["image_new:latest"]}
|
||||
image_old = {"Id": "image_old", "RepoTags": ["image_old:latest"]}
|
||||
docker.images.inspect.side_effect = [image_new, image_old]
|
||||
docker.images.list.return_value = [image_new, image_old]
|
||||
|
||||
with patch.object(DockerAPI, "pull_image", return_value=image_new):
|
||||
await coresys.addons.update("local_example2")
|
||||
docker.images.remove.assert_not_called()
|
||||
docker.images.delete.assert_not_called()
|
||||
assert example_2.version == "1.3.0"
|
||||
|
||||
docker.images.get.side_effect = [image_new]
|
||||
docker.images.inspect.side_effect = [image_new]
|
||||
await coresys.addons.update("local_example_image")
|
||||
docker.images.remove.assert_called_once_with("image_old", force=True)
|
||||
docker.images.delete.assert_called_once_with("image_old", force=True)
|
||||
assert install_addon_example_image.version == "1.3.0"
|
||||
|
||||
@@ -19,7 +19,7 @@ from supervisor.homeassistant.core import HomeAssistantCore
|
||||
from supervisor.homeassistant.module import HomeAssistant
|
||||
|
||||
from tests.api import common_test_api_advanced_logs
|
||||
from tests.common import load_json_fixture
|
||||
from tests.common import AsyncIterator, load_json_fixture
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_route", [True, False])
|
||||
@@ -283,9 +283,9 @@ async def test_api_progress_updates_home_assistant_update(
|
||||
"""Test progress updates sent to Home Assistant for updates."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||
"docker_pull_image_log.json"
|
||||
)
|
||||
|
||||
logs = load_json_fixture("docker_pull_image_log.json")
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||
|
||||
with (
|
||||
|
||||
@@ -24,7 +24,7 @@ from supervisor.homeassistant.module import HomeAssistant
|
||||
from supervisor.store.addon import AddonStore
|
||||
from supervisor.store.repository import Repository
|
||||
|
||||
from tests.common import load_json_fixture
|
||||
from tests.common import AsyncIterator, load_json_fixture
|
||||
from tests.const import TEST_ADDON_SLUG
|
||||
|
||||
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
|
||||
@@ -732,9 +732,10 @@ async def test_api_progress_updates_addon_install_update(
|
||||
"""Test progress updates sent to Home Assistant for installs/updates."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||
"docker_pull_image_log.json"
|
||||
)
|
||||
|
||||
logs = load_json_fixture("docker_pull_image_log.json")
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
coresys.arch._supported_arch = ["amd64"] # pylint: disable=protected-access
|
||||
install_addon_example.data_store["version"] = AwesomeVersion("2.0.0")
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from supervisor.supervisor import Supervisor
|
||||
from supervisor.updater import Updater
|
||||
|
||||
from tests.api import common_test_api_advanced_logs
|
||||
from tests.common import load_json_fixture
|
||||
from tests.common import AsyncIterator, load_json_fixture
|
||||
from tests.dbus_service_mocks.base import DBusServiceMock
|
||||
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService
|
||||
|
||||
@@ -332,9 +332,9 @@ async def test_api_progress_updates_supervisor_update(
|
||||
"""Test progress updates sent to Home Assistant for updates."""
|
||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||
"docker_pull_image_log.json"
|
||||
)
|
||||
|
||||
logs = load_json_fixture("docker_pull_image_log.json")
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Common test functions."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from importlib import import_module
|
||||
from inspect import getclosurevars
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Self
|
||||
|
||||
from dbus_fast.aio.message_bus import MessageBus
|
||||
|
||||
@@ -145,3 +146,22 @@ class MockResponse:
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
"""Exit the context manager."""
|
||||
|
||||
|
||||
class AsyncIterator:
|
||||
"""Make list/fixture into async iterator for test mocks."""
|
||||
|
||||
def __init__(self, seq: Sequence[Any]) -> None:
|
||||
"""Initialize with sequence."""
|
||||
self.iter = iter(seq)
|
||||
|
||||
def __aiter__(self) -> Self:
|
||||
"""Implement aiter."""
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
"""Return next in sequence."""
|
||||
try:
|
||||
return next(self.iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration() from None
|
||||
|
||||
@@ -9,6 +9,7 @@ import subprocess
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from aiodocker.docker import DockerImages
|
||||
from aiohttp import ClientSession, web
|
||||
from aiohttp.test_utils import TestClient
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -55,6 +56,7 @@ from supervisor.store.repository import Repository
|
||||
from supervisor.utils.dt import utcnow
|
||||
|
||||
from .common import (
|
||||
AsyncIterator,
|
||||
MockResponse,
|
||||
load_binary_fixture,
|
||||
load_fixture,
|
||||
@@ -112,40 +114,46 @@ async def supervisor_name() -> None:
|
||||
@pytest.fixture
|
||||
async def docker() -> DockerAPI:
|
||||
"""Mock DockerAPI."""
|
||||
images = [MagicMock(tags=["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"])]
|
||||
image = MagicMock()
|
||||
image.attrs = {"Os": "linux", "Architecture": "amd64"}
|
||||
image_inspect = {
|
||||
"Os": "linux",
|
||||
"Architecture": "amd64",
|
||||
"Id": "test123",
|
||||
"RepoTags": ["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"],
|
||||
}
|
||||
|
||||
with (
|
||||
patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()),
|
||||
patch("supervisor.docker.manager.DockerAPI.images", return_value=MagicMock()),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock()
|
||||
),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.api",
|
||||
return_value=(api_mock := MagicMock()),
|
||||
),
|
||||
patch("supervisor.docker.manager.DockerAPI.images.get", return_value=image),
|
||||
patch("supervisor.docker.manager.DockerAPI.images.list", return_value=images),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.info",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch("supervisor.docker.manager.DockerAPI.api", return_value=MagicMock()),
|
||||
patch("supervisor.docker.manager.DockerAPI.info", return_value=MagicMock()),
|
||||
patch("supervisor.docker.manager.DockerAPI.unload"),
|
||||
patch("supervisor.docker.manager.aiodocker.Docker", return_value=MagicMock()),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.images",
|
||||
new=PropertyMock(
|
||||
return_value=(docker_images := MagicMock(spec=DockerImages))
|
||||
),
|
||||
),
|
||||
):
|
||||
docker_obj = await DockerAPI(MagicMock()).post_init()
|
||||
docker_obj.config._data = {"registries": {}}
|
||||
with patch("supervisor.docker.monitor.DockerMonitor.load"):
|
||||
await docker_obj.load()
|
||||
|
||||
docker_images.inspect.return_value = image_inspect
|
||||
docker_images.list.return_value = [image_inspect]
|
||||
docker_images.import_image.return_value = [
|
||||
{"stream": "Loaded image: test:latest\n"}
|
||||
]
|
||||
|
||||
docker_images.pull.return_value = AsyncIterator([{}])
|
||||
|
||||
docker_obj.info.logging = "journald"
|
||||
docker_obj.info.storage = "overlay2"
|
||||
docker_obj.info.version = AwesomeVersion("1.0.0")
|
||||
|
||||
# Need an iterable for logs
|
||||
api_mock.pull.return_value = []
|
||||
|
||||
yield docker_obj
|
||||
|
||||
|
||||
@@ -838,11 +846,9 @@ async def container(docker: DockerAPI) -> MagicMock:
|
||||
"""Mock attrs and status for container on attach."""
|
||||
docker.containers.get.return_value = addon = MagicMock()
|
||||
docker.containers.create.return_value = addon
|
||||
docker.images.build.return_value = (addon, "")
|
||||
addon.status = "stopped"
|
||||
addon.attrs = {"State": {"ExitCode": 0}}
|
||||
with patch.object(DockerAPI, "pull_image", return_value=addon):
|
||||
yield addon
|
||||
yield addon
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -5,10 +5,10 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch
|
||||
|
||||
import aiodocker
|
||||
from awesomeversion import AwesomeVersion
|
||||
from docker.errors import DockerException, NotFound
|
||||
from docker.models.containers import Container
|
||||
from docker.models.images import Image
|
||||
import pytest
|
||||
from requests import RequestException
|
||||
|
||||
@@ -28,7 +28,7 @@ from supervisor.exceptions import (
|
||||
)
|
||||
from supervisor.jobs import JobSchedulerOptions, SupervisorJob
|
||||
|
||||
from tests.common import load_json_fixture
|
||||
from tests.common import AsyncIterator, load_json_fixture
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -48,35 +48,30 @@ async def test_docker_image_platform(
|
||||
platform: str,
|
||||
):
|
||||
"""Test platform set correctly from arch."""
|
||||
with patch.object(
|
||||
coresys.docker.images, "get", return_value=Mock(id="test:1.2.3")
|
||||
) as get:
|
||||
await test_docker_interface.install(
|
||||
AwesomeVersion("1.2.3"), "test", arch=cpu_arch
|
||||
)
|
||||
coresys.docker.docker.api.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform=platform, stream=True, decode=True
|
||||
)
|
||||
get.assert_called_once_with("test:1.2.3")
|
||||
coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"}
|
||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test", arch=cpu_arch)
|
||||
coresys.docker.images.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform=platform, stream=True
|
||||
)
|
||||
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
|
||||
|
||||
|
||||
async def test_docker_image_default_platform(
|
||||
coresys: CoreSys, test_docker_interface: DockerInterface
|
||||
):
|
||||
"""Test platform set using supervisor arch when omitted."""
|
||||
coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"}
|
||||
with (
|
||||
patch.object(
|
||||
type(coresys.supervisor), "arch", PropertyMock(return_value="i386")
|
||||
),
|
||||
patch.object(
|
||||
coresys.docker.images, "get", return_value=Mock(id="test:1.2.3")
|
||||
) as get,
|
||||
):
|
||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
||||
coresys.docker.docker.api.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
|
||||
coresys.docker.images.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform="linux/386", stream=True
|
||||
)
|
||||
get.assert_called_once_with("test:1.2.3")
|
||||
|
||||
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -207,57 +202,40 @@ async def test_attach_existing_container(
|
||||
|
||||
async def test_attach_container_failure(coresys: CoreSys):
|
||||
"""Test attach fails to find container but finds image."""
|
||||
container_collection = MagicMock()
|
||||
container_collection.get.side_effect = DockerException()
|
||||
image_collection = MagicMock()
|
||||
image_config = {"Image": "sha256:abc123"}
|
||||
image_collection.get.return_value = Image({"Config": image_config})
|
||||
with (
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
new=PropertyMock(return_value=container_collection),
|
||||
),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.images",
|
||||
new=PropertyMock(return_value=image_collection),
|
||||
),
|
||||
patch.object(type(coresys.bus), "fire_event") as fire_event,
|
||||
):
|
||||
coresys.docker.containers.get.side_effect = DockerException()
|
||||
coresys.docker.images.inspect.return_value.setdefault("Config", {})["Image"] = (
|
||||
"sha256:abc123"
|
||||
)
|
||||
with patch.object(type(coresys.bus), "fire_event") as fire_event:
|
||||
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
|
||||
assert not [
|
||||
event
|
||||
for event in fire_event.call_args_list
|
||||
if event.args[0] == BusEvent.DOCKER_CONTAINER_STATE_CHANGE
|
||||
]
|
||||
assert coresys.homeassistant.core.instance.meta_config == image_config
|
||||
assert (
|
||||
coresys.homeassistant.core.instance.meta_config["Image"] == "sha256:abc123"
|
||||
)
|
||||
|
||||
|
||||
async def test_attach_total_failure(coresys: CoreSys):
|
||||
"""Test attach fails to find container or image."""
|
||||
container_collection = MagicMock()
|
||||
container_collection.get.side_effect = DockerException()
|
||||
image_collection = MagicMock()
|
||||
image_collection.get.side_effect = DockerException()
|
||||
with (
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.containers",
|
||||
new=PropertyMock(return_value=container_collection),
|
||||
),
|
||||
patch(
|
||||
"supervisor.docker.manager.DockerAPI.images",
|
||||
new=PropertyMock(return_value=image_collection),
|
||||
),
|
||||
pytest.raises(DockerError),
|
||||
):
|
||||
coresys.docker.containers.get.side_effect = DockerException
|
||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
400, {"message": ""}
|
||||
)
|
||||
with pytest.raises(DockerError):
|
||||
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("err", [DockerException(), RequestException()])
|
||||
@pytest.mark.parametrize(
|
||||
"err", [aiodocker.DockerError(400, {"message": ""}), RequestException()]
|
||||
)
|
||||
async def test_image_pull_fail(
|
||||
coresys: CoreSys, capture_exception: Mock, err: Exception
|
||||
):
|
||||
"""Test failure to pull image."""
|
||||
coresys.docker.images.get.side_effect = err
|
||||
coresys.docker.images.inspect.side_effect = err
|
||||
with pytest.raises(DockerError):
|
||||
await coresys.homeassistant.core.instance.install(
|
||||
AwesomeVersion("2022.7.3"), arch=CpuArch.AMD64
|
||||
@@ -289,8 +267,9 @@ async def test_install_fires_progress_events(
|
||||
coresys: CoreSys, test_docker_interface: DockerInterface
|
||||
):
|
||||
"""Test progress events are fired during an install for listeners."""
|
||||
|
||||
# This is from a sample pull. Filtered log to just one per unique status for test
|
||||
coresys.docker.docker.api.pull.return_value = [
|
||||
logs = [
|
||||
{
|
||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
||||
"id": "2025.7.2",
|
||||
@@ -312,7 +291,11 @@ async def test_install_fires_progress_events(
|
||||
"id": "1578b14a573c",
|
||||
},
|
||||
{"status": "Pull complete", "progressDetail": {}, "id": "1578b14a573c"},
|
||||
{"status": "Verifying Checksum", "progressDetail": {}, "id": "6a1e931d8f88"},
|
||||
{
|
||||
"status": "Verifying Checksum",
|
||||
"progressDetail": {},
|
||||
"id": "6a1e931d8f88",
|
||||
},
|
||||
{
|
||||
"status": "Digest: sha256:490080d7da0f385928022927990e04f604615f7b8c622ef3e58253d0f089881d"
|
||||
},
|
||||
@@ -320,6 +303,7 @@ async def test_install_fires_progress_events(
|
||||
"status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.2"
|
||||
},
|
||||
]
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
events: list[PullLogEntry] = []
|
||||
|
||||
@@ -334,10 +318,10 @@ async def test_install_fires_progress_events(
|
||||
),
|
||||
):
|
||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
||||
coresys.docker.docker.api.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
|
||||
coresys.docker.images.pull.assert_called_once_with(
|
||||
"test", tag="1.2.3", platform="linux/386", stream=True
|
||||
)
|
||||
coresys.docker.images.get.assert_called_once_with("test:1.2.3")
|
||||
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
assert events == [
|
||||
@@ -415,10 +399,11 @@ async def test_install_progress_rounding_does_not_cause_misses(
|
||||
):
|
||||
"""Test extremely close progress events do not create rounding issues."""
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
|
||||
# Current numbers chosen to create a rounding issue with original code
|
||||
# Where a progress update came in with a value between the actual previous
|
||||
# value and what it was rounded to. It should not raise an out of order exception
|
||||
coresys.docker.docker.api.pull.return_value = [
|
||||
logs = [
|
||||
{
|
||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
||||
"id": "2025.7.1",
|
||||
@@ -458,6 +443,7 @@ async def test_install_progress_rounding_does_not_cause_misses(
|
||||
"status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.1"
|
||||
},
|
||||
]
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -513,7 +499,8 @@ async def test_install_raises_on_pull_error(
|
||||
exc_msg: str,
|
||||
):
|
||||
"""Test exceptions raised from errors in pull log."""
|
||||
coresys.docker.docker.api.pull.return_value = [
|
||||
|
||||
logs = [
|
||||
{
|
||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
||||
"id": "2025.7.2",
|
||||
@@ -526,6 +513,7 @@ async def test_install_raises_on_pull_error(
|
||||
},
|
||||
error_log,
|
||||
]
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
with pytest.raises(exc_type, match=exc_msg):
|
||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
||||
@@ -539,11 +527,11 @@ async def test_install_progress_handles_download_restart(
|
||||
):
|
||||
"""Test install handles docker progress events that include a download restart."""
|
||||
coresys.core.set_state(CoreState.RUNNING)
|
||||
|
||||
# Fixture emulates a download restart as it docker logs it
|
||||
# A log out of order exception should not be raised
|
||||
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||
"docker_pull_image_log_restart.json"
|
||||
)
|
||||
logs = load_json_fixture("docker_pull_image_log_restart.json")
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -586,7 +574,7 @@ async def test_install_progress_handles_layers_skipping_download(
|
||||
|
||||
# Reproduce EXACT sequence from SupervisorNoUpdateProgressLogs.txt:
|
||||
# Small layer (02a6e69d8d00) completes BEFORE normal layer (3f4a84073184) starts downloading
|
||||
coresys.docker.docker.api.pull.return_value = [
|
||||
logs = [
|
||||
{"status": "Pulling from test/image", "id": "latest"},
|
||||
# Small layer that skips downloading (02a6e69d8d00 in logs, 96 bytes)
|
||||
{"status": "Pulling fs layer", "progressDetail": {}, "id": "02a6e69d8d00"},
|
||||
@@ -634,6 +622,7 @@ async def test_install_progress_handles_layers_skipping_download(
|
||||
{"status": "Digest: sha256:test"},
|
||||
{"status": "Status: Downloaded newer image for test/image:latest"},
|
||||
]
|
||||
coresys.docker.images.pull.return_value = AsyncIterator(logs)
|
||||
|
||||
# Capture immutable snapshots of install job progress using job.as_dict()
|
||||
# This solves the mutable object problem - we snapshot state at call time
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Test Docker manager."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from docker.errors import DockerException
|
||||
from docker.errors import APIError, DockerException, NotFound
|
||||
import pytest
|
||||
from requests import RequestException
|
||||
|
||||
@@ -20,7 +21,7 @@ async def test_run_command_success(docker: DockerAPI):
|
||||
mock_container.logs.return_value = b"command output"
|
||||
|
||||
# Mock docker containers.run to return our mock container
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
|
||||
# Execute the command
|
||||
result = docker.run_command(
|
||||
@@ -33,7 +34,7 @@ async def test_run_command_success(docker: DockerAPI):
|
||||
assert result.output == b"command output"
|
||||
|
||||
# Verify docker.containers.run was called correctly
|
||||
docker.docker.containers.run.assert_called_once_with(
|
||||
docker.dockerpy.containers.run.assert_called_once_with(
|
||||
"alpine:3.18",
|
||||
command="echo hello",
|
||||
detach=True,
|
||||
@@ -55,7 +56,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
|
||||
mock_container.logs.return_value = b"error output"
|
||||
|
||||
# Mock docker containers.run to return our mock container
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
|
||||
# Execute the command with minimal parameters
|
||||
result = docker.run_command(image="ubuntu")
|
||||
@@ -66,7 +67,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
|
||||
assert result.output == b"error output"
|
||||
|
||||
# Verify docker.containers.run was called with defaults
|
||||
docker.docker.containers.run.assert_called_once_with(
|
||||
docker.dockerpy.containers.run.assert_called_once_with(
|
||||
"ubuntu:latest", # default tag
|
||||
command=None, # default command
|
||||
detach=True,
|
||||
@@ -81,7 +82,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
|
||||
async def test_run_command_docker_exception(docker: DockerAPI):
|
||||
"""Test command execution when Docker raises an exception."""
|
||||
# Mock docker containers.run to raise DockerException
|
||||
docker.docker.containers.run.side_effect = DockerException("Docker error")
|
||||
docker.dockerpy.containers.run.side_effect = DockerException("Docker error")
|
||||
|
||||
# Execute the command and expect DockerError
|
||||
with pytest.raises(DockerError, match="Can't execute command: Docker error"):
|
||||
@@ -91,7 +92,7 @@ async def test_run_command_docker_exception(docker: DockerAPI):
|
||||
async def test_run_command_request_exception(docker: DockerAPI):
|
||||
"""Test command execution when requests raises an exception."""
|
||||
# Mock docker containers.run to raise RequestException
|
||||
docker.docker.containers.run.side_effect = RequestException("Connection error")
|
||||
docker.dockerpy.containers.run.side_effect = RequestException("Connection error")
|
||||
|
||||
# Execute the command and expect DockerError
|
||||
with pytest.raises(DockerError, match="Can't execute command: Connection error"):
|
||||
@@ -104,7 +105,7 @@ async def test_run_command_cleanup_on_exception(docker: DockerAPI):
|
||||
mock_container = MagicMock()
|
||||
|
||||
# Mock docker.containers.run to return container, but container.wait to raise exception
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
mock_container.wait.side_effect = DockerException("Wait failed")
|
||||
|
||||
# Execute the command and expect DockerError
|
||||
@@ -123,7 +124,7 @@ async def test_run_command_custom_stdout_stderr(docker: DockerAPI):
|
||||
mock_container.logs.return_value = b"output"
|
||||
|
||||
# Mock docker containers.run to return our mock container
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
|
||||
# Execute the command with custom stdout/stderr
|
||||
result = docker.run_command(
|
||||
@@ -150,7 +151,7 @@ async def test_run_container_with_cidfile(
|
||||
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
||||
extern_cidfile_path = coresys.config.path_extern_cid_files / f"{container_name}.cid"
|
||||
|
||||
docker.docker.containers.run.return_value = mock_container
|
||||
docker.dockerpy.containers.run.return_value = mock_container
|
||||
|
||||
# Mock container creation
|
||||
with patch.object(
|
||||
@@ -351,3 +352,101 @@ async def test_run_container_with_leftover_cidfile_directory(
|
||||
assert cidfile_path.read_text() == mock_container.id
|
||||
|
||||
assert result == mock_container
|
||||
|
||||
|
||||
async def test_repair(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
"""Test repair API."""
|
||||
coresys.docker.dockerpy.networks.get.side_effect = [
|
||||
hassio := MagicMock(
|
||||
attrs={
|
||||
"Containers": {
|
||||
"good": {"Name": "good"},
|
||||
"corrupt": {"Name": "corrupt"},
|
||||
"fail": {"Name": "fail"},
|
||||
}
|
||||
}
|
||||
),
|
||||
host := MagicMock(attrs={"Containers": {}}),
|
||||
]
|
||||
coresys.docker.dockerpy.containers.get.side_effect = [
|
||||
MagicMock(),
|
||||
NotFound("corrupt"),
|
||||
DockerException("fail"),
|
||||
]
|
||||
|
||||
await coresys.run_in_executor(coresys.docker.repair)
|
||||
|
||||
coresys.docker.dockerpy.api.prune_containers.assert_called_once()
|
||||
coresys.docker.dockerpy.api.prune_images.assert_called_once_with(
|
||||
filters={"dangling": False}
|
||||
)
|
||||
coresys.docker.dockerpy.api.prune_builds.assert_called_once()
|
||||
coresys.docker.dockerpy.api.prune_volumes.assert_called_once()
|
||||
coresys.docker.dockerpy.api.prune_networks.assert_called_once()
|
||||
hassio.disconnect.assert_called_once_with("corrupt", force=True)
|
||||
host.disconnect.assert_not_called()
|
||||
assert "Docker fatal error on container fail on hassio" in caplog.text
|
||||
|
||||
|
||||
async def test_repair_failures(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
||||
"""Test repair proceeds best it can through failures."""
|
||||
coresys.docker.dockerpy.api.prune_containers.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_images.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_builds.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_volumes.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.api.prune_networks.side_effect = APIError("fail")
|
||||
coresys.docker.dockerpy.networks.get.side_effect = NotFound("missing")
|
||||
|
||||
await coresys.run_in_executor(coresys.docker.repair)
|
||||
|
||||
assert "Error for containers prune: fail" in caplog.text
|
||||
assert "Error for images prune: fail" in caplog.text
|
||||
assert "Error for builds prune: fail" in caplog.text
|
||||
assert "Error for volumes prune: fail" in caplog.text
|
||||
assert "Error for networks prune: fail" in caplog.text
|
||||
assert "Error for networks hassio prune: missing" in caplog.text
|
||||
assert "Error for networks host prune: missing" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log_starter", [("Loaded image ID"), ("Loaded image")])
|
||||
async def test_import_image(coresys: CoreSys, tmp_path: Path, log_starter: str):
|
||||
"""Test importing an image into docker."""
|
||||
(test_tar := tmp_path / "test.tar").touch()
|
||||
coresys.docker.images.import_image.return_value = [
|
||||
{"stream": f"{log_starter}: imported"}
|
||||
]
|
||||
coresys.docker.images.inspect.return_value = {"Id": "imported"}
|
||||
|
||||
image = await coresys.docker.import_image(test_tar)
|
||||
|
||||
assert image["Id"] == "imported"
|
||||
coresys.docker.images.inspect.assert_called_once_with("imported")
|
||||
|
||||
|
||||
async def test_import_image_error(coresys: CoreSys, tmp_path: Path):
|
||||
"""Test failure importing an image into docker."""
|
||||
(test_tar := tmp_path / "test.tar").touch()
|
||||
coresys.docker.images.import_image.return_value = [
|
||||
{"errorDetail": {"message": "fail"}}
|
||||
]
|
||||
|
||||
with pytest.raises(DockerError, match="Can't import image from tar: fail"):
|
||||
await coresys.docker.import_image(test_tar)
|
||||
|
||||
coresys.docker.images.inspect.assert_not_called()
|
||||
|
||||
|
||||
async def test_import_multiple_images_in_tar(
|
||||
coresys: CoreSys, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test importing an image into docker."""
|
||||
(test_tar := tmp_path / "test.tar").touch()
|
||||
coresys.docker.images.import_image.return_value = [
|
||||
{"stream": "Loaded image: imported-1"},
|
||||
{"stream": "Loaded image: imported-2"},
|
||||
]
|
||||
|
||||
assert await coresys.docker.import_image(test_tar) is None
|
||||
|
||||
assert "Unexpected image count 2 while importing image from tar" in caplog.text
|
||||
coresys.docker.images.inspect.assert_not_called()
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Test Home Assistant core."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch
|
||||
|
||||
import aiodocker
|
||||
from awesomeversion import AwesomeVersion
|
||||
from docker.errors import APIError, DockerException, ImageNotFound, NotFound
|
||||
from docker.errors import APIError, DockerException, NotFound
|
||||
import pytest
|
||||
from requests import RequestException
|
||||
from time_machine import travel
|
||||
|
||||
from supervisor.const import CpuArch
|
||||
@@ -23,8 +26,12 @@ from supervisor.exceptions import (
|
||||
from supervisor.homeassistant.api import APIState
|
||||
from supervisor.homeassistant.core import HomeAssistantCore
|
||||
from supervisor.homeassistant.module import HomeAssistant
|
||||
from supervisor.resolution.const import ContextType, IssueType
|
||||
from supervisor.resolution.data import Issue
|
||||
from supervisor.updater import Updater
|
||||
|
||||
from tests.common import AsyncIterator
|
||||
|
||||
|
||||
async def test_update_fails_if_out_of_date(coresys: CoreSys):
|
||||
"""Test update of Home Assistant fails when supervisor or plugin is out of date."""
|
||||
@@ -52,11 +59,23 @@ async def test_update_fails_if_out_of_date(coresys: CoreSys):
|
||||
await coresys.homeassistant.core.update()
|
||||
|
||||
|
||||
async def test_install_landingpage_docker_error(
|
||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||
@pytest.mark.parametrize(
|
||||
"err",
|
||||
[
|
||||
aiodocker.DockerError(HTTPStatus.TOO_MANY_REQUESTS, {"message": "ratelimit"}),
|
||||
APIError("ratelimit", MagicMock(status_code=HTTPStatus.TOO_MANY_REQUESTS)),
|
||||
],
|
||||
)
|
||||
async def test_install_landingpage_docker_ratelimit_error(
|
||||
coresys: CoreSys,
|
||||
capture_exception: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
err: Exception,
|
||||
):
|
||||
"""Test install landing page fails due to docker error."""
|
||||
"""Test install landing page fails due to docker ratelimit error."""
|
||||
coresys.security.force = True
|
||||
coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])]
|
||||
|
||||
with (
|
||||
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError),
|
||||
patch.object(
|
||||
@@ -69,19 +88,35 @@ async def test_install_landingpage_docker_error(
|
||||
),
|
||||
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
|
||||
):
|
||||
coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()]
|
||||
await coresys.homeassistant.core.install_landingpage()
|
||||
sleep.assert_awaited_once_with(30)
|
||||
|
||||
assert "Failed to install landingpage, retrying after 30sec" in caplog.text
|
||||
capture_exception.assert_not_called()
|
||||
assert (
|
||||
Issue(IssueType.DOCKER_RATELIMIT, ContextType.SYSTEM)
|
||||
in coresys.resolution.issues
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"err",
|
||||
[
|
||||
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "fail"}),
|
||||
APIError("fail"),
|
||||
DockerException(),
|
||||
RequestException(),
|
||||
OSError(),
|
||||
],
|
||||
)
|
||||
async def test_install_landingpage_other_error(
|
||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||
coresys: CoreSys,
|
||||
capture_exception: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
err: Exception,
|
||||
):
|
||||
"""Test install landing page fails due to other error."""
|
||||
coresys.docker.images.get.side_effect = [(err := OSError()), MagicMock()]
|
||||
coresys.docker.images.inspect.side_effect = [err, MagicMock()]
|
||||
|
||||
with (
|
||||
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError),
|
||||
@@ -102,11 +137,23 @@ async def test_install_landingpage_other_error(
|
||||
capture_exception.assert_called_once_with(err)
|
||||
|
||||
|
||||
async def test_install_docker_error(
|
||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||
@pytest.mark.parametrize(
|
||||
"err",
|
||||
[
|
||||
aiodocker.DockerError(HTTPStatus.TOO_MANY_REQUESTS, {"message": "ratelimit"}),
|
||||
APIError("ratelimit", MagicMock(status_code=HTTPStatus.TOO_MANY_REQUESTS)),
|
||||
],
|
||||
)
|
||||
async def test_install_docker_ratelimit_error(
|
||||
coresys: CoreSys,
|
||||
capture_exception: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
err: Exception,
|
||||
):
|
||||
"""Test install fails due to docker error."""
|
||||
"""Test install fails due to docker ratelimit error."""
|
||||
coresys.security.force = True
|
||||
coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])]
|
||||
|
||||
with (
|
||||
patch.object(HomeAssistantCore, "start"),
|
||||
patch.object(DockerHomeAssistant, "cleanup"),
|
||||
@@ -123,19 +170,35 @@ async def test_install_docker_error(
|
||||
),
|
||||
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
|
||||
):
|
||||
coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()]
|
||||
await coresys.homeassistant.core.install()
|
||||
sleep.assert_awaited_once_with(30)
|
||||
|
||||
assert "Error on Home Assistant installation. Retrying in 30sec" in caplog.text
|
||||
capture_exception.assert_not_called()
|
||||
assert (
|
||||
Issue(IssueType.DOCKER_RATELIMIT, ContextType.SYSTEM)
|
||||
in coresys.resolution.issues
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"err",
|
||||
[
|
||||
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "fail"}),
|
||||
APIError("fail"),
|
||||
DockerException(),
|
||||
RequestException(),
|
||||
OSError(),
|
||||
],
|
||||
)
|
||||
async def test_install_other_error(
|
||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||
coresys: CoreSys,
|
||||
capture_exception: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
err: Exception,
|
||||
):
|
||||
"""Test install fails due to other error."""
|
||||
coresys.docker.images.get.side_effect = [(err := OSError()), MagicMock()]
|
||||
coresys.docker.images.inspect.side_effect = [err, MagicMock()]
|
||||
|
||||
with (
|
||||
patch.object(HomeAssistantCore, "start"),
|
||||
@@ -161,21 +224,29 @@ async def test_install_other_error(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"container_exists,image_exists", [(False, True), (True, False), (True, True)]
|
||||
("container_exc", "image_exc", "remove_calls"),
|
||||
[
|
||||
(NotFound("missing"), None, []),
|
||||
(
|
||||
None,
|
||||
aiodocker.DockerError(404, {"message": "missing"}),
|
||||
[call(force=True, v=True)],
|
||||
),
|
||||
(None, None, [call(force=True, v=True)]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("path_extern")
|
||||
async def test_start(
|
||||
coresys: CoreSys, container_exists: bool, image_exists: bool, path_extern
|
||||
coresys: CoreSys,
|
||||
container_exc: DockerException | None,
|
||||
image_exc: aiodocker.DockerError | None,
|
||||
remove_calls: list[call],
|
||||
):
|
||||
"""Test starting Home Assistant."""
|
||||
if image_exists:
|
||||
coresys.docker.images.get.return_value.id = "123"
|
||||
else:
|
||||
coresys.docker.images.get.side_effect = ImageNotFound("missing")
|
||||
|
||||
if container_exists:
|
||||
coresys.docker.containers.get.return_value.image.id = "123"
|
||||
else:
|
||||
coresys.docker.containers.get.side_effect = NotFound("missing")
|
||||
coresys.docker.images.inspect.return_value = {"Id": "123"}
|
||||
coresys.docker.images.inspect.side_effect = image_exc
|
||||
coresys.docker.containers.get.return_value.id = "123"
|
||||
coresys.docker.containers.get.side_effect = container_exc
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -198,18 +269,14 @@ async def test_start(
|
||||
assert run.call_args.kwargs["hostname"] == "homeassistant"
|
||||
|
||||
coresys.docker.containers.get.return_value.stop.assert_not_called()
|
||||
if container_exists:
|
||||
coresys.docker.containers.get.return_value.remove.assert_called_once_with(
|
||||
force=True,
|
||||
v=True,
|
||||
)
|
||||
else:
|
||||
coresys.docker.containers.get.return_value.remove.assert_not_called()
|
||||
assert (
|
||||
coresys.docker.containers.get.return_value.remove.call_args_list == remove_calls
|
||||
)
|
||||
|
||||
|
||||
async def test_start_existing_container(coresys: CoreSys, path_extern):
|
||||
"""Test starting Home Assistant when container exists and is viable."""
|
||||
coresys.docker.images.get.return_value.id = "123"
|
||||
coresys.docker.images.inspect.return_value = {"Id": "123"}
|
||||
coresys.docker.containers.get.return_value.image.id = "123"
|
||||
coresys.docker.containers.get.return_value.status = "exited"
|
||||
|
||||
@@ -394,24 +461,32 @@ async def test_core_loads_wrong_image_for_machine(
|
||||
"""Test core is loaded with wrong image for machine."""
|
||||
coresys.homeassistant.set_image("ghcr.io/home-assistant/odroid-n2-homeassistant")
|
||||
coresys.homeassistant.version = AwesomeVersion("2024.4.0")
|
||||
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}}
|
||||
|
||||
await coresys.homeassistant.core.load()
|
||||
with patch.object(
|
||||
DockerAPI,
|
||||
"pull_image",
|
||||
return_value={
|
||||
"Id": "abc123",
|
||||
"Config": {"Labels": {"io.hass.version": "2024.4.0"}},
|
||||
},
|
||||
) as pull_image:
|
||||
container.attrs |= pull_image.return_value
|
||||
await coresys.homeassistant.core.load()
|
||||
pull_image.assert_called_once_with(
|
||||
ANY,
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||
"2024.4.0",
|
||||
platform="linux/amd64",
|
||||
)
|
||||
|
||||
container.remove.assert_called_once_with(force=True, v=True)
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": "ghcr.io/home-assistant/odroid-n2-homeassistant:latest",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": "ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0",
|
||||
"force": True,
|
||||
}
|
||||
coresys.docker.pull_image.assert_called_once_with(
|
||||
ANY,
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||
"2024.4.0",
|
||||
platform="linux/amd64",
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
||||
"ghcr.io/home-assistant/odroid-n2-homeassistant:latest",
|
||||
force=True,
|
||||
)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
||||
"ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0",
|
||||
force=True,
|
||||
)
|
||||
assert (
|
||||
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant"
|
||||
@@ -428,8 +503,8 @@ async def test_core_load_allows_image_override(coresys: CoreSys, container: Magi
|
||||
await coresys.homeassistant.core.load()
|
||||
|
||||
container.remove.assert_not_called()
|
||||
coresys.docker.images.remove.assert_not_called()
|
||||
coresys.docker.images.get.assert_not_called()
|
||||
coresys.docker.images.delete.assert_not_called()
|
||||
coresys.docker.images.inspect.assert_not_called()
|
||||
assert (
|
||||
coresys.homeassistant.image == "ghcr.io/home-assistant/odroid-n2-homeassistant"
|
||||
)
|
||||
@@ -440,27 +515,36 @@ async def test_core_loads_wrong_image_for_architecture(
|
||||
):
|
||||
"""Test core is loaded with wrong image for architecture."""
|
||||
coresys.homeassistant.version = AwesomeVersion("2024.4.0")
|
||||
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}}
|
||||
coresys.docker.images.get("ghcr.io/home-assistant/qemux86-64-homeassistant").attrs[
|
||||
"Architecture"
|
||||
] = "arm64"
|
||||
coresys.docker.images.inspect.return_value = img_data = (
|
||||
coresys.docker.images.inspect.return_value
|
||||
| {
|
||||
"Architecture": "arm64",
|
||||
"Config": {"Labels": {"io.hass.version": "2024.4.0"}},
|
||||
}
|
||||
)
|
||||
container.attrs |= img_data
|
||||
|
||||
await coresys.homeassistant.core.load()
|
||||
with patch.object(
|
||||
DockerAPI,
|
||||
"pull_image",
|
||||
return_value=img_data | {"Architecture": "amd64"},
|
||||
) as pull_image:
|
||||
await coresys.homeassistant.core.load()
|
||||
pull_image.assert_called_once_with(
|
||||
ANY,
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||
"2024.4.0",
|
||||
platform="linux/amd64",
|
||||
)
|
||||
|
||||
container.remove.assert_called_once_with(force=True, v=True)
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": "ghcr.io/home-assistant/qemux86-64-homeassistant:latest",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0",
|
||||
"force": True,
|
||||
}
|
||||
coresys.docker.pull_image.assert_called_once_with(
|
||||
ANY,
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||
"2024.4.0",
|
||||
platform="linux/amd64",
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant:latest",
|
||||
force=True,
|
||||
)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
||||
"ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0",
|
||||
force=True,
|
||||
)
|
||||
assert (
|
||||
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch
|
||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
@@ -11,6 +11,7 @@ from supervisor.const import BusEvent, CpuArch
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.const import ContainerState
|
||||
from supervisor.docker.interface import DockerInterface
|
||||
from supervisor.docker.manager import DockerAPI
|
||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||
from supervisor.exceptions import (
|
||||
AudioError,
|
||||
@@ -359,21 +360,26 @@ async def test_load_with_incorrect_image(
|
||||
plugin.version = AwesomeVersion("2024.4.0")
|
||||
|
||||
container.status = "running"
|
||||
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}}
|
||||
coresys.docker.images.inspect.return_value = img_data = (
|
||||
coresys.docker.images.inspect.return_value
|
||||
| {"Config": {"Labels": {"io.hass.version": "2024.4.0"}}}
|
||||
)
|
||||
container.attrs |= img_data
|
||||
|
||||
await plugin.load()
|
||||
with patch.object(DockerAPI, "pull_image", return_value=img_data) as pull_image:
|
||||
await plugin.load()
|
||||
pull_image.assert_called_once_with(
|
||||
ANY, correct_image, "2024.4.0", platform="linux/amd64"
|
||||
)
|
||||
|
||||
container.remove.assert_called_once_with(force=True, v=True)
|
||||
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||
"image": f"{old_image}:latest",
|
||||
"force": True,
|
||||
}
|
||||
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||
"image": f"{old_image}:2024.4.0",
|
||||
"force": True,
|
||||
}
|
||||
coresys.docker.pull_image.assert_called_once_with(
|
||||
ANY, correct_image, "2024.4.0", platform="linux/amd64"
|
||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
||||
f"{old_image}:latest",
|
||||
force=True,
|
||||
)
|
||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
||||
f"{old_image}:2024.4.0",
|
||||
force=True,
|
||||
)
|
||||
assert plugin.image == correct_image
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Test fixup addon execute repair."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
from docker.errors import NotFound
|
||||
import aiodocker
|
||||
import pytest
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
@@ -17,7 +18,9 @@ from supervisor.resolution.fixups.addon_execute_repair import FixupAddonExecuteR
|
||||
|
||||
async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon):
|
||||
"""Test fixup rebuilds addon's container."""
|
||||
docker.images.get.side_effect = NotFound("missing")
|
||||
docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
)
|
||||
install_addon_ssh.data["image"] = "test_image"
|
||||
|
||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||
@@ -41,7 +44,9 @@ async def test_fixup_max_auto_attempts(
|
||||
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
|
||||
):
|
||||
"""Test fixup stops being auto-applied after 5 failures."""
|
||||
docker.images.get.side_effect = NotFound("missing")
|
||||
docker.images.inspect.side_effect = aiodocker.DockerError(
|
||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
||||
)
|
||||
install_addon_ssh.data["image"] = "test_image"
|
||||
|
||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||
@@ -82,8 +87,6 @@ async def test_fixup_image_exists(
|
||||
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
|
||||
):
|
||||
"""Test fixup dismisses if image exists."""
|
||||
docker.images.get.return_value = MagicMock()
|
||||
|
||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||
assert addon_execute_repair.auto is True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user