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)", + )