Add an admin only device wipe API (#4934)

* Add an admin only device wipe API

* Fix pylint issue
This commit is contained in:
Mike Degatano 2024-02-29 10:29:52 -05:00 committed by GitHub
parent 5126820619
commit 9d4848ee77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 114 additions and 7 deletions

View File

@ -182,6 +182,7 @@ class RestAPI(CoreSysAttributes):
web.post("/os/config/sync", api_os.config_sync), web.post("/os/config/sync", api_os.config_sync),
web.post("/os/datadisk/move", api_os.migrate_data), web.post("/os/datadisk/move", api_os.migrate_data),
web.get("/os/datadisk/list", api_os.list_data), web.get("/os/datadisk/list", api_os.list_data),
web.post("/os/datadisk/wipe", api_os.wipe_data),
] ]
) )

View File

@ -118,7 +118,7 @@ ADDONS_ROLE_ACCESS: dict[str, re.Pattern] = {
r"|/multicast/.+" r"|/multicast/.+"
r"|/network/.+" r"|/network/.+"
r"|/observer/.+" r"|/observer/.+"
r"|/os/.+" r"|/os/(?!datadisk/wipe).+"
r"|/refresh_updates" r"|/refresh_updates"
r"|/resolution/.+" r"|/resolution/.+"
r"|/security/.+" r"|/security/.+"

View File

@ -96,6 +96,11 @@ class APIOS(CoreSysAttributes):
await asyncio.shield(self.sys_os.datadisk.migrate_disk(body[ATTR_DEVICE])) await asyncio.shield(self.sys_os.datadisk.migrate_disk(body[ATTR_DEVICE]))
@api_process
def wipe_data(self, request: web.Request) -> Awaitable[None]:
"""Trigger data disk wipe on Host."""
return asyncio.shield(self.sys_os.datadisk.wipe_disk())
@api_process @api_process
async def list_data(self, request: web.Request) -> dict[str, Any]: async def list_data(self, request: web.Request) -> dict[str, Any]:
"""Return possible data targets.""" """Return possible data targets."""

View File

@ -12,6 +12,6 @@ class System(DBusInterface):
object_path: str = DBUS_OBJECT_HAOS_SYSTEM object_path: str = DBUS_OBJECT_HAOS_SYSTEM
@dbus_connected @dbus_connected
async def schedule_wipe_device(self) -> None: async def schedule_wipe_device(self) -> bool:
"""Schedule a factory reset on next system boot.""" """Schedule a factory reset on next system boot."""
await self.dbus.System.call_schedule_wipe_device() return await self.dbus.System.call_schedule_wipe_device()

View File

@ -272,6 +272,35 @@ class DataDisk(CoreSysAttributes):
_LOGGER.warning, _LOGGER.warning,
) from err ) from err
@Job(
name="data_disk_wipe",
conditions=[JobCondition.HAOS, JobCondition.OS_AGENT, JobCondition.HEALTHY],
limit=JobExecutionLimit.ONCE,
on_condition=HassOSJobError,
)
async def wipe_disk(self) -> None:
"""Wipe the current data disk."""
_LOGGER.info("Scheduling wipe of data disk on next reboot")
try:
if not await self.sys_dbus.agent.system.schedule_wipe_device():
raise HassOSDataDiskError(
"Can't schedule wipe of data disk, check host logs for details",
_LOGGER.error,
)
except DBusError as err:
raise HassOSDataDiskError(
f"Can't schedule wipe of data disk: {err!s}", _LOGGER.error
) from err
_LOGGER.info("Rebooting the host to finish the wipe")
try:
await self.sys_host.control.reboot()
except (HostError, DBusError) as err:
raise HassOSError(
f"Can't restart device to finish data disk wipe: {err!s}",
_LOGGER.warning,
) from err
async def _format_device_with_single_partition( async def _format_device_with_single_partition(
self, new_disk: Disk self, new_disk: Disk
) -> UDisks2Block: ) -> UDisks2Block:

