-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
New inventory module: Proxmox (#545)
* This commit adds proxmox inventory module and proxmox_snap for snapshot management * Fixed pylint errors * Missed this one.. * This should fix the doc errors * Remove proxmox_snap to allow for single module per PR * Changes as suggested by felixfontein in #535 * Reverted back to AnsibleError as module.fail_json broke it. Need to investigate further * Made importerror behave similar to docker_swarm and gitlab_runner * FALSE != False * Added myself as author * Added a requested feature from a colleague to also sort VMs based on their running state * Prevent VM templates from being added to the inventory * Processed feedback * Updated my email and included version * Processed doc feedback * More feedback processed * Shortened this line of documentation, it is a duplicate and it was causing a sanity error (> 160 characters) * Added test from PR #736 to check what needs to be changed to make it work * Changed some tests around * Remove some tests, first get these working * Disabled all tests, except the one I am hacking together now * Added mocker, still trying to figure this out * Am I looking in the right direction? * Processed docs feedback * Fixed bot feedback * Removed all other tests, started with basic ones (borrowed from cobbler) * Removed all other tests, started with basic ones (borrowed from cobbler) * Removed all other tests, started with basic ones (borrowed from cobbler) * Removed init_cache test as it is implemented on a different way in the original foreman/satellite inventory (and thus also this one) * This actually passes! Need to check if I need to add asserts as well * Made bot happy again? * Added some assertions * Added note about PVE API version * Mocked only get_json, the rest functions as-is * Fixed sanity errors * Fixed version bump (again...) ;-) * Processed feedback
- Loading branch information
1 parent
f3b82a9
commit 73be912
Showing
2 changed files
with
535 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,348 @@ | ||
# -*- coding: utf-8 -*- | ||
# Copyright (C) 2016 Guido Günther <[email protected]>, Daniel Lobato Garcia <[email protected]> | ||
# Copyright (c) 2018 Ansible Project | ||
# 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 = ''' | ||
name: proxmox | ||
plugin_type: inventory | ||
short_description: Proxmox inventory source | ||
version_added: "1.2.0" | ||
author: | ||
- Jeffrey van Pelt (@Thulium-Drake) <[email protected]> | ||
requirements: | ||
- requests >= 1.1 | ||
description: | ||
- Get inventory hosts from a Proxmox PVE cluster. | ||
- "Uses a configuration file as an inventory source, it must end in C(.proxmox.yml) or C(.proxmox.yaml)" | ||
- Will retrieve the first network interface with an IP for Proxmox nodes. | ||
- Can retrieve LXC/QEMU configuration as facts. | ||
extends_documentation_fragment: | ||
- inventory_cache | ||
options: | ||
plugin: | ||
description: The name of this plugin, it should always be set to C(community.general.proxmox) for this plugin to recognize it as it's own. | ||
required: yes | ||
choices: ['community.general.proxmox'] | ||
type: str | ||
url: | ||
description: URL to Proxmox cluster. | ||
default: 'http://localhost:8006' | ||
type: str | ||
user: | ||
description: Proxmox authentication user. | ||
required: yes | ||
type: str | ||
password: | ||
description: Proxmox authentication password. | ||
required: yes | ||
type: str | ||
validate_certs: | ||
description: Verify SSL certificate if using HTTPS. | ||
type: boolean | ||
default: yes | ||
group_prefix: | ||
description: Prefix to apply to Proxmox groups. | ||
default: proxmox_ | ||
type: str | ||
facts_prefix: | ||
description: Prefix to apply to LXC/QEMU config facts. | ||
default: proxmox_ | ||
type: str | ||
want_facts: | ||
description: Gather LXC/QEMU configuration facts. | ||
default: no | ||
type: bool | ||
''' | ||
|
||
EXAMPLES = ''' | ||
# my.proxmox.yml | ||
plugin: community.general.proxmox | ||
url: http://localhost:8006 | ||
user: ansible@pve | ||
password: secure | ||
validate_certs: no | ||
''' | ||
|
||
import re | ||
|
||
from ansible.module_utils.common._collections_compat import MutableMapping | ||
from distutils.version import LooseVersion | ||
|
||
from ansible.errors import AnsibleError | ||
from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable | ||
from ansible.module_utils.six.moves.urllib.parse import urlencode | ||
|
||
# 3rd party imports | ||
try: | ||
import requests | ||
if LooseVersion(requests.__version__) < LooseVersion('1.1.0'): | ||
raise ImportError | ||
HAS_REQUESTS = True | ||
except ImportError: | ||
HAS_REQUESTS = False | ||
|
||
|
||
class InventoryModule(BaseInventoryPlugin, Cacheable): | ||
''' Host inventory parser for ansible using Proxmox as source. ''' | ||
|
||
NAME = 'community.general.proxmox' | ||
|
||
def __init__(self): | ||
|
||
super(InventoryModule, self).__init__() | ||
|
||
# from config | ||
self.proxmox_url = None | ||
|
||
self.session = None | ||
self.cache_key = None | ||
self.use_cache = None | ||
|
||
def verify_file(self, path): | ||
|
||
valid = False | ||
if super(InventoryModule, self).verify_file(path): | ||
if path.endswith(('proxmox.yaml', 'proxmox.yml')): | ||
valid = True | ||
else: | ||
self.display.vvv('Skipping due to inventory source not ending in "proxmox.yaml" nor "proxmox.yml"') | ||
return valid | ||
|
||
def _get_session(self): | ||
if not self.session: | ||
self.session = requests.session() | ||
self.session.verify = self.get_option('validate_certs') | ||
return self.session | ||
|
||
def _get_auth(self): | ||
credentials = urlencode({'username': self.proxmox_user, 'password': self.proxmox_password, }) | ||
|
||
a = self._get_session() | ||
ret = a.post('%s/api2/json/access/ticket' % self.proxmox_url, data=credentials) | ||
|
||
json = ret.json() | ||
|
||
self.credentials = { | ||
'ticket': json['data']['ticket'], | ||
'CSRFPreventionToken': json['data']['CSRFPreventionToken'], | ||
} | ||
|
||
def _get_json(self, url, ignore_errors=None): | ||
|
||
if not self.use_cache or url not in self._cache.get(self.cache_key, {}): | ||
|
||
if self.cache_key not in self._cache: | ||
self._cache[self.cache_key] = {'url': ''} | ||
|
||
data = [] | ||
s = self._get_session() | ||
while True: | ||
headers = {'Cookie': 'PVEAuthCookie={0}'.format(self.credentials['ticket'])} | ||
ret = s.get(url, headers=headers) | ||
if ignore_errors and ret.status_code in ignore_errors: | ||
break | ||
ret.raise_for_status() | ||
json = ret.json() | ||
|
||
# process results | ||
# FIXME: This assumes 'return type' matches a specific query, | ||
# it will break if we expand the queries and they dont have different types | ||
if 'data' not in json: | ||
# /hosts/:id does not have a 'data' key | ||
data = json | ||
break | ||
elif isinstance(json['data'], MutableMapping): | ||
# /facts are returned as dict in 'data' | ||
data = json['data'] | ||
break | ||
else: | ||
# /hosts 's 'results' is a list of all hosts, returned is paginated | ||
data = data + json['data'] | ||
break | ||
|
||
self._cache[self.cache_key][url] = data | ||
|
||
return self._cache[self.cache_key][url] | ||
|
||
def _get_nodes(self): | ||
return self._get_json("%s/api2/json/nodes" % self.proxmox_url) | ||
|
||
def _get_pools(self): | ||
return self._get_json("%s/api2/json/pools" % self.proxmox_url) | ||
|
||
def _get_lxc_per_node(self, node): | ||
return self._get_json("%s/api2/json/nodes/%s/lxc" % (self.proxmox_url, node)) | ||
|
||
def _get_qemu_per_node(self, node): | ||
return self._get_json("%s/api2/json/nodes/%s/qemu" % (self.proxmox_url, node)) | ||
|
||
def _get_members_per_pool(self, pool): | ||
ret = self._get_json("%s/api2/json/pools/%s" % (self.proxmox_url, pool)) | ||
return ret['members'] | ||
|
||
def _get_node_ip(self, node): | ||
ret = self._get_json("%s/api2/json/nodes/%s/network" % (self.proxmox_url, node)) | ||
|
||
for iface in ret: | ||
try: | ||
return iface['address'] | ||
except Exception: | ||
return None | ||
|
||
def _get_vm_config(self, node, vmid, vmtype, name): | ||
ret = self._get_json("%s/api2/json/nodes/%s/%s/%s/config" % (self.proxmox_url, node, vmtype, vmid)) | ||
|
||
vmid_key = 'vmid' | ||
vmid_key = self.to_safe('%s%s' % (self.get_option('facts_prefix'), vmid_key.lower())) | ||
self.inventory.set_variable(name, vmid_key, vmid) | ||
|
||
vmtype_key = 'vmtype' | ||
vmtype_key = self.to_safe('%s%s' % (self.get_option('facts_prefix'), vmtype_key.lower())) | ||
self.inventory.set_variable(name, vmtype_key, vmtype) | ||
|
||
for config in ret: | ||
key = config | ||
key = self.to_safe('%s%s' % (self.get_option('facts_prefix'), key.lower())) | ||
value = ret[config] | ||
try: | ||
# fixup disk images as they have no key | ||
if config == 'rootfs' or config.startswith(('virtio', 'sata', 'ide', 'scsi')): | ||
value = ('disk_image=' + value) | ||
|
||
if isinstance(value, int) or ',' not in value: | ||
value = value | ||
# split off strings with commas to a dict | ||
else: | ||
# skip over any keys that cannot be processed | ||
try: | ||
value = dict(key.split("=") for key in value.split(",")) | ||
except Exception: | ||
continue | ||
|
||
self.inventory.set_variable(name, key, value) | ||
except NameError: | ||
return None | ||
|
||
def _get_vm_status(self, node, vmid, vmtype, name): | ||
ret = self._get_json("%s/api2/json/nodes/%s/%s/%s/status/current" % (self.proxmox_url, node, vmtype, vmid)) | ||
|
||
status = ret['status'] | ||
status_key = 'status' | ||
status_key = self.to_safe('%s%s' % (self.get_option('facts_prefix'), status_key.lower())) | ||
self.inventory.set_variable(name, status_key, status) | ||
|
||
def to_safe(self, word): | ||
'''Converts 'bad' characters in a string to underscores so they can be used as Ansible groups | ||
#> ProxmoxInventory.to_safe("foo-bar baz") | ||
'foo_barbaz' | ||
''' | ||
regex = r"[^A-Za-z0-9\_]" | ||
return re.sub(regex, "_", word.replace(" ", "")) | ||
|
||
def _populate(self): | ||
|
||
self._get_auth() | ||
|
||
# gather vm's on nodes | ||
for node in self._get_nodes(): | ||
# FIXME: this can probably be cleaner | ||
# create groups | ||
lxc_group = 'all_lxc' | ||
lxc_group = self.to_safe('%s%s' % (self.get_option('group_prefix'), lxc_group.lower())) | ||
self.inventory.add_group(lxc_group) | ||
qemu_group = 'all_qemu' | ||
qemu_group = self.to_safe('%s%s' % (self.get_option('group_prefix'), qemu_group.lower())) | ||
self.inventory.add_group(qemu_group) | ||
nodes_group = 'nodes' | ||
nodes_group = self.to_safe('%s%s' % (self.get_option('group_prefix'), nodes_group.lower())) | ||
self.inventory.add_group(nodes_group) | ||
running_group = 'all_running' | ||
running_group = self.to_safe('%s%s' % (self.get_option('group_prefix'), running_group.lower())) | ||
self.inventory.add_group(running_group) | ||
stopped_group = 'all_stopped' | ||
stopped_group = self.to_safe('%s%s' % (self.get_option('group_prefix'), stopped_group.lower())) | ||
self.inventory.add_group(stopped_group) | ||
|
||
if node.get('node'): | ||
self.inventory.add_host(node['node']) | ||
|
||
if node['type'] == 'node': | ||
self.inventory.add_child(nodes_group, node['node']) | ||
|
||
# get node IP address | ||
ip = self._get_node_ip(node['node']) | ||
self.inventory.set_variable(node['node'], 'ansible_host', ip) | ||
|
||
# get LXC containers for this node | ||
node_lxc_group = self.to_safe('%s%s' % (self.get_option('group_prefix'), ('%s_lxc' % node['node']).lower())) | ||
self.inventory.add_group(node_lxc_group) | ||
for lxc in self._get_lxc_per_node(node['node']): | ||
self.inventory.add_host(lxc['name']) | ||
self.inventory.add_child(lxc_group, lxc['name']) | ||
self.inventory.add_child(node_lxc_group, lxc['name']) | ||
|
||
# get LXC status when want_facts == True | ||
if self.get_option('want_facts'): | ||
self._get_vm_status(node['node'], lxc['vmid'], 'lxc', lxc['name']) | ||
if lxc['status'] == 'stopped': | ||
self.inventory.add_child(stopped_group, lxc['name']) | ||
elif lxc['status'] == 'running': | ||
self.inventory.add_child(running_group, lxc['name']) | ||
|
||
# get LXC config for facts | ||
if self.get_option('want_facts'): | ||
self._get_vm_config(node['node'], lxc['vmid'], 'lxc', lxc['name']) | ||
|
||
# get QEMU vm's for this node | ||
node_qemu_group = self.to_safe('%s%s' % (self.get_option('group_prefix'), ('%s_qemu' % node['node']).lower())) | ||
self.inventory.add_group(node_qemu_group) | ||
for qemu in self._get_qemu_per_node(node['node']): | ||
if not qemu['template']: | ||
self.inventory.add_host(qemu['name']) | ||
self.inventory.add_child(qemu_group, qemu['name']) | ||
self.inventory.add_child(node_qemu_group, qemu['name']) | ||
|
||
# get QEMU status | ||
self._get_vm_status(node['node'], qemu['vmid'], 'qemu', qemu['name']) | ||
if qemu['status'] == 'stopped': | ||
self.inventory.add_child(stopped_group, qemu['name']) | ||
elif qemu['status'] == 'running': | ||
self.inventory.add_child(running_group, qemu['name']) | ||
|
||
# get QEMU config for facts | ||
if self.get_option('want_facts'): | ||
self._get_vm_config(node['node'], qemu['vmid'], 'qemu', qemu['name']) | ||
|
||
# gather vm's in pools | ||
for pool in self._get_pools(): | ||
if pool.get('poolid'): | ||
pool_group = 'pool_' + pool['poolid'] | ||
pool_group = self.to_safe('%s%s' % (self.get_option('group_prefix'), pool_group.lower())) | ||
self.inventory.add_group(pool_group) | ||
|
||
for member in self._get_members_per_pool(pool['poolid']): | ||
if member.get('name'): | ||
self.inventory.add_child(pool_group, member['name']) | ||
|
||
def parse(self, inventory, loader, path, cache=True): | ||
if not HAS_REQUESTS: | ||
raise AnsibleError('This module requires Python Requests 1.1.0 or higher: ' | ||
'https://github.com/psf/requests.') | ||
|
||
super(InventoryModule, self).parse(inventory, loader, path) | ||
|
||
# read config from file, this sets 'options' | ||
self._read_config_data(path) | ||
|
||
# get connection host | ||
self.proxmox_url = self.get_option('url') | ||
self.proxmox_user = self.get_option('user') | ||
self.proxmox_password = self.get_option('password') | ||
self.cache_key = self.get_cache_key(path) | ||
self.use_cache = cache and self.get_option('cache') | ||
|
||
# actually populate inventory | ||
self._populate() |
Oops, something went wrong.