From 15cf56ba655b474367c7912856027896ff49e5f5 Mon Sep 17 00:00:00 2001 From: Bombar Maxime <bombar@crans.org> Date: Sat, 25 Apr 2020 23:10:01 +0200 Subject: [PATCH] Standalone re2o api lookup plugin --- .gitmodules | 3 - lookup_plugins/re2oapi | 1 - lookup_plugins/re2oapi.py | 386 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 367 insertions(+), 23 deletions(-) delete mode 160000 lookup_plugins/re2oapi diff --git a/.gitmodules b/.gitmodules index a5cf29f9..59564548 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/lookup_plugins/re2oapi b/lookup_plugins/re2oapi deleted file mode 160000 index 6565b92f..00000000 --- a/lookup_plugins/re2oapi +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6565b92f3bfc13d02b95888ae021f5bd6f7ef317 diff --git a/lookup_plugins/re2oapi.py b/lookup_plugins/re2oapi.py index b4a87fc7..6b4bef87 100644 --- a/lookup_plugins/re2oapi.py +++ b/lookup_plugins/re2oapi.py @@ -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 + +# 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, + )) -import sys -sys.path.append('./lookup_plugins/') + # Force the 'Authorization' field with the right token. + display.vvv("Forcing authentication token.") + headers.update({ + 'Authorization': 'Token {}'.format(self.get_token()) + }) -from re2oapi import Re2oAPIClient + # 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) -- GitLab