From 6ecb42359306468c0170caab87890463f3ad8245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Tue, 25 Aug 2026 17:17:08 +0200 Subject: [PATCH 1/2] Add endpoint to reset Docker storage Corrupted Docker image layers cannot be always fixed by removing and re-pulling a single image, because layers are shared between images. The only (easy) way to recover is wiping all of Docker's storage which is currently cumbersome and requires OS shell access. Home Assistant OS added a service that performs the Docker storage wipe on boot when a flag file exists in home-assistant/operating-system#4982, and home-assistant/os-agent#283 adds OS Agent DBus interface for creating that file. This adds a new API method POST /docker/reset-storage which calls OS Agent's ScheduleDockerStorageReset and creates a reboot_required issue, following the same pattern used for the Docker storage driver migration. This is expected to ship in HAOS 18.3, so the endpoint checks for that and returns 404 on older OS versions. Technically, it might be nicer to gate on OS Agent version, but from user perspective it's better to report the required OS version. Refs #6555 --- supervisor/api/__init__.py | 1 + supervisor/api/docker.py | 33 +++++++- supervisor/dbus/agent/system.py | 5 ++ tests/api/test_docker.py | 102 +++++++++++++++++++++++ tests/dbus/agent/test_system.py | 16 ++++ tests/dbus_service_mocks/agent_system.py | 8 ++ 6 files changed, 164 insertions(+), 1 deletion(-) diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 529c3353c49..ab2d755e026 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -1071,6 +1071,7 @@ def _register_docker(self, app: web.Application) -> None: web.get("/docker/registries", api_docker.registries), web.post("/docker/registries", api_docker.create_registry), web.delete("/docker/registries/{hostname}", api_docker.remove_registry), + web.post("/docker/reset-storage", api_docker.reset_storage), ] ) diff --git a/supervisor/api/docker.py b/supervisor/api/docker.py index ed9129c712f..267617346bc 100644 --- a/supervisor/api/docker.py +++ b/supervisor/api/docker.py @@ -20,7 +20,7 @@ ATTR_VERSION, ) from ..coresys import CoreSysAttributes -from ..exceptions import APINotFound +from ..exceptions import APIError, APINotFound, DBusError from ..resolution.const import ContextType, IssueType, SuggestionType from .utils import api_process, api_validate @@ -154,3 +154,34 @@ async def migrate_docker_storage_driver(self, request: web.Request) -> None: ContextType.SYSTEM, suggestions=[SuggestionType.EXECUTE_REBOOT], ) + + @api_process + async def reset_storage(self, request: web.Request) -> None: + """Schedule a Docker storage reset on next reboot.""" + if ( + not self.coresys.os.available + or not self.coresys.os.version + or self.coresys.os.version < AwesomeVersion("18.3.dev0") + ): + raise APINotFound( + "Home Assistant OS 18.3 or newer required for Docker storage reset" + ) + + _LOGGER.info("Scheduling reset of Docker storage on next reboot") + try: + if not await self.sys_dbus.agent.system.schedule_docker_storage_reset(): + raise APIError( + "Can't schedule Docker storage reset, check host logs for details", + _LOGGER.error, + ) + except DBusError as err: + raise APIError( + f"Can't schedule Docker storage reset: {err!s}", _LOGGER.error + ) from err + + _LOGGER.info("Host system reboot required to apply Docker storage reset") + self.sys_resolution.create_issue( + IssueType.REBOOT_REQUIRED, + ContextType.SYSTEM, + suggestions=[SuggestionType.EXECUTE_REBOOT], + ) diff --git a/supervisor/dbus/agent/system.py b/supervisor/dbus/agent/system.py index 69f5ce6835d..11b2cb7e999 100644 --- a/supervisor/dbus/agent/system.py +++ b/supervisor/dbus/agent/system.py @@ -20,3 +20,8 @@ async def schedule_wipe_device(self) -> bool: async def migrate_docker_storage_driver(self, backend: str) -> None: """Migrate Docker storage driver.""" await self.connected_dbus.System.call("migrate_docker_storage_driver", backend) + + @dbus_connected + async def schedule_docker_storage_reset(self) -> bool: + """Schedule a Docker storage reset on next system boot.""" + return await self.connected_dbus.System.call("schedule_docker_storage_reset") diff --git a/tests/api/test_docker.py b/tests/api/test_docker.py index 082ff75fc60..68f3b55452e 100644 --- a/tests/api/test_docker.py +++ b/tests/api/test_docker.py @@ -1,6 +1,7 @@ """Test Docker API.""" from aiohttp.test_utils import TestClient +from dbus_fast import DBusError, ErrorType import pytest from supervisor.coresys import CoreSys @@ -170,3 +171,104 @@ async def test_api_migrate_docker_storage_driver_old_os( json={"storage_driver": "overlayfs"}, ) assert resp.status == 404 + + +@pytest.mark.parametrize("os_available", ["18.3.dev0"], indirect=True) +async def test_api_docker_reset_storage( + coresys: CoreSys, + api_client_with_prefix: tuple[TestClient, str], + os_agent_services: dict[str, DBusServiceMock], + os_available, +): + """Test Docker storage reset.""" + api_client, prefix = api_client_with_prefix + system_service: SystemService = os_agent_services["agent_system"] + system_service.ScheduleDockerStorageReset.calls.clear() + + resp = await api_client.post(f"{prefix}/docker/reset-storage") + assert resp.status == 200 + + assert system_service.ScheduleDockerStorageReset.calls == [()] + assert ( + Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + in coresys.resolution.issues + ) + assert ( + Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM) + in coresys.resolution.suggestions + ) + + +async def test_api_docker_reset_storage_not_os( + api_client_with_prefix: tuple[TestClient, str], +): + """Test 404 is returned if not running on HAOS.""" + api_client, prefix = api_client_with_prefix + resp = await api_client.post(f"{prefix}/docker/reset-storage") + assert resp.status == 404 + + +@pytest.mark.parametrize("os_available", ["18.2"], indirect=True) +async def test_api_docker_reset_storage_old_os( + api_client_with_prefix: tuple[TestClient, str], + os_available, +): + """Test 404 is returned if OS is older than 18.3.""" + api_client, prefix = api_client_with_prefix + resp = await api_client.post(f"{prefix}/docker/reset-storage") + assert resp.status == 404 + + +@pytest.mark.parametrize("os_available", ["18.3.dev0"], indirect=True) +async def test_api_docker_reset_storage_schedule_failed( + coresys: CoreSys, + api_client_with_prefix: tuple[TestClient, str], + os_agent_services: dict[str, DBusServiceMock], + os_available, +): + """Test error if OS Agent could not schedule the reset.""" + api_client, prefix = api_client_with_prefix + system_service: SystemService = os_agent_services["agent_system"] + system_service.ScheduleDockerStorageReset.calls.clear() + system_service.response_schedule_docker_storage_reset = False + + resp = await api_client.post(f"{prefix}/docker/reset-storage") + assert resp.status == 400 + body = await resp.json() + assert ( + body["message"] + == "Can't schedule Docker storage reset, check host logs for details" + ) + + assert system_service.ScheduleDockerStorageReset.calls == [()] + assert ( + Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + not in coresys.resolution.issues + ) + + +@pytest.mark.parametrize("os_available", ["18.3.dev0"], indirect=True) +async def test_api_docker_reset_storage_dbus_error( + coresys: CoreSys, + api_client_with_prefix: tuple[TestClient, str], + os_agent_services: dict[str, DBusServiceMock], + os_available, +): + """Test error if the D-Bus call fails.""" + api_client, prefix = api_client_with_prefix + system_service: SystemService = os_agent_services["agent_system"] + system_service.ScheduleDockerStorageReset.calls.clear() + system_service.response_schedule_docker_storage_reset = DBusError( + ErrorType.FAILED, "fail" + ) + + resp = await api_client.post(f"{prefix}/docker/reset-storage") + assert resp.status == 400 + body = await resp.json() + assert body["message"] == "Can't schedule Docker storage reset: fail" + + assert system_service.ScheduleDockerStorageReset.calls == [()] + assert ( + Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + not in coresys.resolution.issues + ) diff --git a/tests/dbus/agent/test_system.py b/tests/dbus/agent/test_system.py index e747032fab1..0f8ab9f06fa 100644 --- a/tests/dbus/agent/test_system.py +++ b/tests/dbus/agent/test_system.py @@ -32,3 +32,19 @@ async def test_dbus_osagent_system_wipe( assert await os_agent.system.schedule_wipe_device() is True assert system_service.ScheduleWipeDevice.calls == [()] + + +async def test_dbus_osagent_system_schedule_docker_storage_reset( + system_service: SystemService, dbus_session_bus: MessageBus +): + """Test scheduling Docker storage reset on host.""" + system_service.ScheduleDockerStorageReset.calls.clear() + os_agent = OSAgent() + + with pytest.raises(DBusNotConnectedError): + await os_agent.system.schedule_docker_storage_reset() + + await os_agent.connect(dbus_session_bus) + + assert await os_agent.system.schedule_docker_storage_reset() is True + assert system_service.ScheduleDockerStorageReset.calls == [()] diff --git a/tests/dbus_service_mocks/agent_system.py b/tests/dbus_service_mocks/agent_system.py index 70a60c033a6..b05bfd651b2 100644 --- a/tests/dbus_service_mocks/agent_system.py +++ b/tests/dbus_service_mocks/agent_system.py @@ -22,6 +22,7 @@ class System(DBusServiceMock): interface = "io.hass.os.System" response_schedule_wipe_device: bool | DBusError = True response_migrate_docker_storage_driver: None | DBusError = None + response_schedule_docker_storage_reset: bool | DBusError = True @dbus_method() def ScheduleWipeDevice(self) -> "b": @@ -40,3 +41,10 @@ def MigrateDockerStorageDriver(self, backend: "s") -> None: ErrorType.FAILED, f"unsupported driver: {backend} (only 'overlayfs' is currently supported)", ) + + @dbus_method() + def ScheduleDockerStorageReset(self) -> "b": + """Schedule Docker storage reset.""" + if isinstance(self.response_schedule_docker_storage_reset, DBusError): + raise self.response_schedule_docker_storage_reset # pylint: disable=raising-bad-type + return self.response_schedule_docker_storage_reset From 05748a34d1706e5dda6c8ae079574f6a1f54c4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Wed, 26 Aug 2026 16:27:17 +0200 Subject: [PATCH 2/2] Add const for minimum OS version supporting the reset --- supervisor/api/docker.py | 4 +++- tests/api/test_docker.py | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/supervisor/api/docker.py b/supervisor/api/docker.py index 267617346bc..71c0ecfc8fb 100644 --- a/supervisor/api/docker.py +++ b/supervisor/api/docker.py @@ -26,6 +26,8 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) +HAOS_DOCKER_STORAGE_RESET_MIN_VERSION = AwesomeVersion("18.3.dev20260826") + SCHEMA_DOCKER_REGISTRY = vol.Schema( { str: { @@ -161,7 +163,7 @@ async def reset_storage(self, request: web.Request) -> None: if ( not self.coresys.os.available or not self.coresys.os.version - or self.coresys.os.version < AwesomeVersion("18.3.dev0") + or self.coresys.os.version < HAOS_DOCKER_STORAGE_RESET_MIN_VERSION ): raise APINotFound( "Home Assistant OS 18.3 or newer required for Docker storage reset" diff --git a/tests/api/test_docker.py b/tests/api/test_docker.py index 68f3b55452e..25aa94f2537 100644 --- a/tests/api/test_docker.py +++ b/tests/api/test_docker.py @@ -173,7 +173,7 @@ async def test_api_migrate_docker_storage_driver_old_os( assert resp.status == 404 -@pytest.mark.parametrize("os_available", ["18.3.dev0"], indirect=True) +@pytest.mark.parametrize("os_available", ["18.3.dev20260826"], indirect=True) async def test_api_docker_reset_storage( coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str], @@ -208,18 +208,18 @@ async def test_api_docker_reset_storage_not_os( assert resp.status == 404 -@pytest.mark.parametrize("os_available", ["18.2"], indirect=True) +@pytest.mark.parametrize("os_available", ["18.3.dev20260825"], indirect=True) async def test_api_docker_reset_storage_old_os( api_client_with_prefix: tuple[TestClient, str], os_available, ): - """Test 404 is returned if OS is older than 18.3.""" + """Test 404 is returned if OS is older than the first release with the reset.""" api_client, prefix = api_client_with_prefix resp = await api_client.post(f"{prefix}/docker/reset-storage") assert resp.status == 404 -@pytest.mark.parametrize("os_available", ["18.3.dev0"], indirect=True) +@pytest.mark.parametrize("os_available", ["18.3.dev20260826"], indirect=True) async def test_api_docker_reset_storage_schedule_failed( coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str], @@ -247,7 +247,7 @@ async def test_api_docker_reset_storage_schedule_failed( ) -@pytest.mark.parametrize("os_available", ["18.3.dev0"], indirect=True) +@pytest.mark.parametrize("os_available", ["18.3.dev20260826"], indirect=True) async def test_api_docker_reset_storage_dbus_error( coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str],