Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions supervisor/addons/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ async def update(
],
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)
Expand All @@ -289,7 +289,7 @@ async def rebuild(self, slug: str) -> asyncio.Task | None:
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
)
Expand Down
9 changes: 8 additions & 1 deletion supervisor/api/addons.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
ATTR_DNS,
ATTR_DOCKER_API,
ATTR_DOCUMENTATION,
ATTR_FORCE,
ATTR_FULL_ACCESS,
ATTR_GPIO,
ATTR_HASSIO_API,
Expand Down Expand Up @@ -139,6 +140,8 @@
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


Expand Down Expand Up @@ -461,7 +464,11 @@ async def restart(self, request: web.Request) -> None:
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
Expand Down
1 change: 1 addition & 0 deletions supervisor/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@
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"
Expand Down
92 changes: 92 additions & 0 deletions tests/api/test_addons.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,98 @@ async def container_events_task(*args, **kwargs):
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,
Expand Down
Loading