View File

@ -179,6 +179,7 @@ async def test_bad_requests(
("post", "/addons/abc123/options", {"admin", "manager"}), ("post", "/addons/abc123/options", {"admin", "manager"}),
("post", "/addons/abc123/restart", {"admin", "manager"}), ("post", "/addons/abc123/restart", {"admin", "manager"}),
("post", "/addons/abc123/security", {"admin"}), ("post", "/addons/abc123/security", {"admin"}),
("post", "/os/datadisk/wipe", {"admin"}),
], ],
) )
async def test_token_validation( async def test_token_validation(

View File

@ -17,6 +17,7 @@ from tests.dbus_service_mocks.agent_boards import Boards as BoardsService
from tests.dbus_service_mocks.agent_boards_green import Green as GreenService from tests.dbus_service_mocks.agent_boards_green import Green as GreenService
from tests.dbus_service_mocks.agent_boards_yellow import Yellow as YellowService from tests.dbus_service_mocks.agent_boards_yellow import Yellow as YellowService
from tests.dbus_service_mocks.agent_datadisk import DataDisk as DataDiskService from tests.dbus_service_mocks.agent_datadisk import DataDisk as DataDiskService
from tests.dbus_service_mocks.agent_system import System as SystemService
from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.base import DBusServiceMock
@ -113,6 +114,23 @@ async def test_api_os_datadisk_migrate(
reboot.assert_called_once() reboot.assert_called_once()
async def test_api_os_datadisk_wipe(
api_client: TestClient,
os_agent_services: dict[str, DBusServiceMock],
os_available,
):
"""Test datadisk wipe."""
system_service: SystemService = os_agent_services["agent_system"]
system_service.ScheduleWipeDevice.calls.clear()
with patch.object(SystemControl, "reboot") as reboot:
resp = await api_client.post("/os/datadisk/wipe")
assert resp.status == 200
assert system_service.ScheduleWipeDevice.calls == [()]
reboot.assert_called_once()
async def test_api_board_yellow_info(api_client: TestClient, coresys: CoreSys): async def test_api_board_yellow_info(api_client: TestClient, coresys: CoreSys):
"""Test yellow board info.""" """Test yellow board info."""
resp = await api_client.get("/os/boards/yellow") resp = await api_client.get("/os/boards/yellow")

View File

@ -30,5 +30,5 @@ async def test_dbus_osagent_system_wipe(
await os_agent.connect(dbus_session_bus) await os_agent.connect(dbus_session_bus)
assert await os_agent.system.schedule_wipe_device() is None assert await os_agent.system.schedule_wipe_device() is True
assert system_service.ScheduleWipeDevice.calls == [()] assert system_service.ScheduleWipeDevice.calls == [()]

View File

@ -1,5 +1,7 @@
"""Mock of OS Agent System dbus service.""" """Mock of OS Agent System dbus service."""
from dbus_fast import DBusError
from .base import DBusServiceMock, dbus_method from .base import DBusServiceMock, dbus_method
BUS_NAME = "io.hass.os" BUS_NAME = "io.hass.os"
@ -18,11 +20,14 @@ class System(DBusServiceMock):
object_path = "/io/hass/os/System" object_path = "/io/hass/os/System"
interface = "io.hass.os.System" interface = "io.hass.os.System"
response_schedule_wipe_device: bool | DBusError = True
@dbus_method() @dbus_method()
def ScheduleWipeDevice(self) -> "b": def ScheduleWipeDevice(self) -> "b":
"""Schedule wipe device.""" """Schedule wipe device."""
return True 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() @dbus_method()
def WipeDevice(self) -> "b": def WipeDevice(self) -> "b":

View File

@ -1,5 +1,7 @@
"""Mock of logind dbus service.""" """Mock of logind dbus service."""
from dbus_fast import DBusError
from .base import DBusServiceMock, dbus_method from .base import DBusServiceMock, dbus_method
BUS_NAME = "org.freedesktop.login1" BUS_NAME = "org.freedesktop.login1"
@ -18,10 +20,13 @@ class Logind(DBusServiceMock):
object_path = "/org/freedesktop/login1" object_path = "/org/freedesktop/login1"
interface = "org.freedesktop.login1.Manager" interface = "org.freedesktop.login1.Manager"
side_effect_reboot: DBusError | None = None
@dbus_method() @dbus_method()
def Reboot(self, interactive: "b") -> None: def Reboot(self, interactive: "b") -> None:
"""Reboot.""" """Reboot."""
if self.side_effect_reboot:
raise self.side_effect_reboot # pylint: disable=raising-bad-type
@dbus_method() @dbus_method()
def PowerOff(self, interactive: "b") -> None: def PowerOff(self, interactive: "b") -> None:

View File

@ -3,16 +3,17 @@ from dataclasses import replace
from pathlib import PosixPath from pathlib import PosixPath
from unittest.mock import patch from unittest.mock import patch
from dbus_fast import Variant from dbus_fast import DBusError, ErrorType, Variant
import pytest import pytest
from supervisor.core import Core from supervisor.core import Core
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.exceptions import HassOSDataDiskError from supervisor.exceptions import HassOSDataDiskError, HassOSError
from supervisor.os.data_disk import Disk from supervisor.os.data_disk import Disk
from tests.common import mock_dbus_services from tests.common import mock_dbus_services
from tests.dbus_service_mocks.agent_datadisk import DataDisk as DataDiskService from tests.dbus_service_mocks.agent_datadisk import DataDisk as DataDiskService
from tests.dbus_service_mocks.agent_system import System as SystemService
from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.base import DBusServiceMock
from tests.dbus_service_mocks.logind import Logind as LogindService from tests.dbus_service_mocks.logind import Logind as LogindService
from tests.dbus_service_mocks.udisks2_block import Block as BlockService from tests.dbus_service_mocks.udisks2_block import Block as BlockService
@ -270,3 +271,45 @@ async def test_datadisk_migrate_between_external_renames(
assert sdb1_partition_service.SetName.calls == [ assert sdb1_partition_service.SetName.calls == [
("hassos-data-external-old", {"auth.no_user_interaction": Variant("b", True)}) ("hassos-data-external-old", {"auth.no_user_interaction": Variant("b", True)})
] ]
async def test_datadisk_wipe_errors(
coresys: CoreSys,
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
os_available,
):
"""Test errors during datadisk wipe."""
system_service: SystemService = all_dbus_services["agent_system"]
system_service.ScheduleWipeDevice.calls.clear()
logind_service: LogindService = all_dbus_services["logind"]
logind_service.Reboot.calls.clear()
system_service.response_schedule_wipe_device = False
with pytest.raises(
HassOSDataDiskError, match="Can't schedule wipe of data disk, check host logs"
):
await coresys.os.datadisk.wipe_disk()
assert system_service.ScheduleWipeDevice.calls == [()]
assert not logind_service.Reboot.calls
system_service.ScheduleWipeDevice.calls.clear()
system_service.response_schedule_wipe_device = DBusError(ErrorType.FAILED, "fail")
with pytest.raises(
HassOSDataDiskError, match="Can't schedule wipe of data disk: fail"
):
await coresys.os.datadisk.wipe_disk()
assert system_service.ScheduleWipeDevice.calls == [()]
assert not logind_service.Reboot.calls
system_service.ScheduleWipeDevice.calls.clear()
system_service.response_schedule_wipe_device = True
logind_service.side_effect_reboot = DBusError(ErrorType.FAILED, "fail")
with patch.object(Core, "shutdown"), pytest.raises(
HassOSError, match="Can't restart device"
):
await coresys.os.datadisk.wipe_disk()
assert system_service.ScheduleWipeDevice.calls == [()]
assert logind_service.Reboot.calls == [(False,)]