Format data disk in Supervisor instead of OS Agent (#4212)

* Supervisor formats data disk instead of os agent

* Fix issues occurring during tests

* Can't migrate if target is too small
This commit is contained in:
Mike Degatano 2023-03-30 14:15:07 -04:00 committed by GitHub
parent a3204f4ebd
commit c0b75edfb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 559 additions and 70 deletions

View File

@ -33,3 +33,8 @@ class DataDisk(DBusInterfaceProxy):
async def reload_device(self) -> None: async def reload_device(self) -> None:
"""Reload device data.""" """Reload device data."""
await self.dbus.DataDisk.call_reload_device() await self.dbus.DataDisk.call_reload_device()
@dbus_connected
async def mark_data_move(self) -> None:
"""Create marker to signal to do data disk migration next reboot."""
await self.dbus.DataDisk.call_mark_data_move()

View File

@ -1,5 +1,6 @@
"""Interface class for D-Bus wrappers.""" """Interface class for D-Bus wrappers."""
from abc import ABC from abc import ABC
from collections.abc import Callable
from functools import wraps from functools import wraps
from typing import Any from typing import Any
@ -83,6 +84,7 @@ class DBusInterfaceProxy(DBusInterface):
properties_interface: str | None = None properties_interface: str | None = None
properties: dict[str, Any] | None = None properties: dict[str, Any] | None = None
sync_properties: bool = True sync_properties: bool = True
_sync_properties_callback: Callable | None = None
def __init__(self): def __init__(self):
"""Initialize properties.""" """Initialize properties."""
@ -104,7 +106,17 @@ class DBusInterfaceProxy(DBusInterface):
await self.update() await self.update()
if self.sync_properties and self.is_connected: if self.sync_properties and self.is_connected:
self.dbus.sync_property_changes(self.properties_interface, self.update) self._sync_properties_callback = self.dbus.sync_property_changes(
self.properties_interface, self.update
)
def stop_sync_property_changes(self) -> None:
"""Stop syncing property changes to object."""
if not self._sync_properties_callback:
return
self.dbus.stop_sync_property_changes(self._sync_properties_callback)
self.sync_properties = False
@dbus_connected @dbus_connected
async def update(self, changed: dict[str, Any] | None = None) -> None: async def update(self, changed: dict[str, Any] | None = None) -> None:

View File

@ -139,8 +139,20 @@ class UDisks2(DBusInterfaceProxy):
resolved = { resolved = {
device: self._block_devices[device] device: self._block_devices[device]
if device in self._block_devices if device in self._block_devices
and self._block_devices[device].is_connected
else await UDisks2Block.new(device, self.dbus.bus) else await UDisks2Block.new(device, self.dbus.bus)
for device in block_devices for device in block_devices
} }
self._block_devices.update(resolved) self._block_devices.update(resolved)
return list(resolved.values()) return list(resolved.values())
def shutdown(self) -> None:
"""Shutdown the object and disconnect from D-Bus.
This method is irreversible.
"""
for block_device in self.block_devices:
block_device.shutdown()
for drive in self.drives:
drive.shutdown()
super().shutdown()

View File

@ -1,6 +1,8 @@
"""Interface to UDisks2 Block Device over D-Bus.""" """Interface to UDisks2 Block Device over D-Bus."""
import asyncio
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from dbus_fast.aio import MessageBus from dbus_fast.aio import MessageBus
@ -61,18 +63,7 @@ class UDisks2Block(DBusInterfaceProxy):
async def connect(self, bus: MessageBus) -> None: async def connect(self, bus: MessageBus) -> None:
"""Connect to bus.""" """Connect to bus."""
await super().connect(bus) await super().connect(bus)
await self._reload_interfaces()
if DBUS_IFACE_FILESYSTEM in self.dbus.proxies:
self._filesystem = UDisks2Filesystem(
self.object_path, sync_properties=self.sync_properties
)
await self._filesystem.initialize(self.dbus)
if DBUS_IFACE_PARTITION_TABLE in self.dbus.proxies:
self._partition_table = UDisks2PartitionTable(
self.object_path, sync_properties=self.sync_properties
)
await self._partition_table.initialize(self.dbus)
@staticmethod @staticmethod
async def new( async def new(
@ -195,6 +186,58 @@ class UDisks2Block(DBusInterfaceProxy):
""" """
return self.properties[DBUS_ATTR_DRIVE] return self.properties[DBUS_ATTR_DRIVE]
@dbus_connected
async def update(self, changed: dict[str, Any] | None = None) -> None:
"""Update properties via D-Bus."""
await super().update(changed)
if not changed:
await asyncio.gather(
*[
intr.update()
for intr in (self.filesystem, self.partition_table)
if intr
]
)
@dbus_connected
async def check_type(self) -> None:
"""Check if type of block device has changed and adjust interfaces if so."""
introspection = await self.dbus.introspect()
interfaces = {intr.name for intr in introspection.interfaces}
# If interfaces changed, update the proxy from introspection and reload interfaces
if interfaces != set(self.dbus.proxies.keys()):
await self.dbus.init_proxy(introspection=introspection)
await self._reload_interfaces()
@dbus_connected
async def _reload_interfaces(self) -> None:
"""Reload interfaces from introspection as necessary."""
# Check if block device is a filesystem
if not self.filesystem and DBUS_IFACE_FILESYSTEM in self.dbus.proxies:
self._filesystem = UDisks2Filesystem(
self.object_path, sync_properties=self.sync_properties
)
await self._filesystem.initialize(self.dbus)
elif self.filesystem and DBUS_IFACE_FILESYSTEM not in self.dbus.proxies:
self.filesystem.stop_sync_property_changes()
self._filesystem = None
# Check if block device is a partition table
if not self.partition_table and DBUS_IFACE_PARTITION_TABLE in self.dbus.proxies:
self._partition_table = UDisks2PartitionTable(
self.object_path, sync_properties=self.sync_properties
)
await self._partition_table.initialize(self.dbus)
elif (
self.partition_table and DBUS_IFACE_PARTITION_TABLE not in self.dbus.proxies
):
self.partition_table.stop_sync_property_changes()
self._partition_table = None
@dbus_connected @dbus_connected
async def format( async def format(
self, type_: FormatType = FormatType.GPT, options: FormatOptions | None = None self, type_: FormatType = FormatType.GPT, options: FormatOptions | None = None

View File

@ -82,7 +82,7 @@ class DeviceSpecification:
def to_dict(self) -> dict[str, Variant]: def to_dict(self) -> dict[str, Variant]:
"""Return dict representation.""" """Return dict representation."""
data = { data = {
"path": Variant("s", str(self.path)) if self.path else None, "path": Variant("s", self.path.as_posix()) if self.path else None,
"label": _optional_variant("s", self.label), "label": _optional_variant("s", self.label),
"uuid": _optional_variant("s", self.uuid), "uuid": _optional_variant("s", self.uuid),
} }

View File

@ -48,14 +48,15 @@ class UDisks2PartitionTable(DBusInterfaceProxy):
self, self,
offset: int = 0, offset: int = 0,
size: int = 0, size: int = 0,
type_: PartitionTableType = PartitionTableType.UNKNOWN, type_: str = "",
name: str = "", name: str = "",
options: CreatePartitionOptions | None = None, options: CreatePartitionOptions | None = None,
) -> str: ) -> str:
"""Create a new partition and return object path of new block device. """Create a new partition and return object path of new block device.
'UNKNOWN' for type here means UDisks2 selects default based on partition table and OS. Type should be a GUID from https://en.wikipedia.org/wiki/GUID_Partition_Table#Partition_type_GUIDs
Use with UDisks2Block.new to get block object. Or UDisks2.get_block_device after UDisks2.update. or empty string and let UDisks2 choose a default based on partition table and OS.
Provide return value with UDisks2Block.new. Or UDisks2.get_block_device after UDisks2.update.
""" """
options = options.to_dict() if options else {} options = options.to_dict() if options else {}
return await self.dbus.PartitionTable.call_create_partition( return await self.dbus.PartitionTable.call_create_partition(

View File

@ -4,11 +4,13 @@ from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Final
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..dbus.udisks2.block import UDisks2Block from ..dbus.udisks2.block import UDisks2Block
from ..dbus.udisks2.const import FormatType
from ..dbus.udisks2.drive import UDisks2Drive from ..dbus.udisks2.drive import UDisks2Drive
from ..exceptions import ( from ..exceptions import (
DBusError, DBusError,
@ -20,6 +22,11 @@ from ..exceptions import (
) )
from ..jobs.const import JobCondition, JobExecutionLimit from ..jobs.const import JobCondition, JobExecutionLimit
from ..jobs.decorator import Job from ..jobs.decorator import Job
from ..utils.sentry import capture_exception
LINUX_DATA_PARTITION_GUID: Final = "0FC63DAF-8483-4772-8E79-3D69D8477DE4"
EXTERNAL_DATA_DISK_PARTITION_NAME: Final = "hassos-data-external"
OS_AGENT_MARK_DATA_MOVE_VERSION: Final = AwesomeVersion("1.5.0")
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -35,6 +42,7 @@ class Disk:
size: int size: int
device_path: Path device_path: Path
object_path: str object_path: str
device_object_path: str
@staticmethod @staticmethod
def from_udisks2_drive( def from_udisks2_drive(
@ -49,6 +57,7 @@ class Disk:
size=drive.size, size=drive.size,
device_path=drive_block_device.device, device_path=drive_block_device.device,
object_path=drive.object_path, object_path=drive.object_path,
device_object_path=drive_block_device.object_path,
) )
@property @property
@ -115,6 +124,7 @@ class DataDisk(CoreSysAttributes):
size=0, size=0,
device_path=self.sys_dbus.agent.datadisk.current_device, device_path=self.sys_dbus.agent.datadisk.current_device,
object_path="", object_path="",
device_object_path="",
) )
@property @property
@ -163,24 +173,42 @@ class DataDisk(CoreSysAttributes):
# Force a dbus update first so all info is up to date # Force a dbus update first so all info is up to date
await self.sys_dbus.udisks2.update() await self.sys_dbus.udisks2.update()
try: target_disk: list[Disk] = [
target_disk: Disk = next( disk
disk for disk in self.available_disks
for disk in self.available_disks if disk.id == new_disk or disk.device_path.as_posix() == new_disk
if disk.id == new_disk or disk.device_path.as_posix() == new_disk ]
) if len(target_disk) != 1:
except StopIteration:
raise HassOSDataDiskError( raise HassOSDataDiskError(
f"'{new_disk!s}' not a valid data disk target!", _LOGGER.error f"'{new_disk!s}' not a valid data disk target!", _LOGGER.error
) from None ) from None
# Migrate data on Host # Older OS did not have mark data move API. Must let OS do disk format & migration
try: if self.sys_dbus.agent.version < OS_AGENT_MARK_DATA_MOVE_VERSION:
await self.sys_dbus.agent.datadisk.change_device(target_disk.device_path) try:
except DBusError as err: await self.sys_dbus.agent.datadisk.change_device(
raise HassOSDataDiskError( target_disk[0].device_path
f"Can't move data partition to {new_disk!s}: {err!s}", _LOGGER.error )
) from err except DBusError as err:
raise HassOSDataDiskError(
f"Can't move data partition to {new_disk!s}: {err!s}", _LOGGER.error
) from err
else:
# Format disk then tell OS to migrate next reboot
partition = await self._format_device_with_single_partition(target_disk[0])
if self.disk_used and partition.size < self.disk_used.size:
raise HassOSDataDiskError(
f"Cannot use {new_disk} as data disk as it is smaller then the current one (new: {partition.size}, current: {self.disk_used.size})"
)
try:
await self.sys_dbus.agent.datadisk.mark_data_move()
except DBusError as err:
raise HassOSDataDiskError(
f"Unable to create data disk migration marker: {err!s}",
_LOGGER.error,
) from err
# Restart Host for finish the process # Restart Host for finish the process
try: try:
@ -190,3 +218,50 @@ class DataDisk(CoreSysAttributes):
f"Can't restart device to finish disk migration: {err!s}", f"Can't restart device to finish disk migration: {err!s}",
_LOGGER.warning, _LOGGER.warning,
) from err ) from err
async def _format_device_with_single_partition(
self, new_disk: Disk
) -> UDisks2Block:
"""Format device with a single partition to use as data disk."""
block_device: UDisks2Block = self.sys_dbus.udisks2.get_block_device(
new_disk.device_object_path
)
try:
await block_device.format(FormatType.GPT)
except DBusError as err:
capture_exception(err)
raise HassOSDataDiskError(
f"Could not format {new_disk.id}: {err!s}", _LOGGER.error
) from err
await block_device.check_type()
if not block_device.partition_table:
raise HassOSDataDiskError(
"Block device does not contain a partition table after format, cannot create data partition",
_LOGGER.error,
)
try:
partition = await block_device.partition_table.create_partition(
0, 0, LINUX_DATA_PARTITION_GUID, EXTERNAL_DATA_DISK_PARTITION_NAME
)
except DBusError as err:
capture_exception(err)
raise HassOSDataDiskError(
f"Could not create new data partition: {err!s}"
) from err
try:
partition_block = await UDisks2Block.new(
partition, self.sys_dbus.bus, sync_properties=False
)
except DBusError as err:
raise HassOSDataDiskError(
f"New data partition at {partition} is missing or unusable"
) from err
_LOGGER.debug(
"New data partition prepared on device %s", partition_block.device
)
return partition_block

View File

@ -56,7 +56,7 @@ class DBus:
self = DBus(bus, bus_name, object_path) self = DBus(bus, bus_name, object_path)
# pylint: disable=protected-access # pylint: disable=protected-access
await self._init_proxy() await self.init_proxy()
_LOGGER.debug("Connect to D-Bus: %s - %s", bus_name, object_path) _LOGGER.debug("Connect to D-Bus: %s - %s", bus_name, object_path)
return self return self
@ -115,13 +115,11 @@ class DBus:
for interface in self._proxy_obj.introspection.interfaces for interface in self._proxy_obj.introspection.interfaces
} }
async def _init_proxy(self) -> None: async def introspect(self) -> Node:
"""Read interface data.""" """Return introspection for dbus object."""
introspection: Node | None = None
for _ in range(3): for _ in range(3):
try: try:
introspection = await self._bus.introspect( return await self._bus.introspect(
self.bus_name, self.object_path, timeout=10 self.bus_name, self.object_path, timeout=10
) )
except InvalidIntrospectionError as err: except InvalidIntrospectionError as err:
@ -132,21 +130,41 @@ class DBus:
_LOGGER.warning( _LOGGER.warning(
"Busy system at %s - %s", self.bus_name, self.object_path "Busy system at %s - %s", self.bus_name, self.object_path
) )
else:
break
await asyncio.sleep(3) await asyncio.sleep(3)
if introspection is None: raise DBusFatalError(
raise DBusFatalError( "Could not get introspection data after 3 attempts", _LOGGER.error
"Could not get introspection data after 3 attempts", _LOGGER.error )
)
async def init_proxy(self, *, introspection: Node | None = None) -> None:
"""Read interface data."""
if not introspection:
introspection = await self.introspect()
# If we have a proxy obj store signal monitors and disconnect first
signal_monitors = self._signal_monitors
if self._proxy_obj:
self.disconnect()
self._proxy_obj = self.bus.get_proxy_object( self._proxy_obj = self.bus.get_proxy_object(
self.bus_name, self.object_path, introspection self.bus_name, self.object_path, introspection
) )
self._add_interfaces() self._add_interfaces()
# Reconnect existing signal monitors on new proxy obj if possible (introspection may have changed)
for intr, signals in signal_monitors.items():
for name, callbacks in signals.items():
if intr in self._proxies and hasattr(self._proxies[intr], f"on_{name}"):
for callback in callbacks:
try:
getattr(self._proxies[intr], f"on_{name}")(
callback, unpack_variants=True
)
self._add_signal_monitor(intr, name, callback)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Can't re-add signal listener")
@property @property
def proxies(self) -> dict[str, ProxyInterface]: def proxies(self) -> dict[str, ProxyInterface]:
"""Return all proxies.""" """Return all proxies."""
@ -232,6 +250,19 @@ class DBus:
"""Get signal context manager for this object.""" """Get signal context manager for this object."""
return DBusSignalWrapper(self, signal_member) return DBusSignalWrapper(self, signal_member)
def _add_signal_monitor(
self, interface: str, dbus_name: str, callback: Callable
) -> None:
"""Add a callback to the tracked signal monitors."""
if interface not in self._signal_monitors:
self._signal_monitors[interface] = {}
if dbus_name not in self._signal_monitors[interface]:
self._signal_monitors[interface][dbus_name] = [callback]
else:
self._signal_monitors[interface][dbus_name].append(callback)
def __getattr__(self, name: str) -> DBusCallWrapper: def __getattr__(self, name: str) -> DBusCallWrapper:
"""Map to dbus method.""" """Map to dbus method."""
return getattr(DBusCallWrapper(self, self.bus_name), name) return getattr(DBusCallWrapper(self, self.bus_name), name)
@ -286,17 +317,8 @@ class DBusCallWrapper:
getattr(self._proxy, name)(callback, unpack_variants=True) getattr(self._proxy, name)(callback, unpack_variants=True)
# pylint: disable=protected-access # pylint: disable=protected-access
if self.interface not in self.dbus._signal_monitors: self.dbus._add_signal_monitor(self.interface, dbus_name, callback)
self.dbus._signal_monitors[self.interface] = {} # pylint: enable=protected-access
if dbus_name not in self.dbus._signal_monitors[self.interface]:
self.dbus._signal_monitors[self.interface][dbus_name] = [
callback
]
else:
self.dbus._signal_monitors[self.interface][dbus_name].append(
callback
)
return _on_signal return _on_signal
@ -322,6 +344,7 @@ class DBusCallWrapper:
del self.dbus._signal_monitors[self.interface][dbus_name] del self.dbus._signal_monitors[self.interface][dbus_name]
if not self.dbus._signal_monitors[self.interface]: if not self.dbus._signal_monitors[self.interface]:
del self.dbus._signal_monitors[self.interface] del self.dbus._signal_monitors[self.interface]
# pylint: enable=protected-access
return _off_signal return _off_signal

