From f55fd891e91cd4550d3809baeb8d832d33fa6501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Thu, 27 Nov 2025 16:02:39 +0100 Subject: [PATCH] Add API endpoint for migrating Docker storage driver (#6361) Implement Supervisor API for home-assistant/os-agent#238, adding possibility to schedule migration either to Containerd overlayfs driver, or migration to the graph overlay2 driver, once the device is rebooted the next time. While it's technically in the DBus OS interface, in Supervisor's abstraction it makes more sense to put it under `/docker` endpoints. --- supervisor/api/__init__.py | 4 ++ supervisor/api/docker.py | 32 ++++++++++ supervisor/const.py | 1 + supervisor/dbus/agent/system.py | 5 ++ tests/api/test_docker.py | 81 ++++++++++++++++++++++++ tests/dbus_service_mocks/agent_system.py | 14 +++- 6 files changed, 136 insertions(+), 1 deletion(-) diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 34d2044e8..f33a5bce8 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -813,6 +813,10 @@ class RestAPI(CoreSysAttributes): self.webapp.add_routes( [ web.get("/docker/info", api_docker.info), + web.post( + "/docker/migrate-storage-driver", + api_docker.migrate_docker_storage_driver, + ), web.post("/docker/options", api_docker.options), web.get("/docker/registries", api_docker.registries), web.post("/docker/registries", api_docker.create_registry), diff --git a/supervisor/api/docker.py b/supervisor/api/docker.py index 8a62054ef..79c278450 100644 --- a/supervisor/api/docker.py +++ b/supervisor/api/docker.py @@ -4,6 +4,7 @@ import logging from typing import Any from aiohttp import web +from awesomeversion import AwesomeVersion import voluptuous as vol from supervisor.resolution.const import ContextType, IssueType, SuggestionType @@ -16,6 +17,7 @@ from ..const import ( ATTR_PASSWORD, ATTR_REGISTRIES, ATTR_STORAGE, + ATTR_STORAGE_DRIVER, ATTR_USERNAME, ATTR_VERSION, ) @@ -42,6 +44,12 @@ SCHEMA_OPTIONS = vol.Schema( } ) +SCHEMA_MIGRATE_DOCKER_STORAGE_DRIVER = vol.Schema( + { + vol.Required(ATTR_STORAGE_DRIVER): vol.In(["overlayfs", "overlay2"]), + } +) + class APIDocker(CoreSysAttributes): """Handle RESTful API for Docker configuration.""" @@ -123,3 +131,27 @@ class APIDocker(CoreSysAttributes): del self.sys_docker.config.registries[hostname] await self.sys_docker.config.save_data() + + @api_process + async def migrate_docker_storage_driver(self, request: web.Request) -> None: + """Migrate Docker storage driver.""" + if ( + not self.coresys.os.available + or not self.coresys.os.version + or self.coresys.os.version < AwesomeVersion("17.0.dev0") + ): + raise APINotFound( + "Home Assistant OS 17.0 or newer required for Docker storage driver migration" + ) + + body = await api_validate(SCHEMA_MIGRATE_DOCKER_STORAGE_DRIVER, request) + await self.sys_dbus.agent.system.migrate_docker_storage_driver( + body[ATTR_STORAGE_DRIVER] + ) + + _LOGGER.info("Host system reboot required to apply Docker storage migration") + self.sys_resolution.create_issue( + IssueType.REBOOT_REQUIRED, + ContextType.SYSTEM, + suggestions=[SuggestionType.EXECUTE_REBOOT], + ) diff --git a/supervisor/const.py b/supervisor/const.py index affd05187..8b7c9cca9 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -328,6 +328,7 @@ ATTR_STATE = "state" ATTR_STATIC = "static" ATTR_STDIN = "stdin" ATTR_STORAGE = "storage" +ATTR_STORAGE_DRIVER = "storage_driver" ATTR_SUGGESTIONS = "suggestions" ATTR_SUPERVISOR = "supervisor" ATTR_SUPERVISOR_INTERNET = "supervisor_internet" diff --git a/supervisor/dbus/agent/system.py b/supervisor/dbus/agent/system.py index 3dd8b42bc..69f5ce683 100644 --- a/supervisor/dbus/agent/system.py +++ b/supervisor/dbus/agent/system.py @@ -15,3 +15,8 @@ class System(DBusInterface): async def schedule_wipe_device(self) -> bool: """Schedule a factory reset on next system boot.""" return await self.connected_dbus.System.call("schedule_wipe_device") + + @dbus_connected + 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) diff --git a/tests/api/test_docker.py b/tests/api/test_docker.py index 8c10aab40..51cecf91d 100644 --- a/tests/api/test_docker.py +++ b/tests/api/test_docker.py @@ -4,6 +4,11 @@ from aiohttp.test_utils import TestClient import pytest from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion + +from tests.dbus_service_mocks.agent_system import System as SystemService +from tests.dbus_service_mocks.base import DBusServiceMock @pytest.mark.asyncio @@ -84,3 +89,79 @@ async def test_registry_not_found(api_client: TestClient): assert resp.status == 404 body = await resp.json() assert body["message"] == "Hostname bad does not exist in registries" + + +@pytest.mark.parametrize("os_available", ["17.0.rc1"], indirect=True) +async def test_api_migrate_docker_storage_driver( + api_client: TestClient, + coresys: CoreSys, + os_agent_services: dict[str, DBusServiceMock], + os_available, +): + """Test Docker storage driver migration.""" + system_service: SystemService = os_agent_services["agent_system"] + system_service.MigrateDockerStorageDriver.calls.clear() + + resp = await api_client.post( + "/docker/migrate-storage-driver", + json={"storage_driver": "overlayfs"}, + ) + assert resp.status == 200 + + assert system_service.MigrateDockerStorageDriver.calls == [("overlayfs",)] + assert ( + Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + in coresys.resolution.issues + ) + assert ( + Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM) + in coresys.resolution.suggestions + ) + + # Test migration back to overlay2 (graph driver) + system_service.MigrateDockerStorageDriver.calls.clear() + resp = await api_client.post( + "/docker/migrate-storage-driver", + json={"storage_driver": "overlay2"}, + ) + assert resp.status == 200 + assert system_service.MigrateDockerStorageDriver.calls == [("overlay2",)] + + +@pytest.mark.parametrize("os_available", ["17.0.rc1"], indirect=True) +async def test_api_migrate_docker_storage_driver_invalid_backend( + api_client: TestClient, + os_available, +): + """Test 400 is returned for invalid storage driver.""" + resp = await api_client.post( + "/docker/migrate-storage-driver", + json={"storage_driver": "invalid"}, + ) + assert resp.status == 400 + + +async def test_api_migrate_docker_storage_driver_not_os( + api_client: TestClient, + coresys: CoreSys, +): + """Test 404 is returned if not running on HAOS.""" + resp = await api_client.post( + "/docker/migrate-storage-driver", + json={"storage_driver": "overlayfs"}, + ) + assert resp.status == 404 + + +@pytest.mark.parametrize("os_available", ["16.2"], indirect=True) +async def test_api_migrate_docker_storage_driver_old_os( + api_client: TestClient, + coresys: CoreSys, + os_available, +): + """Test 404 is returned if OS is older than 17.0.""" + resp = await api_client.post( + "/docker/migrate-storage-driver", + json={"storage_driver": "overlayfs"}, + ) + assert resp.status == 404 diff --git a/tests/dbus_service_mocks/agent_system.py b/tests/dbus_service_mocks/agent_system.py index 0ed149cf5..4148af480 100644 --- a/tests/dbus_service_mocks/agent_system.py +++ b/tests/dbus_service_mocks/agent_system.py @@ -1,6 +1,6 @@ """Mock of OS Agent System dbus service.""" -from dbus_fast import DBusError +from dbus_fast import DBusError, ErrorType from .base import DBusServiceMock, dbus_method @@ -21,6 +21,7 @@ class System(DBusServiceMock): object_path = "/io/hass/os/System" interface = "io.hass.os.System" response_schedule_wipe_device: bool | DBusError = True + response_migrate_docker_storage_driver: None | DBusError = None @dbus_method() def ScheduleWipeDevice(self) -> "b": @@ -28,3 +29,14 @@ class System(DBusServiceMock): if isinstance(self.response_schedule_wipe_device, DBusError): raise self.response_schedule_wipe_device # pylint: disable=raising-bad-type return self.response_schedule_wipe_device + + @dbus_method() + def MigrateDockerStorageDriver(self, backend: "s") -> None: + """Migrate Docker storage driver.""" + if isinstance(self.response_migrate_docker_storage_driver, DBusError): + raise self.response_migrate_docker_storage_driver # pylint: disable=raising-bad-type + if backend not in ("overlayfs", "overlay2"): + raise DBusError( + ErrorType.FAILED, + f"unsupported driver: {backend} (only 'overlayfs' and 'overlay2' are supported)", + )