Standalone re2o api lookup plugin
parent
23d8252f5f
commit
15cf56ba65
|
@ -1,6 +1,3 @@
|
|||
[submodule "roles/re2o-mail-server/templates/re2o-services/mail-server/mail-aliases"]
|
||||
path = roles/re2o-mail-server/templates/re2o-services/mail-server/mail-aliases
|
||||
url = https://gitlab.crans.org/nounous/mail-aliases
|
||||
[submodule "re2o-re2oapi"]
|
||||
path = lookup_plugins/re2oapi
|
||||
url = git@gitlab.crans.org:nounous/re2o-re2oapi.git
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 6565b92f3bfc13d02b95888ae021f5bd6f7ef317
|
|
@ -1,34 +1,368 @@
|
|||
"""
|
||||
A Proof Of Concept of lookup plugin to query the re2o API.
|
||||
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
|
||||
|
||||
|
||||
For now:
|
||||
|
||||
- Need to clone nounous/re2o-re2oapi.git and checkout to crans branch.
|
||||
- This Re2oAPIClient needs python3-iso8601
|
||||
|
||||
TODO: Implement a small client for our needs, this will also remove the sys.path extension ...
|
||||
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
|
||||
from ansible.errors import (AnsibleError,
|
||||
AnsibleFileNotFound,
|
||||
AnsibleLookupError,
|
||||
)
|
||||
from ansible.utils.display import Display
|
||||
|
||||
import sys
|
||||
sys.path.append('./lookup_plugins/')
|
||||
# Ansible Logger to stdout
|
||||
display = Display()
|
||||
|
||||
from re2oapi import Re2oAPIClient
|
||||
# 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):
|
||||
"""
|
||||
If terms = dnszones then this module queries the re2o api and returns the list of all dns zones.
|
||||
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.
|
||||
The following play will use the debug module to output
|
||||
all the zone names managed by Crans.
|
||||
|
||||
- hosts: sputnik.adm.crans.org
|
||||
vars:
|
||||
|
@ -37,7 +371,6 @@ class LookupModule(LookupBase):
|
|||
- debug: var=dnszones
|
||||
"""
|
||||
|
||||
|
||||
def run(self, terms, variables=None, api_hostname=None, api_username=None,
|
||||
api_password=None, use_tls=True):
|
||||
|
||||
|
@ -48,32 +381,47 @@ class LookupModule(LookupBase):
|
|||
: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 or not. You should !
|
||||
: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('You must specify a hostname to contact re2oAPI')
|
||||
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('You must specify a valid username to connect to re2oAPI')
|
||||
raise AnsibleError(to_native(
|
||||
'You must specify a valid username to connect to re2oAPI'
|
||||
))
|
||||
|
||||
if api_password is None:
|
||||
raise AnsibleError('You must specify a valid password to connect to re2oAPI')
|
||||
raise AnsibleError(to_native(
|
||||
'You must specify a valid password to connect to re2oAPI'
|
||||
))
|
||||
|
||||
api_client = Re2oAPIClient(api_hostname, api_username, api_password, use_tls=True)
|
||||
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)
|
||||
|
|
Loading…
Reference in New Issue