diff --git a/plugins/inventory/lxd.py b/plugins/inventory/lxd.py new file mode 100644 index 0000000000..c48818d595 --- /dev/null +++ b/plugins/inventory/lxd.py @@ -0,0 +1,950 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2021, Frank Dornheim +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' + name: community.general.lxd + short_description: Returns Ansible inventory from lxd host + description: + - Get inventory from the lxd. + - Uses a YAML configuration file that ends with 'lxd.(yml|yaml)'. + version_added: "3.0.0" + author: "Frank Dornheim (@conloos)" + options: + plugin: + description: Token that ensures this is a source file for the 'lxd' plugin. + required: true + choices: [ 'community.general.lxd' ] + url: + description: + - The unix domain socket path or the https URL for the lxd server. + - Sockets in filesystem have to start with C(unix:). + - Mostly C(unix:/var/lib/lxd/unix.socket) or C(unix:/var/snap/lxd/common/lxd/unix.socket). + default: unix:/var/snap/lxd/common/lxd/unix.socket + type: str + client_key: + description: + - The client certificate key file path. + aliases: [ key_file ] + default: $HOME/.config/lxc/client.key + type: path + client_cert: + description: + - The client certificate file path. + aliases: [ cert_file ] + default: $HOME/.config/lxc/client.crt + type: path + trust_password: + description: + - The client trusted password. + - You need to set this password on the lxd server before + running this module using the following command + C(lxc config set core.trust_password ) + See U(https://www.stgraber.org/2016/04/18/lxd-api-direct-interaction/). + - If I(trust_password) is set, this module send a request for authentication before sending any requests. + type: str + state: + description: Filter the container according to the current status. + type: str + default: none + choices: [ 'STOPPED', 'STARTING', 'RUNNING', 'none' ] + prefered_container_network_interface: + description: + - If a container has multiple network interfaces, select which one is the prefered as pattern. + - Combined with the first number that can be found e.g. 'eth' + 0. + type: str + default: eth + prefered_container_network_family: + description: + - If a container has multiple network interfaces, which one is the prefered by family. + - Specify C(inet) for IPv4 and C(inet6) for IPv6. + type: str + default: inet + choices: [ 'inet', 'inet6' ] + groupby: + description: + - Create groups by the following keywords C(location), C(pattern), C(network_range), C(os), C(release), C(profile), C(vlanid). + - See example for syntax. + type: json +''' + +EXAMPLES = ''' +# simple lxd.yml +plugin: community.general.lxd +url: unix:/var/snap/lxd/common/lxd/unix.socket + +# simple lxd.yml including filter +plugin: community.general.lxd +url: unix:/var/snap/lxd/common/lxd/unix.socket +state: RUNNING + +# grouping lxd.yml +groupby: + testpattern: + type: pattern + attribute: test + vlan666: + type: vlanid + attribute: 666 + locationBerlin: + type: location + attribute: Berlin + osUbuntu: + type: os + attribute: ubuntu + releaseFocal: + type: release + attribute: focal + releaseBionic: + type: release + attribute: bionic + profileDefault: + type: profile + attribute: default + profileX11: + type: profile + attribute: x11 + netRangeIPv4: + type: network_range + attribute: 10.98.143.0/24 + netRangeIPv6: + type: network_range + attribute: fd42:bd00:7b11:2167:216:3eff::/24 +''' + +import binascii +import json +import re +import time +import os +import socket +from ansible.plugins.inventory import BaseInventoryPlugin +from ansible.module_utils._text import to_native, to_text +from ansible.module_utils.common.dict_transformations import dict_merge +from ansible.errors import AnsibleError, AnsibleParserError +from ansible_collections.community.general.plugins.module_utils.compat import ipaddress +from ansible_collections.community.general.plugins.module_utils.lxd import LXDClient, LXDClientException + + +class InventoryModule(BaseInventoryPlugin): + DEBUG = 4 + NAME = 'community.general.lxd' + SNAP_SOCKET_URL = 'unix:/var/snap/lxd/common/lxd/unix.socket' + SOCKET_URL = 'unix:/var/lib/lxd/unix.socket' + + @staticmethod + def load_json_data(path): + """Load json data + + Load json data from file + + Args: + list(path): Path elements + str(file_name): Filename of data + Kwargs: + None + Raises: + None + Returns: + dict(json_data): json data""" + try: + with open(path, 'r') as json_file: + return json.load(json_file) + except (IOError, json.decoder.JSONDecodeError) as err: + raise AnsibleParserError('Could not load the test data from {0}: {1}'.format(to_native(path), to_native(err))) + + def save_json_data(self, path, file_name=None): + """save data as json + + Save data as json file + + Args: + list(path): Path elements + str(file_name): Filename of data + Kwargs: + None + Raises: + None + Returns: + None""" + + if file_name: + path.append(file_name) + else: + prefix = 'lxd_data-' + time_stamp = time.strftime('%Y%m%d-%H%M%S') + suffix = '.atd' + path.append(prefix + time_stamp + suffix) + + try: + cwd = os.path.abspath(os.path.dirname(__file__)) + with open(os.path.abspath(os.path.join(cwd, *path)), 'w') as json_file: + json.dump(self.data, json_file) + except IOError as err: + raise AnsibleParserError('Could not save data: {0}'.format(to_native(err))) + + def verify_file(self, path): + """Check the config + + Return true/false if the config-file is valid for this plugin + + Args: + str(path): path to the config + Kwargs: + None + Raises: + None + Returns: + bool(valid): is valid""" + valid = False + if super(InventoryModule, self).verify_file(path): + if path.endswith(('lxd.yaml', 'lxd.yml')): + valid = True + else: + self.display.vvv('Inventory source not ending in "lxd.yaml" or "lxd.yml"') + return valid + + @staticmethod + def validate_url(url): + """validate url + + check whether the url is correctly formatted + + Args: + url + Kwargs: + None + Raises: + AnsibleError + Returns: + bool""" + if not isinstance(url, str): + return False + if not url.startswith(('unix:', 'https:')): + raise AnsibleError('URL is malformed: {0}'.format(to_native(url))) + return True + + def _connect_to_socket(self): + """connect to lxd socket + + Connect to lxd socket by provided url or defaults + + Args: + None + Kwargs: + None + Raises: + AnsibleError + Returns: + None""" + error_storage = {} + url_list = [self.get_option('url'), self.SNAP_SOCKET_URL, self.SOCKET_URL] + urls = (url for url in url_list if self.validate_url(url)) + for url in urls: + try: + socket_connection = LXDClient(url, self.client_key, self.client_cert, self.debug) + return socket_connection + except LXDClientException as err: + error_storage[url] = err + raise AnsibleError('No connection to the socket: {0}'.format(to_native(error_storage))) + + def _get_networks(self): + """Get Networknames + + Returns all network config names + + Args: + None + Kwargs: + None + Raises: + None + Returns: + list(names): names of all network_configs""" + # e.g. {'type': 'sync', + # 'status': 'Success', + # 'status_code': 200, + # 'operation': '', + # 'error_code': 0, + # 'error': '', + # 'metadata': ['/1.0/networks/lxdbr0']} + network_configs = self.socket.do('GET', '/1.0/networks') + return [m.split('/')[3] for m in network_configs['metadata']] + + def _get_containers(self): + """Get Containernames + + Returns all containernames + + Args: + None + Kwargs: + None + Raises: + None + Returns: + list(names): names of all containers""" + # e.g. {'type': 'sync', + # 'status': 'Success', + # 'status_code': 200, + # 'operation': '', + # 'error_code': 0, + # 'error': '', + # 'metadata': ['/1.0/containers/udemy-ansible-ubuntu-2004']} + containers = self.socket.do('GET', '/1.0/containers') + return [m.split('/')[3] for m in containers['metadata']] + + def _get_config(self, branch, name): + """Get inventory of container + + Get config of container + + Args: + str(branch): Name oft the API-Branch + str(name): Name of Container + Kwargs: + None + Source: + https://github.com/lxc/lxd/blob/master/doc/rest-api.md + Raises: + None + Returns: + dict(config): Config of the container""" + config = {} + if isinstance(branch, (tuple, list)): + config[name] = {branch[1]: self.socket.do('GET', '/1.0/{0}/{1}/{2}'.format(to_native(branch[0]), to_native(name), to_native(branch[1])))} + else: + config[name] = {branch: self.socket.do('GET', '/1.0/{0}/{1}'.format(to_native(branch), to_native(name)))} + return config + + def get_container_data(self, names): + """Create Inventory of the container + + Iterate through the different branches of the containers and collect Informations. + + Args: + list(names): List of container names + Kwargs: + None + Raises: + None + Returns: + None""" + # tuple(('instances','metadata/templates')) to get section in branch + # e.g. /1.0/instances//metadata/templates + branches = ['containers', ('instances', 'state')] + container_config = {} + for branch in branches: + for name in names: + container_config['containers'] = self._get_config(branch, name) + self.data = dict_merge(container_config, self.data) + + def get_network_data(self, names): + """Create Inventory of the container + + Iterate through the different branches of the containers and collect Informations. + + Args: + list(names): List of container names + Kwargs: + None + Raises: + None + Returns: + None""" + # tuple(('instances','metadata/templates')) to get section in branch + # e.g. /1.0/instances//metadata/templates + branches = [('networks', 'state')] + network_config = {} + for branch in branches: + for name in names: + try: + network_config['networks'] = self._get_config(branch, name) + except LXDClientException: + network_config['networks'] = {name: None} + self.data = dict_merge(network_config, self.data) + + def extract_network_information_from_container_config(self, container_name): + """Returns the network interface configuration + + Returns the network ipv4 and ipv6 config of the container without local-link + + Args: + str(container_name): Name oft he container + Kwargs: + None + Raises: + None + Returns: + dict(network_configuration): network config""" + container_network_interfaces = self._get_data_entry('containers/{0}/state/metadata/network'.format(container_name)) + network_configuration = None + if container_network_interfaces: + network_configuration = {} + gen_interface_names = [interface_name for interface_name in container_network_interfaces if interface_name != 'lo'] + for interface_name in gen_interface_names: + gen_address = [address for address in container_network_interfaces[interface_name]['addresses'] if address.get('scope') != 'link'] + network_configuration[interface_name] = [] + for address in gen_address: + address_set = {} + address_set['family'] = address.get('family') + address_set['address'] = address.get('address') + address_set['netmask'] = address.get('netmask') + address_set['combined'] = address.get('address') + '/' + address.get('netmask') + network_configuration[interface_name].append(address_set) + return network_configuration + + def get_prefered_container_network_interface(self, container_name): + """Helper to get the prefered interface of thr container + + Helper to get the prefered interface provide by neme pattern from 'prefered_container_network_interface'. + + Args: + str(containe_name): name of container + Kwargs: + None + Raises: + None + Returns: + str(prefered_interface): None or interface name""" + container_network_interfaces = self._get_data_entry('inventory/{0}/network_interfaces'.format(container_name)) + prefered_interface = None # init + if container_network_interfaces: # container have network interfaces + # generator if interfaces which start with the desired pattern + net_generator = [interface for interface in container_network_interfaces if interface.startswith(self.prefered_container_network_interface)] + selected_interfaces = [] # init + for interface in net_generator: + selected_interfaces.append(interface) + if len(selected_interfaces) > 0: + prefered_interface = sorted(selected_interfaces)[0] + return prefered_interface + + def get_container_vlans(self, container_name): + """Get VLAN(s) from container + + Helper to get the VLAN_ID from the container + + Args: + str(containe_name): name of container + Kwargs: + None + Raises: + None + Returns: + None""" + # get network device configuration and store {network: vlan_id} + network_vlans = {} + for network in self._get_data_entry('networks'): + if self._get_data_entry('state/metadata/vlan/vid', data=self.data['networks'].get(network)): + network_vlans[network] = self._get_data_entry('state/metadata/vlan/vid', data=self.data['networks'].get(network)) + + # get networkdevices of container and return + # e.g. + # "eth0":{ "name":"eth0", + # "network":"lxdbr0", + # "type":"nic"}, + vlan_ids = {} + devices = self._get_data_entry('containers/{0}/containers/metadata/expanded_devices'.format(to_native(container_name))) + for device in devices: + if 'network' in devices[device]: + if devices[device]['network'] in network_vlans: + vlan_ids[devices[device].get('network')] = network_vlans[devices[device].get('network')] + return vlan_ids if vlan_ids else None + + def _get_data_entry(self, path, data=None, delimiter='/'): + """Helper to get data + + Helper to get data from self.data by a path like 'path/to/target' + Attention: Escaping of the delimiter is not (yet) provided. + + Args: + str(path): path to nested dict + Kwargs: + dict(data): datastore + str(delimiter): delimiter in Path. + Raises: + None + Returns: + *(value)""" + try: + if not data: + data = self.data + if delimiter in path: + path = path.split(delimiter) + + if isinstance(path, list) and len(path) > 1: + data = data[path.pop(0)] + path = delimiter.join(path) + return self._get_data_entry(path, data, delimiter) # recursion + return data[path] + except KeyError: + return None + + def _set_data_entry(self, container_name, key, value, path=None): + """Helper to save data + + Helper to save the data in self.data + Detect if data is allready in branch and use dict_merge() to prevent that branch is overwritten. + + Args: + str(container_name): name of container + str(key): same as dict + *(value): same as dict + Kwargs: + str(path): path to branch-part + Raises: + AnsibleParserError + Returns: + None""" + if not path: + path = self.data['inventory'] + if container_name not in path: + path[container_name] = {} + + try: + if isinstance(value, dict) and key in path[container_name]: + path[container_name] = dict_merge(value, path[container_name][key]) + else: + path[container_name][key] = value + except KeyError as err: + raise AnsibleParserError("Unable to store Informations: {0}".format(to_native(err))) + + def extract_information_from_container_configs(self): + """Process configuration information + + Preparation of the data + + Args: + dict(configs): Container configurations + Kwargs: + None + Raises: + None + Returns: + None""" + # create branch "inventory" + if 'inventory' not in self.data: + self.data['inventory'] = {} + + for container_name in self.data['containers']: + self._set_data_entry(container_name, 'os', self._get_data_entry( + 'containers/{0}/containers/metadata/config/image.os'.format(container_name))) + self._set_data_entry(container_name, 'release', self._get_data_entry( + 'containers/{0}/containers/metadata/config/image.release'.format(container_name))) + self._set_data_entry(container_name, 'version', self._get_data_entry( + 'containers/{0}/containers/metadata/config/image.version'.format(container_name))) + self._set_data_entry(container_name, 'profile', self._get_data_entry( + 'containers/{0}/containers/metadata/profiles'.format(container_name))) + self._set_data_entry(container_name, 'location', self._get_data_entry( + 'containers/{0}/containers/metadata/location'.format(container_name))) + self._set_data_entry(container_name, 'state', self._get_data_entry( + 'containers/{0}/containers/metadata/config/volatile.last_state.power'.format(container_name))) + self._set_data_entry(container_name, 'network_interfaces', self.extract_network_information_from_container_config(container_name)) + self._set_data_entry(container_name, 'preferred_interface', self.get_prefered_container_network_interface(container_name)) + self._set_data_entry(container_name, 'vlan_ids', self.get_container_vlans(container_name)) + + def build_inventory_network(self, container_name): + """Add the network interfaces of the container to the inventory + + Logic: + - if the container have no interface -> 'ansible_connection: local' + - get preferred_interface & prefered_container_network_family -> 'ansible_connection: ssh' & 'ansible_host: ' + - first Interface from: network_interfaces prefered_container_network_family -> 'ansible_connection: ssh' & 'ansible_host: ' + + Args: + str(container_name): name of container + Kwargs: + None + Raises: + None + Returns: + None""" + + def interface_selection(container_name): + """Select container Interface for inventory + + Logic: + - get preferred_interface & prefered_container_network_family -> str(IP) + - first Interface from: network_interfaces prefered_container_network_family -> str(IP) + + Args: + str(container_name): name of container + Kwargs: + None + Raises: + None + Returns: + dict(interface_name: ip)""" + prefered_interface = self._get_data_entry('inventory/{0}/preferred_interface'.format(container_name)) # name or None + prefered_container_network_family = self.prefered_container_network_family + + ip_address = '' + if prefered_interface: + interface = self._get_data_entry('inventory/{0}/network_interfaces/{1}'.format(container_name, prefered_interface)) + for config in interface: + if config['family'] == prefered_container_network_family: + ip_address = config['address'] + break + else: + interface = self._get_data_entry('inventory/{0}/network_interfaces'.format(container_name)) + for config in interface: + if config['family'] == prefered_container_network_family: + ip_address = config['address'] + break + return ip_address + + if self._get_data_entry('inventory/{0}/network_interfaces'.format(container_name)): # container have network interfaces + if self._get_data_entry('inventory/{0}/preferred_interface'.format(container_name)): # container have a preferred interface + self.inventory.set_variable(container_name, 'ansible_connection', 'ssh') + self.inventory.set_variable(container_name, 'ansible_host', interface_selection(container_name)) + else: + self.inventory.set_variable(container_name, 'ansible_connection', 'local') + + def build_inventory_hosts(self): + """Build host-part dynamic inventory + + Build the host-part of the dynamic inventory. + Add Hosts and host_vars to the inventory. + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + for container_name in self.data['inventory']: + # Only consider containers that match the "state" filter, if self.state is not None + if self.filter: + if self.filter.lower() != self._get_data_entry('inventory/{0}/state'.format(container_name)).lower(): + continue + # add container + self.inventory.add_host(container_name) + # add network informations + self.build_inventory_network(container_name) + # add os + self.inventory.set_variable(container_name, 'ansible_lxd_os', self._get_data_entry('inventory/{0}/os'.format(container_name)).lower()) + # add release + self.inventory.set_variable(container_name, 'ansible_lxd_release', self._get_data_entry('inventory/{0}/release'.format(container_name)).lower()) + # add profile + self.inventory.set_variable(container_name, 'ansible_lxd_profile', self._get_data_entry('inventory/{0}/profile'.format(container_name))) + # add state + self.inventory.set_variable(container_name, 'ansible_lxd_state', self._get_data_entry('inventory/{0}/state'.format(container_name)).lower()) + # add location information + if self._get_data_entry('inventory/{0}/location'.format(container_name)) != "none": # wrong type by lxd 'none' != 'None' + self.inventory.set_variable(container_name, 'ansible_lxd_location', self._get_data_entry('inventory/{0}/location'.format(container_name))) + # add VLAN_ID information + if self._get_data_entry('inventory/{0}/vlan_ids'.format(container_name)): + self.inventory.set_variable(container_name, 'ansible_lxd_vlan_ids', self._get_data_entry('inventory/{0}/vlan_ids'.format(container_name))) + + def build_inventory_groups_location(self, group_name): + """create group by attribute: location + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + for container_name in self.inventory.hosts: + if 'ansible_lxd_location' in self.inventory.get_host(container_name).get_vars(): + self.inventory.add_child(group_name, container_name) + + def build_inventory_groups_pattern(self, group_name): + """create group by name pattern + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + regex_pattern = self.groupby[group_name].get('attribute') + + for container_name in self.inventory.hosts: + result = re.search(regex_pattern, container_name) + if result: + self.inventory.add_child(group_name, container_name) + + def build_inventory_groups_network_range(self, group_name): + """check if IP is in network-class + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + try: + network = ipaddress.ip_network(to_text(self.groupby[group_name].get('attribute'))) + except ValueError as err: + raise AnsibleParserError( + 'Error while parsing network range {0}: {1}'.format(self.groupby[group_name].get('attribute'), to_native(err))) + + for container_name in self.inventory.hosts: + if self.data['inventory'][container_name].get('network_interfaces') is not None: + for interface in self.data['inventory'][container_name].get('network_interfaces'): + for interface_family in self.data['inventory'][container_name].get('network_interfaces')[interface]: + try: + address = ipaddress.ip_address(to_text(interface_family['address'])) + if address.version == network.version and address in network: + self.inventory.add_child(group_name, container_name) + except ValueError: + # Ignore invalid IP addresses returned by lxd + pass + + def build_inventory_groups_os(self, group_name): + """create group by attribute: os + + Args: + str(group_name): Group name + Kwargs: + Noneself.data['inventory'][container_name][interface] + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_containers = [ + container_name for container_name in self.inventory.hosts + if 'ansible_lxd_os' in self.inventory.get_host(container_name).get_vars()] + for container_name in gen_containers: + if self.groupby[group_name].get('attribute').lower() == self.inventory.get_host(container_name).get_vars().get('ansible_lxd_os'): + self.inventory.add_child(group_name, container_name) + + def build_inventory_groups_release(self, group_name): + """create group by attribute: release + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_containers = [ + container_name for container_name in self.inventory.hosts + if 'ansible_lxd_release' in self.inventory.get_host(container_name).get_vars()] + for container_name in gen_containers: + if self.groupby[group_name].get('attribute').lower() == self.inventory.get_host(container_name).get_vars().get('ansible_lxd_release'): + self.inventory.add_child(group_name, container_name) + + def build_inventory_groups_profile(self, group_name): + """create group by attribute: profile + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_containers = [ + container_name for container_name in self.inventory.hosts.keys() + if 'ansible_lxd_profile' in self.inventory.get_host(container_name).get_vars().keys()] + for container_name in gen_containers: + if self.groupby[group_name].get('attribute').lower() in self.inventory.get_host(container_name).get_vars().get('ansible_lxd_profile'): + self.inventory.add_child(group_name, container_name) + + def build_inventory_groups_vlanid(self, group_name): + """create group by attribute: vlanid + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_containers = [ + container_name for container_name in self.inventory.hosts.keys() + if 'ansible_lxd_vlan_ids' in self.inventory.get_host(container_name).get_vars().keys()] + for container_name in gen_containers: + if self.groupby[group_name].get('attribute') in self.inventory.get_host(container_name).get_vars().get('ansible_lxd_vlan_ids').values(): + self.inventory.add_child(group_name, container_name) + + def build_inventory_groups(self): + """Build group-part dynamic inventory + + Build the group-part of the dynamic inventory. + Add groups to the inventory. + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + + def group_type(group_name): + """create groups defined by lxd.yml or defaultvalues + + create groups defined by lxd.yml or defaultvalues + supportetd: + * 'location' + * 'pattern' + * 'network_range' + * 'os' + * 'release' + * 'profile' + * 'vlanid' + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + + # Due to the compatibility with python 2 no use of map + if self.groupby[group_name].get('type') == 'location': + self.build_inventory_groups_location(group_name) + elif self.groupby[group_name].get('type') == 'pattern': + self.build_inventory_groups_pattern(group_name) + elif self.groupby[group_name].get('type') == 'network_range': + self.build_inventory_groups_network_range(group_name) + elif self.groupby[group_name].get('type') == 'os': + self.build_inventory_groups_os(group_name) + elif self.groupby[group_name].get('type') == 'release': + self.build_inventory_groups_release(group_name) + elif self.groupby[group_name].get('type') == 'profile': + self.build_inventory_groups_profile(group_name) + elif self.groupby[group_name].get('type') == 'vlanid': + self.build_inventory_groups_vlanid(group_name) + else: + raise AnsibleParserError('Unknown group type: {0}'.format(to_native(group_name))) + + if self.groupby: + for group_name in self.groupby: + if not group_name.isalnum(): + raise AnsibleParserError('Invalid character(s) in groupname: {0}'.format(to_native(group_name))) + group_type(group_name) + + def build_inventory(self): + """Build dynamic inventory + + Build the dynamic inventory. + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + + self.build_inventory_hosts() + self.build_inventory_groups() + + def _populate(self): + """Return the hosts and groups + + Returns the processed container configurations from the lxd import + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + + if len(self.data) == 0: # If no data is injected by unittests open socket + self.socket = self._connect_to_socket() + self.get_container_data(self._get_containers()) + self.get_network_data(self._get_networks()) + + self.extract_information_from_container_configs() + + # self.display.vvv(self.save_json_data([os.path.abspath(__file__)])) + + self.build_inventory() + + def parse(self, inventory, loader, path, cache): + """Return dynamic inventory from source + + Returns the processed inventory from the lxd import + + Args: + str(inventory): inventory object with existing data and + the methods to add hosts/groups/variables + to inventory + str(loader): Ansible's DataLoader + str(path): path to the config + bool(cache): use or avoid caches + Kwargs: + None + Raises: + AnsibleParserError + Returns: + None""" + + super(InventoryModule, self).parse(inventory, loader, path, cache=False) + # Read the inventory YAML file + self._read_config_data(path) + try: + self.client_key = self.get_option('client_key') + self.client_cert = self.get_option('client_cert') + self.debug = self.DEBUG + self.data = {} # store for inventory-data + self.groupby = self.get_option('groupby') + self.plugin = self.get_option('plugin') + self.prefered_container_network_family = self.get_option('prefered_container_network_family') + self.prefered_container_network_interface = self.get_option('prefered_container_network_interface') + if self.get_option('state').lower() == 'none': # none in config is str() + self.filter = None + else: + self.filter = self.get_option('state').lower() + self.trust_password = self.get_option('trust_password') + self.url = self.get_option('url') + except Exception as err: + raise AnsibleParserError( + 'All correct options required: {0}'.format(to_native(err))) + # Call our internal helper to populate the dynamic inventory + self._populate() diff --git a/tests/unit/plugins/inventory/fixtures/lxd_inventory.atd b/tests/unit/plugins/inventory/fixtures/lxd_inventory.atd new file mode 100644 index 0000000000..b308243228 --- /dev/null +++ b/tests/unit/plugins/inventory/fixtures/lxd_inventory.atd @@ -0,0 +1,174 @@ +{ + "containers":{ + "vlantest":{ + "containers":{ + "metadata":{ + "config":{ + "image.os":"ubuntu", + "image.release":"focal", + "image.version":"20.04", + "volatile.last_state.power":"RUNNING" + }, + "devices":{ + "eth0":{ + "name":"eth0", + "network":"my-macvlan", + "type":"nic" + } + }, + "profiles":[ + "default" + ], + "expanded_devices":{ + "eth0":{ + "name":"eth0", + "network":"my-macvlan", + "type":"nic" + } + }, + "name":"vlantest", + "status":"Running", + "location":"Berlin" + } + }, + "state":{ + "metadata":{ + "status":"Running", + "network":{ + "eth0":{ + "addresses":[ + { + "family":"inet", + "address":"10.98.143.199", + "netmask":"24", + "scope":"global" + }, + { + "family":"inet6", + "address":"fd42:bd00:7b11:2167:216:3eff:fe78:2ef3", + "netmask":"64", + "scope":"global" + }, + { + "family":"inet6", + "address":"fe80::216:3eff:fed3:7af3", + "netmask":"64", + "scope":"link" + } + ] + }, + "lo":{ + "addresses":[ + { + "family":"inet", + "address":"127.0.0.1", + "netmask":"8", + "scope":"local" + }, + { + "family":"inet6", + "address":"::1", + "netmask":"128", + "scope":"local" + } + ] + } + } + } + } + } + }, + + "networks":{ + "my-macvlan":{ + "state":{ + "metadata":{ + "addresses":[ + { + "family":"inet", + "address":"192.168.178.199", + "netmask":"24", + "scope":"global" + }, + { + "family":"inet6", + "address":"fd42:bd00:7b11:2167:216:3eff:fe78:2ef3", + "netmask":"64", + "scope":"global" + }, + { + "family":"inet6", + "address":"fe80::216:3eff:fed3:7af3", + "netmask":"64", + "scope":"link" + } + ], + "vlan":{ + "lower_device":"eno1", + "vid":666 + } + } + } + }, + "lo":{ + "state":{ + "metadata":{ + "addresses":[ + { + "family":"inet", + "address":"127.0.0.1", + "netmask":"8", + "scope":"local" + }, + { + "family":"inet6", + "address":"::1", + "netmask":"128", + "scope":"local" + } + ], + "vlan":null + } + } + }, + "eno1":{ + "state":{ + "metadata":{ + "addresses":[ + { + "family":"inet", + "address":"192.168.178.126", + "netmask":"24", + "scope":"global" + }, + { + "family":"inet6", + "address":"fe80::3c0b:7da9:3cc7:9e40", + "netmask":"64", + "scope":"link" + } + ], + "vlan":null + } + } + }, + "eno1.666":{ + "state":{ + "metadata":{ + "addresses":[ + { + "family":"inet6", + "address":"fe80::de4a:3eff:fe8d:f356", + "netmask":"64", + "scope":"link" + } + ], + "vlan":{ + "lower_device":"eno1", + "vid":666 + } + } + } + } + } +} diff --git a/tests/unit/plugins/inventory/test_lxd.py b/tests/unit/plugins/inventory/test_lxd.py new file mode 100644 index 0000000000..8a98af6e71 --- /dev/null +++ b/tests/unit/plugins/inventory/test_lxd.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2021, Frank Dornheim +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest +import os + +from ansible.errors import AnsibleError +from ansible.inventory.data import InventoryData +from ansible_collections.community.general.plugins.inventory.lxd import InventoryModule + + +HOST_COMPARATIVE_DATA = { + 'ansible_connection': 'ssh', 'ansible_host': '10.98.143.199', 'ansible_lxd_os': 'ubuntu', 'ansible_lxd_release': 'focal', + 'ansible_lxd_profile': ['default'], 'ansible_lxd_state': 'running', 'ansible_lxd_location': 'Berlin', + 'ansible_lxd_vlan_ids': {'my-macvlan': 666}, 'inventory_hostname': 'vlantest', 'inventory_hostname_short': 'vlantest'} +GROUP_COMPARATIVE_DATA = { + 'all': [], 'ungrouped': [], 'testpattern': ['vlantest'], 'vlan666': ['vlantest'], 'locationBerlin': ['vlantest'], + 'osUbuntu': ['vlantest'], 'releaseFocal': ['vlantest'], 'releaseBionic': [], 'profileDefault': ['vlantest'], + 'profileX11': [], 'netRangeIPv4': ['vlantest'], 'netRangeIPv6': ['vlantest']} +GROUP_Config = { + 'testpattern': {'type': 'pattern', 'attribute': 'test'}, + 'vlan666': {'type': 'vlanid', 'attribute': 666}, + 'locationBerlin': {'type': 'location', 'attribute': 'Berlin'}, + 'osUbuntu': {'type': 'os', 'attribute': 'ubuntu'}, + 'releaseFocal': {'type': 'release', 'attribute': 'focal'}, + 'releaseBionic': {'type': 'release', 'attribute': 'bionic'}, + 'profileDefault': {'type': 'profile', 'attribute': 'default'}, + 'profileX11': {'type': 'profile', 'attribute': 'x11'}, + 'netRangeIPv4': {'type': 'network_range', 'attribute': '10.98.143.0/24'}, + 'netRangeIPv6': {'type': 'network_range', 'attribute': 'fd42:bd00:7b11:2167:216:3eff::/96'}} + + +@pytest.fixture +def inventory(): + inv = InventoryModule() + inv.inventory = InventoryData() + + # Test Values + inv.data = inv.load_json_data('tests/unit/plugins/inventory/fixtures/lxd_inventory.atd') # Load Test Data + inv.groupby = GROUP_Config + inv.prefered_container_network_interface = 'eth' + inv.prefered_container_network_family = 'inet' + inv.filter = 'running' + inv.dump_data = False + + return inv + + +def test_verify_file_bad_config(inventory): + assert inventory.verify_file('foobar.lxd.yml') is False + + +def test_build_inventory_hosts(inventory): + """Load example data and start the inventoryto test the host generation. + + After the inventory plugin has run with the test data, the result of the host is checked.""" + inventory._populate() + generated_data = inventory.inventory.get_host('vlantest').get_vars() + + eq = True + for key, value in HOST_COMPARATIVE_DATA.items(): + if generated_data[key] != value: + eq = False + assert eq + + +def test_build_inventory_groups(inventory): + """Load example data and start the inventory to test the group generation. + + After the inventory plugin has run with the test data, the result of the host is checked.""" + inventory._populate() + generated_data = inventory.inventory.get_groups_dict() + + eq = True + for key, value in GROUP_COMPARATIVE_DATA.items(): + if generated_data[key] != value: + eq = False + assert eq + + +def test_build_inventory_groups_with_no_groupselection(inventory): + """Load example data and start the inventory to test the group generation with groupby is none. + + After the inventory plugin has run with the test data, the result of the host is checked.""" + inventory.groupby = None + inventory._populate() + generated_data = inventory.inventory.get_groups_dict() + group_comparative_data = {'all': [], 'ungrouped': []} + + eq = True + print("data: {0}".format(generated_data)) + for key, value in group_comparative_data.items(): + if generated_data[key] != value: + eq = False + assert eq