428 lines
15 KiB
Python
428 lines
15 KiB
Python
"""
|
|
A lookup plugin to query the re2o API.
|
|
|
|
For a detailed example look at https://github.com/ansible/ansible/blob/3dbf89e8aeb80eb2d1484b1cb63458e4bb12795a/lib/ansible/plugins/lookup/aws_ssm.py
|
|
|
|
|
|
The API Client has been adapted from https://gitlab.federez.net/re2o/re2oapi
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import datetime
|
|
import requests
|
|
import stat
|
|
import json
|
|
|
|
from ansible.module_utils._text import to_native
|
|
from ansible.plugins.lookup import LookupBase
|
|
from ansible.errors import (AnsibleError,
|
|
AnsibleFileNotFound,
|
|
AnsibleLookupError,
|
|
)
|
|
from ansible.utils.display import Display
|
|
|
|
# Ansible Logger to stdout
|
|
display = Display()
|
|
|
|
# Number of seconds before expiration where renewing the token is done
|
|
TIME_FOR_RENEW = 120
|
|
# Default name of the file to store tokens. Path $HOME/{DEFAUlt_TOKEN_FILENAME}
|
|
DEFAULT_TOKEN_FILENAME = '.re2o.token'
|
|
|
|
|
|
class Client:
|
|
"""
|
|
Class based client to contact re2o API.
|
|
"""
|
|
def __init__(self, hostname, username, password, use_tls=True):
|
|
"""
|
|
:arg hostname: The hostname of the Re2o instance to use.
|
|
:arg username: The username to use.
|
|
:arg password: The password to use.
|
|
:arg use_tls: A boolean to specify whether the client should use a
|
|
a TLS connection. Default is True. Please, keep it.
|
|
"""
|
|
self.use_tls = use_tls
|
|
self.hostname = hostname
|
|
self._username = username
|
|
self._password = password
|
|
|
|
self.token_file = Path.home() / DEFAULT_TOKEN_FILENAME
|
|
|
|
display.v("Connecting to {hostname} as user {user}".format(
|
|
hostname=to_native(self.hostname), user=to_native(self._username)))
|
|
try:
|
|
self.token = self._get_token_from_file()
|
|
except AnsibleFileNotFound:
|
|
display.vv("Force renew the token")
|
|
self._force_renew_token()
|
|
|
|
def _get_token_from_file(self):
|
|
display.vv("Trying to fetch token from {}".format(self.token_file))
|
|
|
|
# Check if the token file exists
|
|
if not self.token_file.is_file():
|
|
display.vv("Unable to access file {}".format(self.token_file))
|
|
raise AnsibleFileNotFound(file_name=self.token_file)
|
|
|
|
try:
|
|
with self.token_file.open() as f:
|
|
data = json.load(f)
|
|
except Exception as e:
|
|
display.vv("File {} not readable".format(self.token_file))
|
|
display.vvv("Original error was {}".format(to_native(e)))
|
|
raise AnsibleFileNotFound(file_name=self.token_file.as_posix() +
|
|
' (Not readable)')
|
|
|
|
try:
|
|
token_data = data[self.hostname][self._username]
|
|
ret = {
|
|
'token': token_data['token'],
|
|
'expiration': self._parse_date(token_data["expiration"]),
|
|
}
|
|
|
|
except KeyError:
|
|
raise AnsibleLookupError("""Token for {user}@{host} not found
|
|
in token file ({token})""".format(user=self._username,
|
|
host=self.hostname,
|
|
token=self.token_file,
|
|
)
|
|
)
|
|
else:
|
|
display.vv("""Token successfully retreived from
|
|
file {token}""".format(token=self.token_file))
|
|
return ret
|
|
|
|
def _force_renew_token(self):
|
|
self.token = self._get_token_from_server()
|
|
self._save_token_to_file()
|
|
|
|
def _get_token_from_server(self):
|
|
display.vv("Requesting a new token for {user}@{host}".format(
|
|
user=self._username,
|
|
host=self.hostname,
|
|
))
|
|
# Authentication request
|
|
response = requests.post(
|
|
self.get_url_for('token-auth'),
|
|
data={'username': self._username, 'password': self._password},
|
|
)
|
|
display.vv("Response code: {}".format(response.status_code))
|
|
if response.status_code == requests.codes.bad_request:
|
|
display.vv("Please provide valid credentials")
|
|
raise AnsibleLookupError("Unable to connect to the API for {host}"
|
|
.format(host=self.hostname))
|
|
try:
|
|
response.raise_for_status()
|
|
except Exception as e:
|
|
raise AnsibleError("""An error occured while trying to contact
|
|
the API. This was the original exception: {}"""
|
|
.format(to_native(e)))
|
|
|
|
response = response.json()
|
|
ret = {
|
|
'token': response['token'],
|
|
'expiration': self._parse_date(response['expiration']),
|
|
}
|
|
|
|
display.vv("Token successfully retreived for {user}@{host}".format(
|
|
user=self._username,
|
|
host=self.hostname,
|
|
)
|
|
)
|
|
return ret
|
|
|
|
def _parse_date(self, date, date_format="%Y-%m-%dT%H:%M:%S"):
|
|
return datetime.datetime.strptime(date.split('.')[0], date_format)
|
|
|
|
def _save_token_to_file(self):
|
|
display.vv("Saving token to file {}".format(self.token_file))
|
|
try:
|
|
# Read previous data to avoid erasures
|
|
with self.token_file.open() as f:
|
|
data = json.load(f)
|
|
except Exception:
|
|
display.v("""Beware, token file {} was not a valid JSON readable
|
|
file. Considered empty.""".format(self.token_file))
|
|
data = {}
|
|
|
|
if self.hostname not in data.keys():
|
|
data[self.hostname] = {}
|
|
data[self.hostname][self._username] = {
|
|
'token': self.token['token'],
|
|
'expiration': self.token['expiration'].isoformat(),
|
|
}
|
|
|
|
try:
|
|
with self.token_file.open('w') as f:
|
|
json.dump(data, f)
|
|
self.token_file.chmod(stat.S_IWRITE | stat.S_IREAD)
|
|
except Exception as e:
|
|
display.vv("Token file {} could not be written. Passing."
|
|
.format(self.token_file))
|
|
display.vvv("Original error was {}".format(to_native(e)))
|
|
else:
|
|
display.vv("Token successfully written to file {}"
|
|
.format(self.token_file))
|
|
|
|
def get_token(self):
|
|
"""
|
|
Retrieves the token to use for the current connection.
|
|
Automatically renewed if needed.
|
|
"""
|
|
if self.need_renew_token:
|
|
self._force_renew_token()
|
|
|
|
return self.token['token']
|
|
|
|
@property
|
|
def need_renew_token(self):
|
|
return self.token['expiration'] < \
|
|
datetime.datetime.now() + \
|
|
datetime.timedelta(seconds=TIME_FOR_RENEW)
|
|
|
|
def _request(self, method, url, headers={}, params={}, *args, **kwargs):
|
|
display.vv("Building the {method} request to {url}.".format(
|
|
method=method.upper(),
|
|
url=url,
|
|
))
|
|
|
|
# Force the 'Authorization' field with the right token.
|
|
display.vvv("Forcing authentication token.")
|
|
headers.update({
|
|
'Authorization': 'Token {}'.format(self.get_token())
|
|
})
|
|
|
|
# Use a json format unless the user already specified something
|
|
if 'format' not in params.keys():
|
|
display.vvv("Forcing JSON format response.")
|
|
params.update({'format': 'json'})
|
|
|
|
# Perform the request
|
|
display.v("{} {}".format(method.upper(), url))
|
|
response = getattr(requests, method)(
|
|
url, headers=headers, params=params, *args, **kwargs
|
|
)
|
|
display.vvv("Response code: {}".format(response.status_code))
|
|
|
|
if response.status_code == requests.codes.unauthorized:
|
|
# Force re-login to the server (case of a wrong token but valid
|
|
# credentials) and then retry the request without catching errors.
|
|
display.vv("Token refused. Trying to refresh the token.")
|
|
self._force_renew_token()
|
|
|
|
headers.update({
|
|
'Authorization': 'Token {}'.format(self.get_token())
|
|
})
|
|
display.vv("Re-performing the request {method} {url}".format(
|
|
method=method.upper(),
|
|
url=url,
|
|
))
|
|
response = getattr(requests, method)(
|
|
url, headers=headers, params=params, *args, **kwargs
|
|
)
|
|
display.vvv("Response code: ".format(response.status_code))
|
|
|
|
if response.status_code == requests.codes.forbidden:
|
|
err = "The {method} request to {url} was denied for {user}".format(
|
|
method=method.upper(),
|
|
url=url,
|
|
user=self._username
|
|
)
|
|
display.vvv(err)
|
|
raise AnsibleLookupError(to_native(err))
|
|
|
|
try:
|
|
response.raise_for_status()
|
|
except Exception as e:
|
|
raise AnsibleError("""An error occured while trying to contact
|
|
the API. This was the original exception: {}"""
|
|
.format(to_native(e)))
|
|
|
|
ret = response.json()
|
|
display.vvv("{method} request to {url} successful.".format(
|
|
method=method.upper(),
|
|
url=url
|
|
))
|
|
return ret
|
|
|
|
def get_url_for(self, endpoint):
|
|
"""
|
|
Retrieves the complete URL to use for a given endpoint's name.
|
|
"""
|
|
return '{proto}://{host}/{namespace}/{endpoint}'.format(
|
|
proto=('https' if self.use_tls else 'http'),
|
|
host=self.hostname,
|
|
namespace='api',
|
|
endpoint=endpoint
|
|
)
|
|
|
|
def get(self, *args, **kwargs):
|
|
"""
|
|
Perform a GET request to the API
|
|
"""
|
|
return self._request('get', *args, **kwargs)
|
|
|
|
def list(self, endpoint, max_results=None, params={}):
|
|
"""List all objects on the server that corresponds to the given
|
|
endpoint. The endpoint must be valid for listing objects.
|
|
|
|
:arg endpoint: The path of the endpoint.
|
|
:kwarg max_results: A limit on the number of result to return
|
|
:kwarg params: See `requests.get` params.
|
|
:returns: The list of all the objects as returned by the API.
|
|
"""
|
|
display.v("Starting listing objects under '{}'"
|
|
.format(endpoint))
|
|
display.vvv("max_results = {}".format(max_results))
|
|
|
|
# For optimization, list all results in one page unless the user
|
|
# is forcing a different `page_size`.
|
|
if 'page_size' not in params.keys():
|
|
display.vvv("Forcing 'page_size' parameter to 'all'.")
|
|
params['page_size'] = max_results or 'all'
|
|
|
|
# Performs the request for the first page
|
|
response = self.get(
|
|
self.get_url_for(endpoint),
|
|
params=params,
|
|
)
|
|
|
|
results = response['results']
|
|
|
|
# Get all next pages and append the results
|
|
while response['next'] is not None and \
|
|
(max_results is None or len(results) < max_results):
|
|
response = self.get(response['next'])
|
|
results += response['results']
|
|
|
|
# Returns the exact number of results if applicable
|
|
ret = results[:max_results] if max_results else results
|
|
display.vvv("Listing objects under '{}' successful"
|
|
.format(endpoint))
|
|
return ret
|
|
|
|
def count(self, endpoint, params={}):
|
|
"""Counts all objects on the server that corresponds to the given
|
|
endpoint. The endpoint must be valid for listing objects.
|
|
|
|
:arg endpoint: The path of the endpoint.
|
|
:kwarg params: See `requests.get` params.
|
|
:returns: Number of objects on the server as returned by the API.
|
|
"""
|
|
display.v("Starting counting objects under '{}'"
|
|
.format(endpoint))
|
|
|
|
# For optimization, ask for only 1 result (so the server will take
|
|
# less time to process the request) unless the user is forcing
|
|
# a different `page_size`.
|
|
if 'page_size' not in params.keys():
|
|
display.vvv("Forcing 'page_size' parameter to '1'.")
|
|
params['page_size'] = 1
|
|
|
|
# Performs the request and return the `count` value in the response.
|
|
ret = self.get(
|
|
self.get_url_for(endpoint),
|
|
params=params,
|
|
)['count']
|
|
|
|
display.vvv("Counting objects under '{}' successful"
|
|
.format(endpoint))
|
|
return ret
|
|
|
|
def view(self, endpoint, params={}):
|
|
"""Retrieves the details of an object from the server that corresponds
|
|
to the given endpoint.
|
|
|
|
:args endpoint: The path of the endpoint.
|
|
:kwargs params: See `requests.get` params.
|
|
:returns: The object serialized as returned by the API.
|
|
"""
|
|
display.v("Starting viewing an object under '{}'"
|
|
.format(endpoint))
|
|
ret = self.get(
|
|
self.get_url_for(endpoint),
|
|
params=params
|
|
)
|
|
|
|
display.vvv("Viewing object under '{}' successful"
|
|
.format(endpoint))
|
|
return ret
|
|
|
|
|
|
class LookupModule(LookupBase):
|
|
"""
|
|
Available terms =
|
|
- dnszones: Queries the re2o API and returns the list of all dns zones
|
|
nicely formatted to be rendered in a template.
|
|
|
|
If a term is not in the previous list, make a raw query to the API
|
|
with endpoint term.
|
|
|
|
Usage:
|
|
|
|
The following play will use the debug module to output
|
|
all the zone names managed by Crans.
|
|
|
|
- hosts: sputnik.adm.crans.org
|
|
vars:
|
|
dnszones: "{{ lookup('re2oapi', 'dnszones', api_hostname='intranet.crans.org') }}"
|
|
tasks:
|
|
- debug: var=dnszones
|
|
"""
|
|
|
|
def run(self, terms, variables=None, api_hostname=None, api_username=None,
|
|
api_password=None, use_tls=True):
|
|
|
|
"""
|
|
:arg terms: a list of lookups to run
|
|
e.g. ['dnszones']
|
|
:kwarg variables: ansible variables active at the time of the lookup
|
|
:kwarg api_hostname: The hostname of re2o instance.
|
|
:kwarg api_username: The username to connect to the API.
|
|
:kwarg api_password: The password to use to connect to the API.
|
|
:kwarg use_tls: A boolean to specify whether to use tls. You should!
|
|
:returns: A list of results to the specific queries.
|
|
"""
|
|
|
|
if api_hostname is None:
|
|
raise AnsibleError(to_native(
|
|
'You must specify a hostname to contact re2oAPI'
|
|
))
|
|
|
|
if api_username is None and api_password is None:
|
|
api_username = variables.get('vault_re2o_service_user')
|
|
api_password = variables.get('vault_re2o_service_password')
|
|
|
|
if api_username is None:
|
|
raise AnsibleError(to_native(
|
|
'You must specify a valid username to connect to re2oAPI'
|
|
))
|
|
|
|
if api_password is None:
|
|
raise AnsibleError(to_native(
|
|
'You must specify a valid password to connect to re2oAPI'
|
|
))
|
|
|
|
api_client = Client(api_hostname, api_username,
|
|
api_password, use_tls=True)
|
|
|
|
res = []
|
|
for term in terms:
|
|
display.v("\nLookup for {} \n".format(term))
|
|
if term == 'dnszones':
|
|
res.append(self._getzones(api_client))
|
|
else:
|
|
res.append(self._rawquery(api_client, term))
|
|
return res
|
|
|
|
def _getzones(self, api_client):
|
|
display.v("Getting dns zone names")
|
|
zones = api_client.list('dns/zones')
|
|
zones_name = [zone["name"][1:] for zone in zones]
|
|
return zones_name
|
|
|
|
def _rawquery(self, api_client, endpoint):
|
|
display.v("Make a raw query to endpoint {}".format(endpoint))
|
|
return api_client.list(endpoint)
|