From 3a0ed3dbd505fbeff7464f39f10c9dae32a5221b Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Fri, 7 Feb 2025 09:05:58 -0600 Subject: [PATCH] fixup! wip: dynamic inventory --- ansible.cfg | 2 +- hosts.pyrocufflink.yml | 10 ++ plugins/inventory/pyrocufflink.py | 264 +++++++++++++++++++++++------- 3 files changed, 212 insertions(+), 64 deletions(-) create mode 100644 hosts.pyrocufflink.yml diff --git a/ansible.cfg b/ansible.cfg index e74504a..5cb5a56 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,7 +1,7 @@ # vim: set ft=dosini : [defaults] -inventory = hosts +inventory = hosts, hosts.pyrocufflink.yml callback_plugins = plugins/callback inventory_plugins = plugins/inventory 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 index c492277..2960c2c 100644 --- a/plugins/inventory/pyrocufflink.py +++ b/plugins/inventory/pyrocufflink.py @@ -1,11 +1,14 @@ +import enum +import functools import logging +from typing import cast, Any, Optional from xml.etree import ElementTree as etree import libvirt -import yaml from ansible.plugins.inventory import BaseInventoryPlugin, Constructable + DOCUMENTATION = r''' name: pyrocufflink extends_documentation_fragment: @@ -22,87 +25,222 @@ options: 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') -METADATA_XMLNS = 'http://du5t1n.me/xmlns/libvirt/metadata/' - -DOMAIN_STATES = { - 0: 'no state', - 1: 'the domain is running', - 2: 'the domain is blocked on resource', - 3: 'the domain is paused by user', - 4: 'the domain is being shut down', - 5: 'the domain is shut off', - 6: 'the domain is crashed', - 7: 'the domain is suspended by guest power management', -} # 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) - # set _options from config data 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 - conn = libvirt.open() - for dom in conn.listAllDomains(): - v = {} - - name = v['name'] = dom.name() - try: - v['title'] = dom.metadata(1, None) - except libvirt.libvirtError as e: - log.debug('Could not get title for domain %s: %s', name, e) - inventory_hostname = name + for uri in uri_list: + if read_only: + conn = libvirt.openReadOnly(uri) else: - inventory_hostname = v['title'] - self.inventory.add_host(inventory_hostname) - - try: - v['description'] = dom.metadata(0, None) - except libvirt.libvirtError as e: - log.debug('Could not get description for %s: %s', name, e) - - try: - metadata = dom.metadata(2, METADATA_XMLNS) - except libvirt.libvirtError as e: - log.debug( - 'Could not get extended domain metadata for %s: %s', - name, - e, - ) - else: - try: - v['metadata'] = parse_metadata(metadata) - except Exception as e: - log.error('Failed to parse metadata for %s: %s', name, e) - - state, reason = dom.state() - v['state_code'] = state - v['state_reason_code'] = reason - try: - v['state'] = DOMAIN_STATES[state] - except KeyError: - pass - if state == 1: - try: - guest_info = dom.guestInfo() - except libvirt.libvirtError as e: - log.error('Could not get guest info for %s: %s', name, e) + 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: - self.inventory.set_variable(name, 'guest_info', guest_info) - self.inventory.set_variable(inventory_hostname, 'libvirt', v) + 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) + ) -def parse_metadata(metadata: str): - tree = etree.fromstring(metadata) - return yaml.safe_load(tree.text) +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]: + grouplist = [] + if self.metadata: + if groups := self.metadata.find('groups'): + for elem in groups.iter('group'): + if group_name := elem.get('name'): + grouplist.append(group_name) + return grouplist + + @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