diff --git a/doc/example.xml b/doc/example.xml index c55b952b..6d63723f 100644 --- a/doc/example.xml +++ b/doc/example.xml @@ -22,7 +22,7 @@ - 01:23.45 + diff --git a/qubes/devices.py b/qubes/devices.py index a10bf1c2..94ce5e7f 100644 --- a/qubes/devices.py +++ b/qubes/devices.py @@ -23,8 +23,30 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # -import re +'''API for various types of devices. +Main concept is that some domain main +expose (potentially multiple) devices, which can be attached to other domains. +Devices can be of different classes (like 'pci', 'usb', etc). Each device +class is implemented by an extension. + +Devices are identified by pair of (backend domain, `ident`), where `ident` is +:py:class:`str`. + +Such extension should provide: + - `qubes.devices` endpoint - a class descendant from + :py:class:`qubes.devices.DeviceInfo`, designed to hold device description ( + including class-specific properties) + - handle `device-attach:class` and `device-detach:class` events for + performing the attach/detach action; events are fired even when domain isn't + running and extension should be prepared for this + - handle `device-list:class` event - list devices exposed by particular + domain; it should return list of appropriate DeviceInfo objects + - handle `device-get:class` event - get one device object exposed by this + domain of given identifier + - handle `device-list-attached:class` event - list currently attached + devices to this domain +''' import qubes.utils @@ -35,6 +57,52 @@ class DeviceCollection(object): :param vm: VM for which we manage devices :param class_: device class + + This class emits following events on VM object: + + .. event:: device-attach: (device) + + Fired when device is attached to a VM. + + :param device: :py:class:`DeviceInfo` object to be attached + + .. event:: device-pre-attach: (device) + + Fired before device is attached to a VM + + :param device: :py:class:`DeviceInfo` object to be attached + + .. event:: device-detach: (device) + + Fired when device is detached from a VM. + + :param device: :py:class:`DeviceInfo` object to be attached + + .. event:: device-pre-detach: (device) + + Fired before device is detached from a VM + + :param device: :py:class:`DeviceInfo` object to be attached + + .. event:: device-list: + + Fired to get list of devices exposed by a VM. Handlers of this + event should return a list of py:class:`DeviceInfo` objects (or + appropriate class specific descendant) + + .. event:: device-get: (ident) + + Fired to get a single device, given by the `ident` parameter. + Handlers of this event should either return appropriate object of + :py:class:`DeviceInfo`, or :py:obj:`None`. Especially should not + raise :py:class:`exceptions.KeyError`. + + .. event:: device-list-attached: (persistent) + + Fired to get list of currently attached devices to a VM. Handlers + of this event should return list of devices actually attached to + a domain, regardless of its settings. + ''' def __init__(self, vm, class_): @@ -42,23 +110,16 @@ class DeviceCollection(object): self._class = class_ self._set = set() + self.devclass = qubes.utils.get_entry_point_one( + 'qubes.devices', self._class) def attach(self, device): '''Attach (add) device to domain. - :param str device: device identifier (format is class-dependent) + :param DeviceInfo device: device object ''' - try: - devclass = qubes.utils.get_entry_point_one( - 'qubes.devices', self._class) - except KeyError: - devclass = str - - if not isinstance(device, devclass): - device = devclass(device) - - if device in self: + if device in self.attached(): raise KeyError( 'device {!r} of class {} already attached to {!r}'.format( device, self._class, self._vm)) @@ -70,10 +131,10 @@ class DeviceCollection(object): def detach(self, device): '''Detach (remove) device from domain. - :param str device: device identifier (format is class-dependent) + :param DeviceInfo device: device object ''' - if device not in self: + if device not in self.attached(): raise KeyError( 'device {!r} of class {} not attached to {!r}'.format( device, self._class, self._vm)) @@ -81,17 +142,73 @@ class DeviceCollection(object): self._set.remove(device) self._vm.fire_event('device-detach:' + self._class, device) + def attached(self, persistent=None): + '''List devices which are (or may be) attached to this vm + + Devices may be attached persistently (so they are included in + :file:`qubes.xml`) or not. Device can also be in :file:`qubes.xml`, + but be temporarily detached. + + :param bool persistent: only include devices which are (or are not) \ + attached persistently - None means both + ''' + seen = self._set.copy() + + # ask for really attached devices only when requested not only + # persistent ones + if persistent is not True: + attached = self._vm.fire_event( + 'device-list-attached:' + self._class, + persistent=persistent) + for device in attached: + device_persistent = device in self._set + if persistent is not None and device_persistent != persistent: + continue + assert device.frontend_domain == self._vm, \ + '{!r} != {!r}'.format(device.frontend_domain, self._vm) + + yield device + + try: + seen.remove(device) + except KeyError: + pass + + if persistent is False: + return + + for device in seen: + # get fresh object - may contain updated information + device = device.backend_domain.devices[self._class][device.ident] + yield device + + def available(self): + '''List devices exposed by this vm''' + devices = self._vm.fire_event('device-list:' + self._class) + return devices def __iter__(self): - return iter(self._set) + return iter(self.available()) + def __getitem__(self, ident): + '''Get device object with given ident. - def __contains__(self, item): - return item in self._set + :returns: py:class:`DeviceInfo` + If domain isn't running, it is impossible to check device validity, + so return UnknownDevice object. Also do the same for non-existing + devices - otherwise it will be impossible to detach already + disconnected device. - def __len__(self): - return len(self._set) + :raises AssertionError: when multiple devices with the same ident are + found + ''' + dev = self._vm.fire_event('device-get:' + self._class, ident) + if dev: + assert len(dev) == 1 + return dev[0] + else: + return UnknownDevice(self._vm, ident) class DeviceManager(dict): @@ -109,30 +226,50 @@ class DeviceManager(dict): return self[key] -class RegexDevice(str): - regex = None - def __init__(self, *args, **kwargs): - super(RegexDevice, self).__init__(*args, **kwargs) +class DeviceInfo(object): + # pylint: disable=too-few-public-methods + def __init__(self, backend_domain, ident, description=None, + frontend_domain=None, **kwargs): + #: domain providing this device + self.backend_domain = backend_domain + #: device identifier (unique for given domain and device type) + self.ident = ident + #: human readable description/name of the device + self.description = description + #: (running) domain to which device is currently attached + self.frontend_domain = frontend_domain + self.data = kwargs - if self.regex is None: - raise NotImplementedError( - 'You should overload .regex attribute in subclass') + if hasattr(self, 'regex'): + # pylint: disable=no-member + dev_match = self.regex.match(ident) + if not dev_match: + raise ValueError('Invalid device identifier: {!r}'.format( + ident)) - dev_match = self.regex.match(self) - if not dev_match: - raise ValueError('Invalid device identifier: {!r}'.format(self)) + for group in self.regex.groupindex: + setattr(self, group, dev_match.group(group)) - for group in self.regex.groupindex: - setattr(self, group, dev_match.group(group)) + def __hash__(self): + return hash(self.ident) + + def __eq__(self, other): + return ( + self.backend_domain == other.backend_domain and + self.ident == other.ident + ) -class PCIDevice(RegexDevice): - regex = re.compile( - r'^(?P[0-9a-f]+):(?P[0-9a-f]+)\.(?P[0-9a-f]+)$') +class UnknownDevice(DeviceInfo): + # pylint: disable=too-few-public-methods + '''Unknown device - for example exposed by domain not running currently''' - @property - def libvirt_name(self): - return 'pci_0000_{}_{}_{}'.format(self.bus, self.device, self.function) + def __init__(self, backend_domain, ident, description=None, + frontend_domain=None, **kwargs): + if description is None: + description = "Unknown device" + super(UnknownDevice, self).__init__(backend_domain, ident, description, + frontend_domain, **kwargs) class BlockDevice(object): diff --git a/qubes/events.py b/qubes/events.py index be7d1309..f3381fe0 100644 --- a/qubes/events.py +++ b/qubes/events.py @@ -129,7 +129,7 @@ class Emitter(object): ''' if not self.events_enabled: - return + return [] effects = [] for cls in order: diff --git a/qubes/tests/__init__.py b/qubes/tests/__init__.py index 14667878..825c0750 100644 --- a/qubes/tests/__init__.py +++ b/qubes/tests/__init__.py @@ -135,12 +135,15 @@ class TestEmitter(qubes.events.Emitter): self.fired_events = collections.Counter() def fire_event(self, event, *args, **kwargs): - super(TestEmitter, self).fire_event(event, *args, **kwargs) + effects = super(TestEmitter, self).fire_event(event, *args, **kwargs) self.fired_events[(event, args, tuple(sorted(kwargs.items())))] += 1 + return effects def fire_event_pre(self, event, *args, **kwargs): - super(TestEmitter, self).fire_event_pre(event, *args, **kwargs) + effects = super(TestEmitter, self).fire_event_pre(event, *args, + **kwargs) self.fired_events[(event, args, tuple(sorted(kwargs.items())))] += 1 + return effects def expectedFailureIfTemplate(templates): """ diff --git a/qubes/tests/devices.py b/qubes/tests/devices.py index ebd4332b..82dd71a8 100644 --- a/qubes/tests/devices.py +++ b/qubes/tests/devices.py @@ -27,24 +27,68 @@ import qubes.devices import qubes.tests +class TestDevice(qubes.devices.DeviceInfo): + pass + + +class TestVMCollection(dict): + def __iter__(self): + return iter(set(self.values())) + + +class TestApp(object): + def __init__(self): + self.domains = TestVMCollection() + + +class TestVM(qubes.tests.TestEmitter): + def __init__(self, app, name, *args, **kwargs): + super(TestVM, self).__init__(*args, **kwargs) + self.app = app + self.name = name + self.device = TestDevice(self, 'testdev', 'Description') + self.events_enabled = True + self.devices = { + 'testclass': qubes.devices.DeviceCollection(self, 'testclass') + } + self.app.domains[name] = self + self.app.domains[self] = self + + def __str__(self): + return self.name + + @qubes.events.handler('device-list-attached:testclass') + def dev_testclass_list_attached(self, event, persistent): + for vm in self.app.domains: + if vm.device.frontend_domain == self: + yield vm.device + + @qubes.events.handler('device-list:testclass') + def dev_testclass_list(self, event): + yield self.device + + class TC_00_DeviceCollection(qubes.tests.QubesTestCase): def setUp(self): - self.emitter = qubes.tests.TestEmitter() - self.collection = qubes.devices.DeviceCollection(self.emitter, 'testclass') + self.app = TestApp() + self.emitter = TestVM(self.app, 'vm') + self.app.domains['vm'] = self.emitter + self.device = self.emitter.device + self.collection = self.emitter.devices['testclass'] def test_000_init(self): self.assertFalse(self.collection._set) def test_001_attach(self): - self.collection.attach('testdev') + self.collection.attach(self.device) self.assertEventFired(self.emitter, 'device-pre-attach:testclass') self.assertEventFired(self.emitter, 'device-attach:testclass') self.assertEventNotFired(self.emitter, 'device-pre-detach:testclass') self.assertEventNotFired(self.emitter, 'device-detach:testclass') def test_002_detach(self): - self.collection.attach('testdev') - self.collection.detach('testdev') + self.collection.attach(self.device) + self.collection.detach(self.device) self.assertEventFired(self.emitter, 'device-pre-attach:testclass') self.assertEventFired(self.emitter, 'device-attach:testclass') self.assertEventFired(self.emitter, 'device-pre-detach:testclass') @@ -52,31 +96,33 @@ class TC_00_DeviceCollection(qubes.tests.QubesTestCase): def test_010_empty_detach(self): with self.assertRaises(LookupError): - self.collection.detach('testdev') + self.collection.detach(self.device) def test_011_double_attach(self): - self.collection.attach('testdev') + self.collection.attach(self.device) with self.assertRaises(LookupError): - self.collection.attach('testdev') + self.collection.attach(self.device) def test_012_double_detach(self): - self.collection.attach('testdev') - self.collection.detach('testdev') + self.collection.attach(self.device) + self.collection.detach(self.device) with self.assertRaises(LookupError): - self.collection.detach('testdev') + self.collection.detach(self.device) class TC_01_DeviceManager(qubes.tests.QubesTestCase): def setUp(self): - self.emitter = qubes.tests.TestEmitter() + self.app = TestApp() + self.emitter = TestVM(self.app, 'vm') self.manager = qubes.devices.DeviceManager(self.emitter) def test_000_init(self): self.assertEqual(self.manager, {}) def test_001_missing(self): - self.manager['testclass'].attach('testdev') + device = TestDevice(self.emitter.app.domains['vm'], 'testdev') + self.manager['testclass'].attach(device) self.assertEventFired(self.emitter, 'device-attach:testclass') diff --git a/qubes/tests/vm/init.py b/qubes/tests/vm/init.py index 05185f36..ac467759 100644 --- a/qubes/tests/vm/init.py +++ b/qubes/tests/vm/init.py @@ -31,6 +31,11 @@ import qubes.vm import qubes.tests +class TestApp(object): + def __init__(self): + super(TestApp, self).__init__() + self.domains = {} + class TestVM(qubes.vm.BaseVM): qid = qubes.property('qid', type=int) @@ -66,7 +71,7 @@ class TC_10_BaseVM(qubes.tests.QubesTestCase): - 00:11.22 + @@ -81,7 +86,8 @@ class TC_10_BaseVM(qubes.tests.QubesTestCase): def test_000_load(self): node = self.xml.xpath('//domain')[0] - vm = TestVM(None, node) + vm = TestVM(TestApp(), node) + vm.app.domains['domain1'] = vm vm.load_properties(load_stage=None) vm.load_extras() @@ -97,7 +103,8 @@ class TC_10_BaseVM(qubes.tests.QubesTestCase): }) self.assertItemsEqual(vm.devices.keys(), ('pci',)) - self.assertItemsEqual(vm.devices['pci'], ('00:11.22',)) + self.assertItemsEqual(list(vm.devices['pci'].attached(persistent=True)), + [qubes.devices.PCIDevice(vm, '00:11.22')]) self.assertXMLIsValid(vm.__xml__(), 'domain.rng') diff --git a/qubes/vm/__init__.py b/qubes/vm/__init__.py index f07512af..de167770 100644 --- a/qubes/vm/__init__.py +++ b/qubes/vm/__init__.py @@ -198,7 +198,11 @@ class BaseVM(qubes.PropertyHolder): for parent in self.xml.xpath('./devices'): devclass = parent.get('class') for node in parent.xpath('./device'): - self.devices[devclass].attach(node.text) + device = self.devices[devclass].devclass( + self.app.domains[node.get('backend-domain')], + node.get('id') + ) + self.devices[devclass].attach(device) # tags for node in self.xml.xpath('./tags/tag'): @@ -227,9 +231,10 @@ class BaseVM(qubes.PropertyHolder): for devclass in self.devices: devices = lxml.etree.Element('devices') devices.set('class', devclass) - for device in self.devices[devclass]: + for device in self.devices[devclass].attached(persistent=True): node = lxml.etree.Element('device') - node.text = device + node.set('backend-domain', device.backend_domain.name) + node.set('id', device.ident) devices.append(node) element.append(devices) diff --git a/qubes/vm/qubesvm.py b/qubes/vm/qubesvm.py index c28f20e6..646d4a81 100644 --- a/qubes/vm/qubesvm.py +++ b/qubes/vm/qubesvm.py @@ -243,7 +243,7 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): # CORE2: swallowed uses_default_kernelopts kernelopts = qubes.property('kernelopts', type=str, load_stage=4, default=(lambda self: qubes.config.defaults['kernelopts_pcidevs'] - if len(self.devices['pci']) > 0 + if list(self.devices['pci'].attached()) else self.template.kernelopts if hasattr(self, 'template') else qubes.config.defaults['kernelopts']), ls_width=30, @@ -476,6 +476,9 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): self.events_enabled = True self.fire_event('domain-init') + def __hash__(self): + return self.qid + def __xml__(self): element = super(QubesVM, self).__xml__() @@ -700,7 +703,7 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): qmemman_client = self.request_memory(mem_required) # Bind pci devices to pciback driver - for pci in self.devices['pci']: + for pci in self.devices['pci'].attached(): self.bind_pci_to_pciback(pci) self.libvirt_domain.createWithFlags(libvirt.VIR_DOMAIN_START_PAUSED) @@ -802,7 +805,7 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM): if not self.is_running() and not self.is_paused(): raise qubes.exc.QubesVMNotRunningError(self) - if len(self.devices['pci']) > 0: + if list(self.devices['pci'].attached()): raise qubes.exc.QubesNotImplementedError( 'Cannot suspend domain {!r} which has PCI devices attached' .format(self.name)) diff --git a/relaxng/qubes.rng b/relaxng/qubes.rng index 667e263b..7197aa28 100644 --- a/relaxng/qubes.rng +++ b/relaxng/qubes.rng @@ -206,14 +206,24 @@ the parser will complain about missing combine= attribute on the second . - One device. This tag should contain some - identifier, format of which depends on - particular device class. + One device. It's identified by by a pair of + backend domain and some identifier (device class + dependant). - - - [0-9a-f]{2}:[0-9a-f]{2}.[0-9a-f]{2} - + + + Backend domain name. + + + [a-z0-9_]+ + + + + + + [0-9a-f]{2}:[0-9a-f]{2}.[0-9a-f]{2} + + diff --git a/setup.py b/setup.py index 95a3065f..12819997 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ if __name__ == '__main__': ], 'qubes.devices': [ 'pci = qubes.devices:PCIDevice', + 'testclass = qubes.tests.devices:TestDevice', ], 'qubes.storage': [ 'file = qubes.storage.file:FilePool', diff --git a/templates/libvirt/xen.xml b/templates/libvirt/xen.xml index eeae75c3..18147cc6 100644 --- a/templates/libvirt/xen.xml +++ b/templates/libvirt/xen.xml @@ -27,7 +27,8 @@ {% endif %} - {% if vm.devices['pci'] and vm.features.get('pci-e820-host', True) %} + {% if vm.devices['pci'].attached() | list + and vm.features.get('pci-e820-host', True) %} @@ -90,10 +91,10 @@ {% endfor %} {% if vm.netvm %} - {% include 'libvirt/devices/net.xml' %} + {% include 'libvirt/devices/net.xml' with context %} {% endif %} - {% for device in vm.devices.pci %} + {% for device in vm.devices.pci.attached() %} {% include 'libvirt/devices/pci.xml' %} {% endfor %}