diff --git a/supervisor/addons/manager.py b/supervisor/addons/manager.py index 8616fda50..c36050117 100644 --- a/supervisor/addons/manager.py +++ b/supervisor/addons/manager.py @@ -266,7 +266,7 @@ class AddonManager(CoreSysAttributes): ], on_condition=AddonsJobError, ) - async def rebuild(self, slug: str) -> asyncio.Task | None: + async def rebuild(self, slug: str, *, force: bool = False) -> asyncio.Task | None: """Perform a rebuild of local build add-on. Returns a Task that completes when addon has state 'started' (see addon.start) @@ -289,7 +289,7 @@ class AddonManager(CoreSysAttributes): raise AddonsError( "Version changed, use Update instead Rebuild", _LOGGER.error ) - if not addon.need_build: + if not force and not addon.need_build: raise AddonsNotSupportedError( "Can't rebuild a image based add-on", _LOGGER.error ) diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index 320068981..26c95a7f5 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -36,6 +36,7 @@ from ..const import ( ATTR_DNS, ATTR_DOCKER_API, ATTR_DOCUMENTATION, + ATTR_FORCE, ATTR_FULL_ACCESS, ATTR_GPIO, ATTR_HASSIO_API, @@ -139,6 +140,8 @@ SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()}) SCHEMA_UNINSTALL = vol.Schema( {vol.Optional(ATTR_REMOVE_CONFIG, default=False): vol.Boolean()} ) + +SCHEMA_REBUILD = vol.Schema({vol.Optional(ATTR_FORCE, default=False): vol.Boolean()}) # pylint: enable=no-value-for-parameter @@ -461,7 +464,11 @@ class APIAddons(CoreSysAttributes): async def rebuild(self, request: web.Request) -> None: """Rebuild local build add-on.""" addon = self.get_addon_for_request(request) - if start_task := await asyncio.shield(self.sys_addons.rebuild(addon.slug)): + body: dict[str, Any] = await api_validate(SCHEMA_REBUILD, request) + + if start_task := await asyncio.shield( + self.sys_addons.rebuild(addon.slug, force=body[ATTR_FORCE]) + ): await start_task @api_process diff --git a/supervisor/const.py b/supervisor/const.py index f08098178..797a58cf0 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -188,6 +188,7 @@ ATTR_FEATURES = "features" ATTR_FILENAME = "filename" ATTR_FLAGS = "flags" ATTR_FOLDERS = "folders" +ATTR_FORCE = "force" ATTR_FORCE_SECURITY = "force_security" ATTR_FREQUENCY = "frequency" ATTR_FULL_ACCESS = "full_access" diff --git a/tests/api/test_addons.py b/tests/api/test_addons.py index 64e87bf1a..053a4de3c 100644 --- a/tests/api/test_addons.py +++ b/tests/api/test_addons.py @@ -261,6 +261,98 @@ async def test_api_addon_rebuild_healthcheck( assert resp.status == 200 +async def test_api_addon_rebuild_force( + api_client: TestClient, + coresys: CoreSys, + install_addon_ssh: Addon, + container: MagicMock, + tmp_supervisor_data, + path_extern, +): + """Test rebuilding an image-based addon with force parameter.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + container.status = "running" + install_addon_ssh.path_data.mkdir() + container.attrs["Config"] = {"Healthcheck": "exists"} + await install_addon_ssh.load() + await asyncio.sleep(0) + assert install_addon_ssh.state == AddonState.STARTUP + + state_changes: list[AddonState] = [] + _container_events_task: asyncio.Task | None = None + + async def container_events(): + nonlocal state_changes + + await install_addon_ssh.container_state_changed( + _create_test_event(f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED) + ) + state_changes.append(install_addon_ssh.state) + + await install_addon_ssh.container_state_changed( + _create_test_event(f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) + ) + state_changes.append(install_addon_ssh.state) + await asyncio.sleep(0) + + await install_addon_ssh.container_state_changed( + _create_test_event(f"addon_{TEST_ADDON_SLUG}", ContainerState.HEALTHY) + ) + + async def container_events_task(*args, **kwargs): + nonlocal _container_events_task + _container_events_task = asyncio.create_task(container_events()) + + # Test 1: Without force, image-based addon should fail + with ( + patch.object(AddonBuild, "is_valid", return_value=True), + patch.object(DockerAddon, "is_running", return_value=False), + patch.object( + Addon, "need_build", new=PropertyMock(return_value=False) + ), # Image-based + patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])), + ): + resp = await api_client.post("/addons/local_ssh/rebuild") + + assert resp.status == 400 + result = await resp.json() + assert "Can't rebuild a image based add-on" in result["message"] + + # Reset state for next test + state_changes.clear() + + # Test 2: With force=True, image-based addon should succeed + with ( + patch.object(AddonBuild, "is_valid", return_value=True), + patch.object(DockerAddon, "is_running", return_value=False), + patch.object( + Addon, "need_build", new=PropertyMock(return_value=False) + ), # Image-based + patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])), + patch.object(DockerAddon, "run", new=container_events_task), + patch.object( + coresys.docker, + "run_command", + new=PropertyMock(return_value=CommandReturn(0, b"Build successful")), + ), + patch.object( + DockerAddon, "healthcheck", new=PropertyMock(return_value={"exists": True}) + ), + patch.object( + type(coresys.config), + "local_to_extern_path", + return_value="/addon/path/on/host", + ), + ): + resp = await api_client.post("/addons/local_ssh/rebuild", json={"force": True}) + + assert state_changes == [AddonState.STOPPED, AddonState.STARTUP] + assert install_addon_ssh.state == AddonState.STARTED + assert resp.status == 200 + + await _container_events_task + + async def test_api_addon_uninstall( api_client: TestClient, coresys: CoreSys,