Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New inventory module: Proxmox #545

Merged
merged 37 commits into from
Aug 21, 2020
Merged
Changes from 12 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
0cc12b2
This commit adds proxmox inventory module and proxmox_snap for snapsh…
Thulium-Drake Jun 18, 2020
81fc0b0
Fixed pylint errors
Thulium-Drake Jun 18, 2020
438ae35
Missed this one..
Thulium-Drake Jun 18, 2020
12133c0
This should fix the doc errors
Thulium-Drake Jun 19, 2020
a33f79d
Remove proxmox_snap to allow for single module per PR
Thulium-Drake Jun 20, 2020
852345f
Changes as suggested by felixfontein in #535
Thulium-Drake Jun 20, 2020
eff47cd
Reverted back to AnsibleError as module.fail_json broke it. Need to i…
Thulium-Drake Jun 20, 2020
3913339
Made importerror behave similar to docker_swarm and gitlab_runner
Thulium-Drake Jun 20, 2020
47244ce
FALSE != False
Thulium-Drake Jun 20, 2020
1eff97c
Added myself as author
Thulium-Drake Jun 20, 2020
09a2802
Added a requested feature from a colleague to also sort VMs based on …
Thulium-Drake Jun 20, 2020
6d2cd8d
Prevent VM templates from being added to the inventory
Thulium-Drake Jun 20, 2020
1e0b0f5
Processed feedback
Thulium-Drake Jul 31, 2020
c9ada5e
Updated my email and included version
Thulium-Drake Jul 31, 2020
c687e48
Processed doc feedback
Thulium-Drake Aug 5, 2020
055518c
More feedback processed
Thulium-Drake Aug 5, 2020
c8b2392
Shortened this line of documentation, it is a duplicate and it was ca…
Thulium-Drake Aug 6, 2020
aab6bbf
Added test from PR #736 to check what needs to be changed to make it …
Thulium-Drake Aug 6, 2020
927f844
Changed some tests around
Thulium-Drake Aug 6, 2020
c8d326c
Remove some tests, first get these working
Thulium-Drake Aug 6, 2020
5846e1c
Disabled all tests, except the one I am hacking together now
Thulium-Drake Aug 6, 2020
f4dc3da
Added mocker, still trying to figure this out
Thulium-Drake Aug 6, 2020
e4069b2
Am I looking in the right direction?
Thulium-Drake Aug 6, 2020
e5ba2de
Processed docs feedback
Thulium-Drake Aug 11, 2020
976d34b
Fixed bot feedback
Thulium-Drake Aug 17, 2020
88f9519
Removed all other tests, started with basic ones (borrowed from cobbler)
Thulium-Drake Aug 17, 2020
8eee828
Removed all other tests, started with basic ones (borrowed from cobbler)
Thulium-Drake Aug 17, 2020
6d1a1df
Removed all other tests, started with basic ones (borrowed from cobbler)
Thulium-Drake Aug 17, 2020
5b5020b
Removed init_cache test as it is implemented on a different way in th…
Thulium-Drake Aug 18, 2020
cafaa69
This actually passes! Need to check if I need to add asserts as well
Thulium-Drake Aug 19, 2020
a3e86c2
Made bot happy again?
Thulium-Drake Aug 19, 2020
9442b4d
Added some assertions
Thulium-Drake Aug 19, 2020
b7e8730
Added note about PVE API version
Thulium-Drake Aug 19, 2020
e9ddb22
Mocked only get_json, the rest functions as-is
Thulium-Drake Aug 20, 2020
4f856a3
Fixed sanity errors
Thulium-Drake Aug 20, 2020
7a65b0c
Fixed version bump (again...) ;-)
Thulium-Drake Aug 20, 2020
86a8085
Processed feedback
Thulium-Drake Aug 20, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
350 changes: 350 additions & 0 deletions plugins/inventory/proxmox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
# -*- 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.0.0"
author:
- Jeffrey van Pelt (@Thulium-Drake) <[email protected]>
requirements:
- requests >= 1.1
description:
- Get inventory hosts from the proxmox service.
- "Uses a configuration file as an inventory source, it must end in ``.proxmox.yml`` or ``.proxmox.yaml`` and has a ``plugin: proxmox`` entry."
- 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 alwys be set to 'proxmox' for this plugin to recognize it as it's own.
required: True
choices: ['proxmox']
type: str
url:
description: url to proxmox
default: 'http://localhost:8006'
type: str
user:
description: proxmox authentication user
required: True
type: str
password:
description: proxmox authentication password
required: True
type: str
validate_certs:
description: verify SSL certificate if using https
type: boolean
default: False
group_prefix:
description: prefix to apply to proxmox groups
default: proxmox_
type: str
facts_prefix:
description: prefix to apply to vm config facts
default: proxmox_
type: str
want_facts:
description: gather vm configuration facts
default: False
type: bool
'''

EXAMPLES = '''
# my.proxmox.yml
plugin: proxmox
url: http://localhost:8006
user: ansible-tester
password: secure
validate_certs: False
'''

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 = '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 dos 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']

# if we want the fact, then set it
if self.get_option('want_facts'):
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
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()