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)
unifi-restore
Dustin 2025-02-07 06:23:21 -06:00
parent e9d6020563
commit 7ff18ab75e
3 changed files with 260 additions and 1 deletions

View File

@ -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

10
hosts.pyrocufflink.yml Normal file
View File

@ -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

View File

@ -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 <dustin@hatch.name>
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