diff --git a/supervisor/bus.py b/supervisor/bus.py index 794d188e1..2a0b8c1a2 100644 --- a/supervisor/bus.py +++ b/supervisor/bus.py @@ -12,6 +12,14 @@ from .coresys import CoreSys, CoreSysAttributes _LOGGER: logging.Logger = logging.getLogger(__name__) +@attr.s(slots=True, frozen=True) +class EventListener: + """Event listener.""" + + event_type: BusEvent = attr.ib() + callback: Callable[[Any], Awaitable[None]] = attr.ib() + + class Bus(CoreSysAttributes): """Handle Bus event system.""" @@ -34,10 +42,9 @@ class Bus(CoreSysAttributes): for listener in self._listeners.get(event, []): self.sys_create_task(listener.callback(reference)) - -@attr.s(slots=True, frozen=True) -class EventListener: - """Event listener.""" - - event_type: BusEvent = attr.ib() - callback: Callable[[Any], Awaitable[None]] = attr.ib() + def remove_listener(self, listener: EventListener) -> None: + """Unregister an listener.""" + try: + self._listeners[listener.event_type].remove(listener) + except (ValueError, KeyError): + _LOGGER.warning("Listener %s not registered", listener) diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 1ec06b2e3..273f9c67a 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -13,6 +13,7 @@ import docker import requests from ..addons.build import AddonBuild +from ..bus import EventListener from ..const import ( DOCKER_CPU_RUNTIME_ALLOCATION, ENV_TIME, @@ -28,10 +29,19 @@ from ..const import ( SECURITY_PROFILE, SYSTEMD_JOURNAL_PERSISTENT, SYSTEMD_JOURNAL_VOLATILE, + BusEvent, ) from ..coresys import CoreSys -from ..exceptions import CoreDNSError, DockerError, DockerNotFound, HardwareNotFound +from ..exceptions import ( + CoreDNSError, + DBusError, + DockerError, + DockerNotFound, + HardwareNotFound, +) from ..hardware.const import PolicyGroup +from ..hardware.data import Device +from ..jobs.decorator import Job, JobCondition from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils import process_lock from .const import Capabilities @@ -52,7 +62,9 @@ class DockerAddon(DockerInterface): def __init__(self, coresys: CoreSys, addon: Addon): """Initialize Docker Home Assistant wrapper.""" super().__init__(coresys) - self.addon = addon + self.addon: Addon = addon + + self._hw_listener: EventListener | None = None @property def image(self) -> str | None: @@ -495,6 +507,12 @@ class DockerAddon(DockerInterface): _LOGGER.warning("Can't update DNS for %s", self.name) self.sys_capture_exception(err) + # Hardware Access + if self.addon.static_devices: + self._hw_listener = self.sys_bus.register_event( + BusEvent.HARDWARE_NEW_DEVICE, self._hardware_events + ) + def _install( self, version: AwesomeVersion, image: str | None = None, latest: bool = False ) -> None: @@ -636,15 +654,55 @@ class DockerAddon(DockerInterface): Need run inside executor. """ + # DNS if self.ip_address != NO_ADDDRESS: try: self.sys_plugins.dns.delete_host(self.addon.hostname) except CoreDNSError as err: _LOGGER.warning("Can't update DNS for %s", self.name) self.sys_capture_exception(err) + + # Hardware + if self._hw_listener: + self.sys_bus.remove_listener(self._hw_listener) + self._hw_listener = None + super()._stop(remove_container) def _validate_trust( self, image_id: str, image: str, version: AwesomeVersion ) -> None: """Validate trust of content.""" + + @Job(conditions=[JobCondition.OS_AGENT]) + async def _hardware_events(self, device: Device) -> None: + """Process Hardware events for adjust device access.""" + if not any( + device_path in (device.path, device.sysfs) + for device_path in self.addon.static_devices + ): + return + + try: + docker_container = self.sys_docker.containers.get(self.name) + except docker.errors.NotFound: + self.sys_bus.remove_listener(self._hw_listener) + self._hw_listener = None + return + except (docker.errors.DockerException, requests.RequestException) as err: + raise DockerError( + f"Can't process Hardware Event on {self.name}: {err!s}", _LOGGER.error + ) from err + + try: + await self.sys_dbus.agent.cgroup.add_devices_allowed( + docker_container.id, self.sys_hardware.policy.get_cgroups_rule(device) + ) + _LOGGER.info( + "Added cgroup permissions for device %s to %s", device.path, self.name + ) + except DBusError as err: + raise DockerError( + f"Can't set cgroup permission on the host for {self.name}", + _LOGGER.error, + ) from err diff --git a/tests/test_bus.py b/tests/test_bus.py index b9ae6df5c..3a8ae5080 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -42,3 +42,29 @@ async def test_bus_event_not_called(coresys: CoreSys) -> None: coresys.bus.fire_event(BusEvent.HARDWARE_REMOVE_DEVICE, None) await asyncio.sleep(0) assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_bus_event_removed(coresys: CoreSys) -> None: + """Test bus events over the backend and remove.""" + results = [] + + async def callback(data) -> None: + """Test callback.""" + results.append(data) + + listener = coresys.bus.register_event(BusEvent.HARDWARE_NEW_DEVICE, callback) + + coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, None) + await asyncio.sleep(0) + assert results[-1] is None + + coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, "test") + await asyncio.sleep(0) + assert results[-1] == "test" + + coresys.bus.remove_listener(listener) + + coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, None) + await asyncio.sleep(0) + assert results[-1] == "test"