fixup! wip: dynamic inventory

Dustin 2025-02-07 09:05:58 -06:00
parent 9e7eff5e9e
commit 3a0ed3dbd5
3 changed files with 212 additions and 64 deletions

View File

@ -1,7 +1,7 @@
# vim: set ft=dosini :
[defaults]
inventory = hosts
inventory = hosts, hosts.pyrocufflink.yml
callback_plugins = plugins/callback
inventory_plugins = plugins/inventory

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

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