diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 4364dae17..b88457725 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -467,6 +467,11 @@ class Addon(AddonModel): """Return True if this add-on need a local build.""" return ATTR_IMAGE not in self.data + @property + def latest_need_build(self) -> bool: + """Return True if the latest version of the addon needs a local build.""" + return ATTR_IMAGE not in self.data_store + @property def path_data(self) -> Path: """Return add-on data path inside Supervisor.""" diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 7901f5bde..172afde3b 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -523,18 +523,42 @@ class DockerAddon(DockerInterface): BusEvent.HARDWARE_NEW_DEVICE, self._hardware_events ) + def _update( + self, version: AwesomeVersion, image: str | None = None, latest: bool = False + ) -> None: + """Update a docker image. + + Need run inside executor. + """ + image = image or self.image + + _LOGGER.info( + "Updating image %s:%s to %s:%s", self.image, self.version, image, version + ) + + # Update docker image + self._install( + version, image=image, latest=latest, need_build=self.addon.latest_need_build + ) + + # Stop container & cleanup + with suppress(DockerError): + self._stop() + def _install( self, version: AwesomeVersion, image: str | None = None, latest: bool = False, arch: CpuArch | None = None, + *, + need_build: bool | None = None, ) -> None: """Pull Docker image or build it. Need run inside executor. """ - if self.addon.need_build: + if need_build is None and self.addon.need_build or need_build: self._build(version) else: super()._install(version, image, latest, arch) diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py new file mode 100644 index 000000000..26e1b43f0 --- /dev/null +++ b/tests/addons/test_manager.py @@ -0,0 +1,67 @@ +"""Test addon manager.""" + +from unittest.mock import PropertyMock, patch + +from awesomeversion import AwesomeVersion +import pytest + +from supervisor.addons.addon import Addon +from supervisor.arch import CpuArch +from supervisor.coresys import CoreSys +from supervisor.docker.addon import DockerAddon +from supervisor.docker.interface import DockerInterface + +from tests.common import load_json_fixture +from tests.const import TEST_ADDON_SLUG + + +@pytest.fixture(autouse=True) +async def fixture_mock_arch_disk() -> None: + """Mock supported arch and disk space.""" + with patch( + "shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3)) + ), patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])): + yield + + +async def test_image_added_removed_on_update( + coresys: CoreSys, install_addon_ssh: Addon +): + """Test image added or removed from addon config on update.""" + assert install_addon_ssh.need_update is False + with patch( + "supervisor.store.data.read_json_or_yaml_file", + return_value=load_json_fixture("addon-config-add-image.json"), + ): + await coresys.store.data.update() + + assert install_addon_ssh.need_update is True + assert install_addon_ssh.image == "local/amd64-addon-ssh" + assert coresys.addons.store.get(TEST_ADDON_SLUG).image == "test/amd64-my-ssh-addon" + + with patch.object(DockerInterface, "_install") as install, patch.object( + DockerAddon, "_build" + ) as build: + await install_addon_ssh.update() + build.assert_not_called() + install.assert_called_once_with( + AwesomeVersion("10.0.0"), "test/amd64-my-ssh-addon", False, None + ) + + assert install_addon_ssh.need_update is False + with patch( + "supervisor.store.data.read_json_or_yaml_file", + return_value=load_json_fixture("addon-config-remove-image.json"), + ): + await coresys.store.data.update() + + assert install_addon_ssh.need_update is True + assert install_addon_ssh.image == "test/amd64-my-ssh-addon" + assert coresys.addons.store.get(TEST_ADDON_SLUG).image == "local/amd64-addon-ssh" + + with patch.object(DockerInterface, "_install") as install, patch.object( + DockerAddon, "_build" + ) as build: + await install_addon_ssh.update() + build.assert_called_once_with(AwesomeVersion("11.0.0")) + install.assert_not_called() diff --git a/tests/fixtures/addon-config-add-image.json b/tests/fixtures/addon-config-add-image.json new file mode 100644 index 000000000..a731433ec --- /dev/null +++ b/tests/fixtures/addon-config-add-image.json @@ -0,0 +1,46 @@ +{ + "name": "Terminal & SSH", + "version": "10.0.0", + "slug": "ssh", + "description": "Allow logging in remotely to Home Assistant using SSH", + "url": "https://github.com/home-assistant/hassio-addons/tree/master/ssh", + "arch": ["armhf", "armv7", "aarch64", "amd64", "i386"], + "init": false, + "advanced": true, + "host_dbus": true, + "ingress": true, + "panel_icon": "mdi:console", + "panel_title": "Terminal", + "hassio_api": true, + "hassio_role": "manager", + "audio": true, + "uart": true, + "ports": { + "22/tcp": null + }, + "map": [ + "config:rw", + "ssl:rw", + "addons:rw", + "share:rw", + "backup:rw", + "media:rw" + ], + "options": { + "authorized_keys": [], + "apks": [], + "password": "", + "server": { + "tcp_forwarding": false + } + }, + "schema": { + "authorized_keys": ["str"], + "password": "password", + "server": { + "tcp_forwarding": "bool" + }, + "apks": ["str"] + }, + "image": "test/{arch}-my-ssh-addon" +} diff --git a/tests/fixtures/addon-config-remove-image.json b/tests/fixtures/addon-config-remove-image.json new file mode 100644 index 000000000..e05111ea8 --- /dev/null +++ b/tests/fixtures/addon-config-remove-image.json @@ -0,0 +1,45 @@ +{ + "name": "Terminal & SSH", + "version": "11.0.0", + "slug": "ssh", + "description": "Allow logging in remotely to Home Assistant using SSH", + "url": "https://github.com/home-assistant/hassio-addons/tree/master/ssh", + "arch": ["armhf", "armv7", "aarch64", "amd64", "i386"], + "init": false, + "advanced": true, + "host_dbus": true, + "ingress": true, + "panel_icon": "mdi:console", + "panel_title": "Terminal", + "hassio_api": true, + "hassio_role": "manager", + "audio": true, + "uart": true, + "ports": { + "22/tcp": null + }, + "map": [ + "config:rw", + "ssl:rw", + "addons:rw", + "share:rw", + "backup:rw", + "media:rw" + ], + "options": { + "authorized_keys": [], + "apks": [], + "password": "", + "server": { + "tcp_forwarding": false + } + }, + "schema": { + "authorized_keys": ["str"], + "password": "password", + "server": { + "tcp_forwarding": "bool" + }, + "apks": ["str"] + } +}