View File

@ -310,8 +310,6 @@ async def coresys(
): ):
message_bus.return_value.connect = AsyncMock(return_value=dbus_session_bus) message_bus.return_value.connect = AsyncMock(return_value=dbus_session_bus)
await coresys_obj._dbus.load() await coresys_obj._dbus.load()
# coresys_obj._dbus._bus = dbus_session_bus
# coresys_obj._dbus._network = network_manager
# Mock docker # Mock docker
coresys_obj._docker = docker coresys_obj._docker = docker
@ -350,6 +348,7 @@ async def coresys(
with patch("supervisor.store.git.GitRepo._remove"): with patch("supervisor.store.git.GitRepo._remove"):
yield coresys_obj yield coresys_obj
await coresys_obj.dbus.unload()
await coresys_obj.websession.close() await coresys_obj.websession.close()

View File

@ -72,3 +72,19 @@ async def test_dbus_osagent_datadisk_reload_device(
assert await os_agent.datadisk.reload_device() is None assert await os_agent.datadisk.reload_device() is None
assert datadisk_service.ReloadDevice.calls == [tuple()] assert datadisk_service.ReloadDevice.calls == [tuple()]
async def test_dbus_osagent_datadisk_mark_data_move(
datadisk_service: DataDiskService, dbus_session_bus: MessageBus
):
"""Create data disk migration marker for next reboot."""
datadisk_service.MarkDataMove.calls.clear()
os_agent = OSAgent()
with pytest.raises(DBusNotConnectedError):
await os_agent.datadisk.mark_data_move()
await os_agent.connect(dbus_session_bus)
assert await os_agent.datadisk.mark_data_move() is None
assert datadisk_service.MarkDataMove.calls == [tuple()]

View File

@ -142,7 +142,7 @@ async def test_handling_bad_devices(
caplog.clear() caplog.clear()
caplog.set_level(logging.INFO, "supervisor.dbus.network") caplog.set_level(logging.INFO, "supervisor.dbus.network")
with patch.object(DBus, "_init_proxy", side_effect=DBusFatalError()): with patch.object(DBus, "init_proxy", side_effect=DBusFatalError()):
await network_manager.update( await network_manager.update(
{"Devices": ["/org/freedesktop/NetworkManager/Devices/100"]} {"Devices": ["/org/freedesktop/NetworkManager/Devices/100"]}
) )
@ -157,7 +157,7 @@ async def test_handling_bad_devices(
# Unparseable introspections shouldn't happen, this one is logged and captured # Unparseable introspections shouldn't happen, this one is logged and captured
await network_manager.update() await network_manager.update()
with patch.object(DBus, "_init_proxy", side_effect=(err := DBusParseError())): with patch.object(DBus, "init_proxy", side_effect=(err := DBusParseError())):
await network_manager.update( await network_manager.update(
{"Devices": [device := "/org/freedesktop/NetworkManager/Devices/102"]} {"Devices": [device := "/org/freedesktop/NetworkManager/Devices/102"]}
) )
@ -167,7 +167,7 @@ async def test_handling_bad_devices(
# We should be able to debug these situations if necessary # We should be able to debug these situations if necessary
caplog.set_level(logging.DEBUG, "supervisor.dbus.network") caplog.set_level(logging.DEBUG, "supervisor.dbus.network")
await network_manager.update() await network_manager.update()
with patch.object(DBus, "_init_proxy", side_effect=DBusFatalError()): with patch.object(DBus, "init_proxy", side_effect=DBusFatalError()):
await network_manager.update( await network_manager.update(
{"Devices": [device := "/org/freedesktop/NetworkManager/Devices/103"]} {"Devices": [device := "/org/freedesktop/NetworkManager/Devices/103"]}
) )

View File

@ -56,13 +56,12 @@ async def fixture_proxy(
proxy.bus_name = "service.test.TestInterface" proxy.bus_name = "service.test.TestInterface"
proxy.object_path = "/service/test/TestInterface" proxy.object_path = "/service/test/TestInterface"
proxy.properties_interface = "service.test.TestInterface" proxy.properties_interface = "service.test.TestInterface"
proxy.sync_properties = request.param proxy.sync_properties = getattr(request, "param", True)
await proxy.connect(dbus_session_bus) await proxy.connect(dbus_session_bus)
yield proxy yield proxy
@pytest.mark.parametrize("proxy", [True], indirect=True)
async def test_dbus_proxy_connect( async def test_dbus_proxy_connect(
proxy: DBusInterfaceProxy, test_service: TestInterface proxy: DBusInterfaceProxy, test_service: TestInterface
): ):
@ -213,3 +212,21 @@ async def test_initialize(test_service: TestInterface, dbus_session_bus: Message
) )
) )
assert proxy.is_connected is True assert proxy.is_connected is True
async def test_stop_sync_property_changes(
proxy: DBusInterfaceProxy, test_service: TestInterface
):
"""Test stop sync property changes disables the sync via signal."""
assert proxy.is_connected
assert proxy.properties["TestProp"] == 4
test_service.emit_properties_changed({"TestProp": 1})
await test_service.ping()
assert proxy.properties["TestProp"] == 1
proxy.stop_sync_property_changes()
test_service.emit_properties_changed({"TestProp": 4})
await test_service.ping()
assert proxy.properties["TestProp"] == 1

View File

@ -1,6 +1,7 @@
"""Test UDisks2 Block Device interface.""" """Test UDisks2 Block Device interface."""
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from dbus_fast import Variant from dbus_fast import Variant
from dbus_fast.aio.message_bus import MessageBus from dbus_fast.aio.message_bus import MessageBus
@ -9,7 +10,11 @@ import pytest
from supervisor.dbus.udisks2.block import UDisks2Block from supervisor.dbus.udisks2.block import UDisks2Block
from supervisor.dbus.udisks2.const import FormatType, PartitionTableType from supervisor.dbus.udisks2.const import FormatType, PartitionTableType
from supervisor.dbus.udisks2.data import FormatOptions from supervisor.dbus.udisks2.data import FormatOptions
from supervisor.dbus.udisks2.filesystem import UDisks2Filesystem
from supervisor.dbus.udisks2.partition_table import UDisks2PartitionTable
from supervisor.utils.dbus import DBus
from tests.common import mock_dbus_services
from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.base import DBusServiceMock
from tests.dbus_service_mocks.udisks2_block import Block as BlockService from tests.dbus_service_mocks.udisks2_block import Block as BlockService
@ -107,3 +112,92 @@ async def test_format(block_sda_service: BlockService, dbus_session_bus: Message
}, },
) )
] ]
async def test_check_type(dbus_session_bus: MessageBus):
"""Test block device changes types correctly."""
block_services = (
await mock_dbus_services(
{
"udisks2_block": [
"/org/freedesktop/UDisks2/block_devices/sda",
"/org/freedesktop/UDisks2/block_devices/sda1",
]
},
dbus_session_bus,
)
)["udisks2_block"]
sda_block_service = block_services["/org/freedesktop/UDisks2/block_devices/sda"]
sda1_block_service = block_services["/org/freedesktop/UDisks2/block_devices/sda1"]
sda = UDisks2Block("/org/freedesktop/UDisks2/block_devices/sda")
sda1 = UDisks2Block("/org/freedesktop/UDisks2/block_devices/sda1")
await sda.connect(dbus_session_bus)
await sda1.connect(dbus_session_bus)
# Connected but neither are filesystems are partition tables
assert sda.partition_table is None
assert sda1.filesystem is None
assert sda.id_label == ""
assert sda1.id_label == "hassos-data"
# Store current introspection then make sda into a partition table and sda1 into a filesystem
orig_introspection = await sda.dbus.introspect()
services = await mock_dbus_services(
{
"udisks2_partition_table": "/org/freedesktop/UDisks2/block_devices/sda",
"udisks2_filesystem": "/org/freedesktop/UDisks2/block_devices/sda1",
},
dbus_session_bus,
)
sda_pt_service = services["udisks2_partition_table"]
sda1_fs_service = services["udisks2_filesystem"]
await sda.check_type()
await sda1.check_type()
# Check that the type is now correct and property changes are syncing
assert sda.partition_table
assert sda1.filesystem
partition_table: UDisks2PartitionTable = sda.partition_table
filesystem: UDisks2Filesystem = sda1.filesystem
assert partition_table.type == PartitionTableType.GPT
assert filesystem.size == 250058113024
sda_pt_service.emit_properties_changed({"Type": "dos"})
await sda_pt_service.ping()
assert partition_table.type == PartitionTableType.DOS
sda1_fs_service.emit_properties_changed({"Size": 100})
await sda1_fs_service.ping()
assert filesystem.size == 100
# Force introspection to return the original block device only introspection and re-check type
with patch.object(DBus, "introspect", return_value=orig_introspection):
await sda.check_type()
await sda1.check_type()
# Check that it's a connected block device and no longer the other types
assert sda.is_connected is True
assert sda1.is_connected is True
assert sda.partition_table is None
assert sda1.filesystem is None
# Property changes should still sync for the block devices
sda_block_service.emit_properties_changed({"IdLabel": "test"})
await sda_block_service.ping()
assert sda.id_label == "test"
sda1_block_service.emit_properties_changed({"IdLabel": "test"})
await sda1_block_service.ping()
assert sda1.id_label == "test"
# Property changes should stop syncing for the now unused dbus objects
sda_pt_service.emit_properties_changed({"Type": "gpt"})
await sda_pt_service.ping()
assert partition_table.type == PartitionTableType.DOS
sda1_fs_service.emit_properties_changed({"Size": 250058113024})
await sda1_fs_service.ping()
assert filesystem.size == 100

