From 7ff18ab75e3305d043d862dafacae7f347a75df7 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Fri, 7 Feb 2025 06:23:21 -0600 Subject: [PATCH] Introduce dynamic inventory In order to fully automate host provisioning, we need to eliminate the manual step of adding hosts to the Ansible inventory. Ansible has had the _community.libvirt.libvirt_ inventory plugin for quite a while, but by itself it is insufficient, as it has no way to add hosts to groups dynamically. It does expose the domain XML, but parsing that and extracting group memberships from that using Jinja templates would be pretty terrible. Thus, I decided the easiest and most appropriate option would be to develop my own dynamic inventory plugin. * Supports multiple _libvirt_ servers * Can connect to the read-only _libvirt_ socket * Can optionally exclude VMs that are powered off * Can exclude VMs based on their operating system (if the _libosinfo_ metadata is specified in the domain metadata) * Can add hosts to groups as specified in the domain metadata * Exposes guest info as inventory host variables (requires QEMU guest agent running in the VM and does not work with a read-only _libvirt_ connection) --- ansible.cfg | 3 +- hosts.pyrocufflink.yml | 10 ++ plugins/inventory/pyrocufflink.py | 248 ++++++++++++++++++++++++++++++ 3 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 hosts.pyrocufflink.yml create mode 100644 plugins/inventory/pyrocufflink.py diff --git a/ansible.cfg b/ansible.cfg index 4aa9bc9..5cb5a56 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,9 +1,10 @@ # vim: set ft=dosini : [defaults] -inventory = hosts +inventory = hosts, hosts.pyrocufflink.yml callback_plugins = plugins/callback +inventory_plugins = plugins/inventory gathering = smart fact_caching = jsonfile diff --git a/hosts.pyrocufflink.yml b/hosts.pyrocufflink.yml new file mode 100644 index 0000000..17ed5f2 --- /dev/null +++ b/hosts.pyrocufflink.yml @@ -0,0 +1,10 @@ +plugin: pyrocufflink +uri: +- qemu+ssh://vmhost0.pyrocufflink.blue/system +- qemu+ssh://vmhost1.pyrocufflink.blue/system +read_only: true +dns_domain: pyrocufflink.blue +exclude_off: true +exclude_os: +- http://fedoraproject.org/coreos/stable +log_excluded: true diff --git a/plugins/inventory/pyrocufflink.py b/plugins/inventory/pyrocufflink.py new file mode 100644 index 0000000..26b778a --- /dev/null +++ b/plugins/inventory/pyrocufflink.py @@ -0,0 +1,248 @@ +import enum +import functools +import logging +from typing import cast, Any, Iterator, Optional +from xml.etree import ElementTree as etree + +import libvirt + +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable + + +DOCUMENTATION = r''' +name: pyrocufflink +extends_documentation_fragment: +- constructed +short_description: Pyrocufflink inventory source +version_added: '' +description: +- Dynamic inventory for Pyrocufflink machines +author: +- Dustin C. Hatch +options: + plugin: + description: + Token that ensures this is a source file for the 'pyrocufflink' plugin. + required: True + choices: ['pyrocufflink'] + uri: + description: libvirt connection URI + type: list + required: true + read_only: + description: Open a read-only connection to libvirt + type: boolean + default: false + dns_domain: + description: DNS doman to append to single-label VM names + type: string + exclude_off: + description: Exclude libvirt domains that are powered off + type: boolean + default: false + exclude_os: + description: Exclude domains running specified operating systems + type: list + default: [] + log_excluded: + description: + Print a log message about excluded domains, useful for troubleshooting + type: boolean + default: false +''' + + +log = logging.getLogger('ansible.plugins.inventory.pyrocufflink') + + +# Remove the default error handler, which prints to stderr +libvirt.registerErrorHandler(lambda *_: None, None) + + +class XMLNS: + dch = 'http://du5t1n.me/xmlns/libvirt/metadata/' + libosinfo = 'http://libosinfo.org/xmlns/libvirt/domain/1.0' + + +class DomainState(enum.IntEnum): + nostate = 0 + running = 1 + blockde = 2 + paused = 3 + shutdown = 4 + shutoff = 5 + crashed = 6 + pmsuspend = 7 + + +class InventoryModule(BaseInventoryPlugin, Constructable): + NAME = 'pyrocufflink' + + def parse(self, inventory, loader, path, cache=True): + super().parse(inventory, loader, path, cache=cache) + config_data = self._read_config_data(path) + self._consume_options(config_data) + + uri_list = self.get_option('uri') + if not isinstance(uri_list, list): + uri_list = [uri_list] + read_only = cast(bool, self.get_option('read_only')) + dns_domain = self.get_option('dns_domain') + exclude_off = cast(bool, self.get_option('exclude_off')) + exclude_os = cast(list[str], self.get_option('exclude_os')) + log_excluded = cast(bool, self.get_option('log_excluded')) + + assert self.inventory + for uri in uri_list: + if read_only: + conn = libvirt.openReadOnly(uri) + else: + conn = libvirt.open(uri) + for dom in conn.listAllDomains(): + host = Host(dom) + state = host.get_state()[0] + if state == DomainState.shutoff and exclude_off: + if log_excluded: + log.warning( + 'Excluding libvirt domain %s in state %r', + host.name, + DomainState.shutoff.name, + ) + continue + if host.os_id in exclude_os: + if log_excluded: + log.warning( + 'Excluding libvirt domain %s with OS %r', + host.name, + host.os_id, + ) + continue + if host.title: + inventory_hostname = host.title + else: + inventory_hostname = host.name + if dns_domain and '.' not in inventory_hostname: + inventory_hostname = f'{inventory_hostname}.{dns_domain}' + self.inventory.add_host(inventory_hostname) + for group in host.groups: + self.inventory.add_group(group) + self.inventory.add_host(inventory_hostname, group) + self.inventory.set_variable( + inventory_hostname, 'libvirt_uri', uri + ) + self.inventory.set_variable( + inventory_hostname, 'libvirt', host.variables(read_only) + ) + + +class Host: + + def __init__(self, domain: libvirt.virDomain) -> None: + self.domain = domain + + @functools.cached_property + def description(self) -> Optional[str]: + try: + return self.domain.metadata(0, None) + except libvirt.libvirtError as e: + log.debug( + 'Could not get description for domain %s: %s', self.name, e + ) + return None + + @functools.cached_property + def groups(self) -> list[str]: + return list(self._groups()) + + @functools.cached_property + def libosinfo(self) -> Optional[etree.Element]: + try: + metadata = self.domain.metadata(2, XMLNS.libosinfo) + except libvirt.libvirtError as e: + log.debug( + 'Could not get extended domain metadata for %s: %s', + self.name, + e, + ) + return None + return etree.fromstring(metadata) + + @functools.cached_property + def metadata(self) -> Optional[etree.Element]: + try: + metadata = self.domain.metadata(2, XMLNS.dch) + except libvirt.libvirtError as e: + log.debug( + 'Could not get extended domain metadata for %s: %s', + self.name, + e, + ) + return None + return etree.fromstring(metadata) + + @functools.cached_property + def name(self) -> str: + return self.domain.name() + + @functools.cached_property + def os_id(self) -> Optional[str]: + if self.libosinfo is None: + return + if (os := self.libosinfo.find('os')) is not None: + return os.get('id') + + @functools.cached_property + def title(self) -> Optional[str]: + try: + return self.domain.metadata(1, None) + except libvirt.libvirtError as e: + log.debug('Could not get title for domain %s: %s', self.name, e) + return None + + def get_guest_info(self) -> Optional[dict[str, str]]: + try: + return self.domain.guestInfo() + except libvirt.libvirtError as e: + log.error('Could not get guest info for %s: %s', self.name, e) + + def get_state(self) -> tuple[int, int]: + state, reason = self.domain.state() + return state, reason + + def variables(self, read_only: bool = False) -> dict[str, Any]: + values = {} + + if title := self.title: + values['title'] = title + if description := self.description: + values['description'] = description + + if self.os_id: + values['os_id'] = self.os_id + + state_code, reason_code = self.get_state() + values['state_code'] = state_code + values['reason_code'] = reason_code + try: + state = DomainState(state_code) + except KeyError: + log.warning( + 'Unknown state for domain %s: %s:', self.name, state_code + ) + state = None + else: + values['state'] = state.name + + if not read_only and state is DomainState.running: + if guest_info := self.get_guest_info(): + values['guest_info'] = guest_info + + return values + + def _groups(self) -> Iterator[str]: + if self.metadata is None: + return + if groups := self.metadata.find('groups'): + for elem in groups.iter('group'): + if group_name := elem.get('name'): + yield group_name