1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00
community.general/plugins/inventory/virtualbox.py
patchback[bot] 37308c929b
[PR #8510/21b16c1c backport][stable-9] Update VirtualBox Group parsing to align with documentation. (#8621)
Update VirtualBox Group parsing to align with documentation. (#8510)

* Update VirtualBox Group parsing to align with documentation.

Previously, we could separate the group string on the `/` char and
consider each element to be distinct, top-level groups. This change
implements the notion of nested groups and the use of the `,` char to
split multiple groups.

* Address code review comments.

Changed the implementation from a breaking change to a minor change by
introducing a new parameter to configure the behaviour. Keep the default
values to maintain the existing behaviour, and allow consumers an option
to opt-in.

* Fix line length.

The long lines were tripping CI. Reduce the length.

* Apply suggestions from code review

Update documentation to match expected conventions and correct the final rendered formatting.
Set the initial parent_group to `None` instead of `all` and rely on the parent class' inventory reconciliation logic to ensure consistent behaviour across different inventory plugins.

Co-authored-by: Felix Fontein <felix@fontein.de>

* Reword module arg description to avoid issues with CI.

One of the lines ended with a colon character which made the CI tests
fail since it would interpret it as a YAML key. Reworded the description
altogether to avoid that issue.

* Apply suggestions from code review

Co-authored-by: Felix Fontein <felix@fontein.de>

---------

Co-authored-by: Felix Fontein <felix@fontein.de>
(cherry picked from commit 21b16c1c77)

Co-authored-by: lyrandy <42095565+lyrandy@users.noreply.github.com>
2024-07-14 12:17:49 +02:00

360 lines
15 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2017 Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
DOCUMENTATION = '''
author: Unknown (!UNKNOWN)
name: virtualbox
short_description: virtualbox inventory source
description:
- Get inventory hosts from the local virtualbox installation.
- Uses a YAML configuration file that ends with virtualbox.(yml|yaml) or vbox.(yml|yaml).
- The inventory_hostname is always the 'Name' of the virtualbox instance.
- Groups can be assigned to the VMs using C(VBoxManage). Multiple groups can be assigned by using V(/) as a delimeter.
- A separate parameter, O(enable_advanced_group_parsing) is exposed to change grouping behaviour. See the parameter documentation for details.
extends_documentation_fragment:
- constructed
- inventory_cache
options:
plugin:
description: token that ensures this is a source file for the 'virtualbox' plugin
required: true
choices: ['virtualbox', 'community.general.virtualbox']
running_only:
description: toggles showing all vms vs only those currently running
type: boolean
default: false
settings_password_file:
description: provide a file containing the settings password (equivalent to --settingspwfile)
network_info_path:
description: property path to query for network information (ansible_host)
default: "/VirtualBox/GuestInfo/Net/0/V4/IP"
query:
description: create vars from virtualbox properties
type: dictionary
default: {}
enable_advanced_group_parsing:
description:
- The default group parsing rule (when this setting is set to V(false)) is to split the VirtualBox VM's group based on the V(/) character and
assign the resulting list elements as an Ansible Group.
- Setting O(enable_advanced_group_parsing=true) changes this behaviour to match VirtualBox's interpretation of groups according to
U(https://www.virtualbox.org/manual/UserManual.html#gui-vmgroups).
Groups are now split using the V(,) character, and the V(/) character indicates nested groups.
- When enabled, a VM that's been configured using V(VBoxManage modifyvm "vm01" --groups "/TestGroup/TestGroup2,/TestGroup3") will result in
the group C(TestGroup2) being a child group of C(TestGroup); and
the VM being a part of C(TestGroup2) and C(TestGroup3).
default: false
type: bool
version_added: 9.2.0
'''
EXAMPLES = '''
# file must be named vbox.yaml or vbox.yml
simple_config_file:
plugin: community.general.virtualbox
settings_password_file: /etc/virtulbox/secrets
query:
logged_in_users: /VirtualBox/GuestInfo/OS/LoggedInUsersList
compose:
ansible_connection: ('indows' in vbox_Guest_OS)|ternary('winrm', 'ssh')
# add hosts (all match with minishift vm) to the group container if any of the vms are in ansible_inventory'
plugin: community.general.virtualbox
groups:
container: "'minis' in (inventory_hostname)"
'''
import os
from subprocess import Popen, PIPE
from ansible.errors import AnsibleParserError
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
from ansible.module_utils.common._collections_compat import MutableMapping
from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable
from ansible.module_utils.common.process import get_bin_path
from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe
class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):
''' Host inventory parser for ansible using local virtualbox. '''
NAME = 'community.general.virtualbox'
VBOX = "VBoxManage"
def __init__(self):
self._vbox_path = None
super(InventoryModule, self).__init__()
def _query_vbox_data(self, host, property_path):
ret = None
try:
cmd = [self._vbox_path, b'guestproperty', b'get',
to_bytes(host, errors='surrogate_or_strict'),
to_bytes(property_path, errors='surrogate_or_strict')]
x = Popen(cmd, stdout=PIPE)
ipinfo = to_text(x.stdout.read(), errors='surrogate_or_strict')
if 'Value' in ipinfo:
a, ip = ipinfo.split(':', 1)
ret = ip.strip()
except Exception:
pass
return ret
def _set_variables(self, hostvars):
# set vars in inventory from hostvars
for host in hostvars:
query = self.get_option('query')
# create vars from vbox properties
if query and isinstance(query, MutableMapping):
for varname in query:
hostvars[host][varname] = self._query_vbox_data(host, query[varname])
strict = self.get_option('strict')
# create composite vars
self._set_composite_vars(self.get_option('compose'), hostvars[host], host, strict=strict)
# actually update inventory
for key in hostvars[host]:
self.inventory.set_variable(host, key, hostvars[host][key])
# constructed groups based on conditionals
self._add_host_to_composed_groups(self.get_option('groups'), hostvars[host], host, strict=strict)
# constructed keyed_groups
self._add_host_to_keyed_groups(self.get_option('keyed_groups'), hostvars[host], host, strict=strict)
def _populate_from_cache(self, source_data):
source_data = make_unsafe(source_data)
hostvars = source_data.pop('_meta', {}).get('hostvars', {})
for group in source_data:
if group == 'all':
continue
else:
group = self.inventory.add_group(group)
hosts = source_data[group].get('hosts', [])
for host in hosts:
self._populate_host_vars([host], hostvars.get(host, {}), group)
self.inventory.add_child('all', group)
if not source_data:
for host in hostvars:
self.inventory.add_host(host)
self._populate_host_vars([host], hostvars.get(host, {}))
def _populate_from_source(self, source_data, using_current_cache=False):
if using_current_cache:
self._populate_from_cache(source_data)
return source_data
cacheable_results = {'_meta': {'hostvars': {}}}
hostvars = {}
prevkey = pref_k = ''
current_host = None
# needed to possibly set ansible_host
netinfo = self.get_option('network_info_path')
for line in source_data:
line = to_text(line)
if ':' not in line:
continue
try:
k, v = line.split(':', 1)
except Exception:
# skip non splitable
continue
if k.strip() == '':
# skip empty
continue
v = v.strip()
# found host
if k.startswith('Name') and ',' not in v: # some setting strings appear in Name
current_host = make_unsafe(v)
if current_host not in hostvars:
hostvars[current_host] = {}
self.inventory.add_host(current_host)
# try to get network info
netdata = self._query_vbox_data(current_host, netinfo)
if netdata:
self.inventory.set_variable(current_host, 'ansible_host', make_unsafe(netdata))
# found groups
elif k == 'Groups':
if self.get_option('enable_advanced_group_parsing'):
self._handle_vboxmanage_group_string(v, current_host, cacheable_results)
else:
self._handle_group_string(v, current_host, cacheable_results)
continue
else:
# found vars, accumulate in hostvars for clean inventory set
pref_k = make_unsafe('vbox_' + k.strip().replace(' ', '_'))
leading_spaces = len(k) - len(k.lstrip(' '))
if 0 < leading_spaces <= 2:
if prevkey not in hostvars[current_host] or not isinstance(hostvars[current_host][prevkey], dict):
hostvars[current_host][prevkey] = {}
hostvars[current_host][prevkey][pref_k] = make_unsafe(v)
elif leading_spaces > 2:
continue
else:
if v != '':
hostvars[current_host][pref_k] = make_unsafe(v)
if self._ungrouped_host(current_host, cacheable_results):
if 'ungrouped' not in cacheable_results:
cacheable_results['ungrouped'] = {'hosts': []}
cacheable_results['ungrouped']['hosts'].append(current_host)
prevkey = pref_k
self._set_variables(hostvars)
for host in hostvars:
h = self.inventory.get_host(host)
cacheable_results['_meta']['hostvars'][h.name] = h.vars
return cacheable_results
def _ungrouped_host(self, host, inventory):
def find_host(host, inventory):
for k, v in inventory.items():
if k == '_meta':
continue
if isinstance(v, dict):
yield self._ungrouped_host(host, v)
elif isinstance(v, list):
yield host not in v
yield True
return all(find_host(host, inventory))
def _handle_group_string(self, vboxmanage_group, current_host, cacheable_results):
'''Handles parsing the VM's Group assignment from VBoxManage according to this inventory's initial implementation.'''
# The original implementation of this inventory plugin treated `/` as
# a delimeter to split and use as Ansible Groups.
for group in vboxmanage_group.split('/'):
if group:
group = make_unsafe(group)
group = self.inventory.add_group(group)
self.inventory.add_child(group, current_host)
if group not in cacheable_results:
cacheable_results[group] = {'hosts': []}
cacheable_results[group]['hosts'].append(current_host)
def _handle_vboxmanage_group_string(self, vboxmanage_group, current_host, cacheable_results):
'''Handles parsing the VM's Group assignment from VBoxManage according to VirtualBox documentation.'''
# Per the VirtualBox documentation, a VM can be part of many groups,
# and it's possible to have nested groups.
# Many groups are separated by commas ",", and nested groups use
# slash "/".
# https://www.virtualbox.org/manual/UserManual.html#gui-vmgroups
# Multi groups: VBoxManage modifyvm "vm01" --groups "/TestGroup,/TestGroup2"
# Nested groups: VBoxManage modifyvm "vm01" --groups "/TestGroup/TestGroup2"
for group in vboxmanage_group.split(','):
if not group:
# We could get an empty element due how to split works, and
# possible assignments from VirtualBox. e.g. ,/Group1
continue
if group == "/":
# This is the "root" group. We get here if the VM was not
# assigned to a particular group. Consider the host to be
# unassigned to a group.
continue
parent_group = None
for subgroup in group.split('/'):
if not subgroup:
# Similarly to above, we could get an empty element.
# e.g //Group1
continue
if subgroup == '/':
# "root" group.
# Consider the host to be unassigned
continue
subgroup = make_unsafe(subgroup)
subgroup = self.inventory.add_group(subgroup)
if parent_group is not None:
self.inventory.add_child(parent_group, subgroup)
self.inventory.add_child(subgroup, current_host)
if subgroup not in cacheable_results:
cacheable_results[subgroup] = {'hosts': []}
cacheable_results[subgroup]['hosts'].append(current_host)
parent_group = subgroup
def verify_file(self, path):
valid = False
if super(InventoryModule, self).verify_file(path):
if path.endswith(('virtualbox.yaml', 'virtualbox.yml', 'vbox.yaml', 'vbox.yml')):
valid = True
return valid
def parse(self, inventory, loader, path, cache=True):
try:
self._vbox_path = get_bin_path(self.VBOX)
except ValueError as e:
raise AnsibleParserError(e)
super(InventoryModule, self).parse(inventory, loader, path)
cache_key = self.get_cache_key(path)
config_data = self._read_config_data(path)
# set _options from config data
self._consume_options(config_data)
source_data = None
if cache:
cache = self.get_option('cache')
update_cache = False
if cache:
try:
source_data = self._cache[cache_key]
except KeyError:
update_cache = True
if not source_data:
b_pwfile = to_bytes(self.get_option('settings_password_file'), errors='surrogate_or_strict', nonstring='passthru')
running = self.get_option('running_only')
# start getting data
cmd = [self._vbox_path, b'list', b'-l']
if running:
cmd.append(b'runningvms')
else:
cmd.append(b'vms')
if b_pwfile and os.path.exists(b_pwfile):
cmd.append(b'--settingspwfile')
cmd.append(b_pwfile)
try:
p = Popen(cmd, stdout=PIPE)
except Exception as e:
raise AnsibleParserError(to_native(e))
source_data = p.stdout.read().splitlines()
using_current_cache = cache and not update_cache
cacheable_results = self._populate_from_source(source_data, using_current_cache)
if update_cache:
self._cache[cache_key] = cacheable_results