View File

@ -1,5 +1,7 @@
"""Test UDisks2 Manager interface.""" """Test UDisks2 Manager interface."""
from pathlib import Path
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from dbus_fast import Variant from dbus_fast import Variant
from dbus_fast.aio.message_bus import MessageBus from dbus_fast.aio.message_bus import MessageBus
@ -72,6 +74,7 @@ async def test_udisks2_manager_info(
udisks2_manager_service.emit_properties_changed({}, ["SupportedFilesystems"]) udisks2_manager_service.emit_properties_changed({}, ["SupportedFilesystems"])
await udisks2_manager_service.ping() await udisks2_manager_service.ping()
await udisks2_manager_service.ping() await udisks2_manager_service.ping()
await udisks2_manager_service.ping() # Three pings: signal, get all properties and get block devices
assert udisks2.supported_filesystems == [ assert udisks2.supported_filesystems == [
"ext4", "ext4",
"vfat", "vfat",
@ -126,11 +129,11 @@ async def test_resolve_device(
udisks2 = UDisks2() udisks2 = UDisks2()
with pytest.raises(DBusNotConnectedError): with pytest.raises(DBusNotConnectedError):
await udisks2.resolve_device(DeviceSpecification(path="/dev/sda1")) await udisks2.resolve_device(DeviceSpecification(path=Path("/dev/sda1")))
await udisks2.connect(dbus_session_bus) await udisks2.connect(dbus_session_bus)
devices = await udisks2.resolve_device(DeviceSpecification(path="/dev/sda1")) devices = await udisks2.resolve_device(DeviceSpecification(path=Path("/dev/sda1")))
assert len(devices) == 1 assert len(devices) == 1
assert devices[0].id_label == "hassos-data" assert devices[0].id_label == "hassos-data"
assert udisks2_manager_service.ResolveDevice.calls == [ assert udisks2_manager_service.ResolveDevice.calls == [

View File

@ -108,17 +108,17 @@ async def test_create_partition(
await sda.create_partition( await sda.create_partition(
offset=0, offset=0,
size=1000000, size=1000000,
type_=PartitionTableType.DOS, type_="0FC63DAF-8483-4772-8E79-3D69D8477DE4",
name="hassos-data", name="hassos-data",
options=CreatePartitionOptions(partition_type="primary"), options=CreatePartitionOptions(partition_type="primary"),
) )
== "/org/freedesktop/UDisks2/block_devices/sda2" == "/org/freedesktop/UDisks2/block_devices/sda1"
) )
assert partition_table_sda_service.CreatePartition.calls == [ assert partition_table_sda_service.CreatePartition.calls == [
( (
0, 0,
1000000, 1000000,
"dos", "0FC63DAF-8483-4772-8E79-3D69D8477DE4",
"hassos-data", "hassos-data",
{ {
"partition-type": Variant("s", "primary"), "partition-type": Variant("s", "primary"),

View File

@ -38,3 +38,7 @@ class DataDisk(DBusServiceMock):
def ReloadDevice(self) -> "b": def ReloadDevice(self) -> "b":
"""Reload device.""" """Reload device."""
return True return True
@dbus_method()
def MarkDataMove(self) -> None:
"""Do MarkDataMove method."""

View File

@ -57,6 +57,7 @@ class PartitionTable(DBusServiceMock):
""" """
interface = "org.freedesktop.UDisks2.PartitionTable" interface = "org.freedesktop.UDisks2.PartitionTable"
new_partition = "/org/freedesktop/UDisks2/block_devices/sda1"
def __init__(self, object_path: str): def __init__(self, object_path: str):
"""Initialize object.""" """Initialize object."""
@ -79,7 +80,7 @@ class PartitionTable(DBusServiceMock):
self, offset: "t", size: "t", type_: "s", name: "s", options: "a{sv}" self, offset: "t", size: "t", type_: "s", name: "s", options: "a{sv}"
) -> "o": ) -> "o":
"""Do CreatePartition method.""" """Do CreatePartition method."""
return "/org/freedesktop/UDisks2/block_devices/sda2" return self.new_partition
@dbus_method() @dbus_method()
def CreatePartitionAndFormat( def CreatePartitionAndFormat(
@ -93,4 +94,4 @@ class PartitionTable(DBusServiceMock):
format_options: "a{sv}", format_options: "a{sv}",
) -> "o": ) -> "o":
"""Do CreatePartitionAndFormat method.""" """Do CreatePartitionAndFormat method."""
return "/org/freedesktop/UDisks2/block_devices/sda2" return self.new_partition

View File

@ -2,6 +2,7 @@
from pathlib import PosixPath from pathlib import PosixPath
from unittest.mock import patch from unittest.mock import patch
from dbus_fast import Variant
import pytest import pytest
from supervisor.core import Core from supervisor.core import Core
@ -13,6 +14,10 @@ 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.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_partition_table import (
PartitionTable as PartitionTableService,
)
# pylint: disable=protected-access # pylint: disable=protected-access
@ -21,7 +26,7 @@ from tests.dbus_service_mocks.logind import Logind as LogindService
async def add_unusable_drive( async def add_unusable_drive(
coresys: CoreSys, coresys: CoreSys,
udisks2_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], udisks2_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
): ) -> None:
"""Add mock drive with multiple partition tables for negative tests.""" """Add mock drive with multiple partition tables for negative tests."""
await mock_dbus_services( await mock_dbus_services(
{ {
@ -53,6 +58,7 @@ async def tests_datadisk_current(coresys: CoreSys):
size=31268536320, size=31268536320,
device_path=PosixPath("/dev/mmcblk1"), device_path=PosixPath("/dev/mmcblk1"),
object_path="/org/freedesktop/UDisks2/drives/BJTD4R_0x97cde291", object_path="/org/freedesktop/UDisks2/drives/BJTD4R_0x97cde291",
device_object_path="/org/freedesktop/UDisks2/block_devices/mmcblk1",
) )
@ -87,6 +93,7 @@ async def test_datadisk_list(coresys: CoreSys):
size=250059350016, size=250059350016,
device_path=PosixPath("/dev/sda"), device_path=PosixPath("/dev/sda"),
object_path="/org/freedesktop/UDisks2/drives/SSK_SSK_Storage_DF56419883D56", object_path="/org/freedesktop/UDisks2/drives/SSK_SSK_Storage_DF56419883D56",
device_object_path="/org/freedesktop/UDisks2/block_devices/sda",
) )
] ]
@ -114,3 +121,83 @@ async def test_datadisk_migrate(
assert datadisk_service.ChangeDevice.calls == [("/dev/sda",)] assert datadisk_service.ChangeDevice.calls == [("/dev/sda",)]
assert logind_service.Reboot.calls == [(False,)] assert logind_service.Reboot.calls == [(False,)]
@pytest.mark.parametrize(
"new_disk",
["SSK-SSK-Storage-DF56419883D56", "/dev/sda"],
ids=["by drive id", "by device path"],
)
async def test_datadisk_migrate_mark_data_move(
coresys: CoreSys,
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
new_disk: str,
):
"""Test migrating data disk with os agent 1.5.0 or later."""
datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"]
datadisk_service.ChangeDevice.calls.clear()
datadisk_service.MarkDataMove.calls.clear()
block_service: BlockService = all_dbus_services["udisks2_block"][
"/org/freedesktop/UDisks2/block_devices/sda"
]
block_service.Format.calls.clear()
partition_table_service: PartitionTableService = all_dbus_services[
"udisks2_partition_table"
]["/org/freedesktop/UDisks2/block_devices/sda"]
partition_table_service.CreatePartition.calls.clear()
logind_service: LogindService = all_dbus_services["logind"]
logind_service.Reboot.calls.clear()
all_dbus_services["os_agent"].emit_properties_changed({"Version": "1.5.0"})
await all_dbus_services["os_agent"].ping()
coresys.os._available = True
with patch.object(Core, "shutdown") as shutdown:
await coresys.os.datadisk.migrate_disk(new_disk)
shutdown.assert_called_once()
assert datadisk_service.ChangeDevice.calls == []
assert datadisk_service.MarkDataMove.calls == [tuple()]
assert block_service.Format.calls == [
("gpt", {"auth.no_user_interaction": Variant("b", True)})
]
assert partition_table_service.CreatePartition.calls == [
(
0,
0,
"0FC63DAF-8483-4772-8E79-3D69D8477DE4",
"hassos-data-external",
{"auth.no_user_interaction": Variant("b", True)},
)
]
assert logind_service.Reboot.calls == [(False,)]
async def test_datadisk_migrate_too_small(
coresys: CoreSys,
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
):
"""Test migration stops and exits if new partition is too small."""
datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"]
datadisk_service.MarkDataMove.calls.clear()
logind_service: LogindService = all_dbus_services["logind"]
logind_service.Reboot.calls.clear()
partition_table_service: PartitionTableService = all_dbus_services[
"udisks2_partition_table"
]["/org/freedesktop/UDisks2/block_devices/sda"]
partition_table_service.CreatePartition.calls.clear()
partition_table_service.new_partition = (
"/org/freedesktop/UDisks2/block_devices/mmcblk1p3"
)
all_dbus_services["os_agent"].emit_properties_changed({"Version": "1.5.0"})
await all_dbus_services["os_agent"].ping()
coresys.os._available = True
with pytest.raises(HassOSDataDiskError):
await coresys.os.datadisk.migrate_disk("SSK-SSK-Storage-DF56419883D56")
assert partition_table_service.CreatePartition.calls
assert datadisk_service.MarkDataMove.calls == []
assert logind_service.Reboot.calls == []

View File

@ -3,7 +3,7 @@
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from dbus_fast.aio.message_bus import MessageBus from dbus_fast.aio.message_bus import MessageBus
from dbus_fast.service import method from dbus_fast.service import method, signal
import pytest import pytest
from supervisor.dbus.const import DBUS_OBJECT_BASE from supervisor.dbus.const import DBUS_OBJECT_BASE
@ -24,6 +24,10 @@ class TestInterface(DBusServiceMock):
def test(self, _: "b") -> None: # noqa: F821 def test(self, _: "b") -> None: # noqa: F821
"""Do Test method.""" """Do Test method."""
@signal(name="Test")
def signal_test(self) -> None:
"""Signal Test."""
@pytest.fixture(name="test_service") @pytest.fixture(name="test_service")
async def fixture_test_service(dbus_session_bus: MessageBus) -> TestInterface: async def fixture_test_service(dbus_session_bus: MessageBus) -> TestInterface:
@ -74,3 +78,96 @@ async def test_internal_dbus_errors(
await test_obj.call_test(True) await test_obj.call_test(True)
capture_exception.assert_called_once_with(err) capture_exception.assert_called_once_with(err)
async def test_introspect(test_service: TestInterface, dbus_session_bus: MessageBus):
"""Test introspect of dbus object."""
test_obj = DBus(dbus_session_bus, "service.test.TestInterface", DBUS_OBJECT_BASE)
introspection = await test_obj.introspect()
assert {"service.test.TestInterface", "org.freedesktop.DBus.Properties"} <= {
interface.name for interface in introspection.interfaces
}
test_interface = next(
interface
for interface in introspection.interfaces
if interface.name == "service.test.TestInterface"
)
assert "Test" in {method_.name for method_ in test_interface.methods}
async def test_init_proxy(test_service: TestInterface, dbus_session_bus: MessageBus):
"""Test init proxy on already connected object to update interfaces."""
test_obj = await DBus.connect(
dbus_session_bus, "service.test.TestInterface", DBUS_OBJECT_BASE
)
orig_introspection = await test_obj.introspect()
callback_count = 0
def test_callback():
nonlocal callback_count
callback_count += 1
class TestInterface2(TestInterface):
"""Test interface 2."""
interface = "service.test.TestInterface.Test2"
object_path = DBUS_OBJECT_BASE
# Test interfaces and methods match expected
assert "service.test.TestInterface" in test_obj.proxies
assert await test_obj.call_test(True) is None
assert "service.test.TestInterface.Test2" not in test_obj.proxies
# Test basic signal listening works
test_obj.on_test(test_callback)
test_service.signal_test()
await test_service.ping()
assert callback_count == 1
callback_count = 0
# Export the second interface and re-create proxy
test_service_2 = TestInterface2()
test_service_2.export(dbus_session_bus)
await test_obj.init_proxy()
# Test interfaces and methods match expected
assert "service.test.TestInterface" in test_obj.proxies
assert await test_obj.call_test(True) is None
assert "service.test.TestInterface.Test2" in test_obj.proxies
assert await test_obj.Test2.call_test(True) is None
# Test signal listening. First listener should still be attached
test_obj.Test2.on_test(test_callback)
test_service_2.signal_test()
await test_service_2.ping()
assert callback_count == 1
test_service.signal_test()
await test_service.ping()
assert callback_count == 2
callback_count = 0
# Return to original introspection and test interfaces have reset
await test_obj.init_proxy(introspection=orig_introspection)
assert "service.test.TestInterface" in test_obj.proxies
assert "service.test.TestInterface.Test2" not in test_obj.proxies
# Signal listener for second interface should disconnect, first remains
test_service_2.signal_test()
await test_service_2.ping()
assert callback_count == 0
test_service.signal_test()
await test_service.ping()
assert callback_count == 1
callback_count = 0
# Should be able to disconnect first signal listener on new proxy obj
test_obj.off_test(test_callback)
test_service.signal_test()
await test_service.ping()
assert callback_count == 0