Skip to content

Commit

Permalink
api: cache system info
Browse files Browse the repository at this point in the history
Collecting the information used for qrexec policy evaluation can be
expensive, but it doesn't change that often. Cache it inside qubesd
daemon, and invalidate the cache based on events related to any of the
included information.

Fixes QubesOS/qubes-issues#9362
  • Loading branch information
marmarek committed Feb 4, 2025
1 parent ece1603 commit 0f3d7fa
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 52 deletions.
129 changes: 103 additions & 26 deletions qubes/api/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,33 +30,110 @@
import qubes.vm.dispvm


def get_system_info(app):
system_info = {
"domains": {
domain.name: {
"tags": list(domain.tags),
"type": domain.__class__.__name__,
"template_for_dispvms": getattr(
domain, "template_for_dispvms", False
),
"default_dispvm": (
domain.default_dispvm.name
if getattr(domain, "default_dispvm", None)
else None
),
"icon": str(domain.label.icon),
"guivm": (
domain.guivm.name
if getattr(domain, "guivm", None)
else None
),
"power_state": domain.get_power_state(),
"uuid": str(domain.uuid),
class SystemInfoCache:
cache = None
cache_for_app = None

# list of VM events that may affect the content of system_info
vm_events = (
"domain-spawn",
"domain-start",
"domain-shutdown",
"domain-tag-add:*",
"domain-tag-delete:*",
"property-set:template_for_dispvms",
"property-reset:template_for_dispvms",
"property-set:default_dispvm",
"property-reset:default_dispvm",
"property-set:icon",
"property-reset:icon",
"property-set:guivm",
"property-reset:guivm",
# technically not changeable, but keep for consistency
"property-set:uuid",
"property-reset:uuid",
)

@classmethod
def event_handler(cls, subject, event, **kwargs):
"""Invalidate cache on specific events"""
# pylint: disable=unused-argument
cls.cache = None

@classmethod
def on_domain_add(cls, subject, event, vm):
# pylint: disable=unused-argument
cls.register_events_vm(vm)

@classmethod
def on_domain_delete(cls, subject, event, vm):
# pylint: disable=unused-argument
cls.unregister_events_vm(vm)

@classmethod
def register_events_vm(cls, vm):
for event in cls.vm_events:
vm.add_handler(event, cls.event_handler)

@classmethod
def unregister_events_vm(cls, vm):
for event in cls.vm_events:
vm.remove_handler(event, cls.event_handler)

@classmethod
def unregister_events(cls, app):
app.remove_handler("domain-add", cls.on_domain_add)
app.remove_handler("domain-delete", cls.on_domain_delete)
for vm in app.domains:
cls.unregister_events_vm(vm)

@classmethod
def register_events(cls, app):
app.add_handler("domain-add", cls.on_domain_add)
app.add_handler("domain-delete", cls.on_domain_delete)
for vm in app.domains:
cls.register_events_vm(vm)

@classmethod
def get_system_info(cls, app):
if cls.cache_for_app is not app:
# new Qubes() instance created, invalidate the cache
# this is relevant mostly for tests, otherwise Qubes() object is
# never re-created
cls.cache = None
if cls.cache_for_app is not None:
cls.unregister_events(cls.cache_for_app)
cls.register_events(app)
if cls.cache is not None:
return cls.cache
system_info = {
"domains": {
domain.name: {
"tags": list(domain.tags),
"type": domain.__class__.__name__,
"template_for_dispvms": getattr(
domain, "template_for_dispvms", False
),
"default_dispvm": (
domain.default_dispvm.name
if getattr(domain, "default_dispvm", None)
else None
),
"icon": str(domain.label.icon),
"guivm": (
domain.guivm.name
if getattr(domain, "guivm", None)
else None
),
"power_state": domain.get_power_state(),
"uuid": str(domain.uuid),
}
for domain in app.domains
}
for domain in app.domains
}
}
return system_info
cls.cache_for_app = app
cls.cache = system_info
return system_info


class QubesInternalAPI(qubes.api.AbstractQubesAPI):
Expand All @@ -70,7 +147,7 @@ async def getsysteminfo(self):
self.enforce(self.dest.name == "dom0")
self.enforce(not self.arg)

system_info = get_system_info(self.app)
system_info = SystemInfoCache.get_system_info(self.app)

return json.dumps(system_info)

Expand Down
6 changes: 4 additions & 2 deletions qubes/ext/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def admin_vm_list(self, vm, event, arg, **kwargs):
return ((lambda _vm: False),)

policy = self.policy_cache.get_policy()
system_info = qubes.api.internal.get_system_info(vm.app)
system_info = qubes.api.internal.SystemInfoCache.get_system_info(vm.app)

def filter_vms(dest_vm):
request = parser.Request(
Expand Down Expand Up @@ -133,7 +133,9 @@ def filter_events(event):

policy = self.policy_cache.get_policy()
# TODO: cache system_info (based on last qubes.xml write time?)
system_info = qubes.api.internal.get_system_info(vm.app)
system_info = qubes.api.internal.SystemInfoCache.get_system_info(
vm.app
)
request = parser.Request(
"admin.Events",
"+" + event.replace(":", "_"),
Expand Down
3 changes: 3 additions & 0 deletions qubes/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,9 @@ def kernel_validator_patched(obj, key, value):
self.skip_kernel_validation_patch.start()

def cleanup_gc(self):
# remove cached references to Qubes() object
qubes.api.internal.SystemInfoCache.cache_for_app = None

gc.collect()
leaked = [
obj
Expand Down
66 changes: 42 additions & 24 deletions qubes/tests/api_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,31 +185,49 @@ def test_010_get_system_info(self):
vm.uuid = TEST_UUID
self.domains["vm"] = vm

expected_data = {
"domains": {
"dom0": {
"tags": ["tag1", "tag2"],
"type": "AdminVM",
"default_dispvm": None,
"template_for_dispvms": False,
"icon": "icon-dom0",
"guivm": None,
"power_state": "Running",
"uuid": "00000000-0000-0000-0000-000000000000",
},
"vm": {
"tags": ["tag3", "tag4"],
"type": "QubesVM",
"default_dispvm": "vm",
"template_for_dispvms": True,
"icon": "icon-vm",
"guivm": "vm",
"power_state": "Halted",
"uuid": str(TEST_UUID),
},
}
}
ret = json.loads(self.call_mgmt_func(b"internal.GetSystemInfo"))
self.assertEqual(
ret,
{
"domains": {
"dom0": {
"tags": ["tag1", "tag2"],
"type": "AdminVM",
"default_dispvm": None,
"template_for_dispvms": False,
"icon": "icon-dom0",
"guivm": None,
"power_state": "Running",
"uuid": "00000000-0000-0000-0000-000000000000",
},
"vm": {
"tags": ["tag3", "tag4"],
"type": "QubesVM",
"default_dispvm": "vm",
"template_for_dispvms": True,
"icon": "icon-vm",
"guivm": "vm",
"power_state": "Halted",
"uuid": str(TEST_UUID),
},
}
},
expected_data,
)

# test if data got cached (should give outdated answer without events)
vm.tags = ["tag4", "tag5"]
ret = json.loads(self.call_mgmt_func(b"internal.GetSystemInfo"))
self.assertEqual(
ret,
expected_data,
)

# and if the cache got invalidated on event
vm.add_handler.mock_calls[0][1][1](vm, "domain-tag-add:test4")
expected_data["domains"]["vm"]["tags"] = ["tag4", "tag5"]
ret = json.loads(self.call_mgmt_func(b"internal.GetSystemInfo"))
self.assertEqual(
ret,
expected_data,
)

0 comments on commit 0f3d7fa

Please sign in to comment.