#!/usr/bin/python from ansible.module_utils.basic import AnsibleModule import requests import json import functools import time DOCUMENTATION = """ --- module: rancher1_api short_description: Client library for rancher API description: - This module modifies a rancher 1.6 using it's API (v1). - It supports some rancher features by the virtue of a 'mode'. - 'modes' hide from you some necessary cruft and expose you to the only important and interestig variables wich must be set. The mode mechanism makes this module more easy to use and you don't have to create an unnecessary boilerplate for the API. - Only a few modes are/will be implemented so far - as they are/will be needed. In the future the 'raw' mode can be added to enable you to craft your own API requests, but that would be on the same level of a user experience as running curl commands, and because the rancher 1.6 is already obsoleted by the project, it would be a wasted effort. options: rancher: description: - The domain name or the IP address and the port of the rancher where API is exposed. - For example: http://10.0.0.1:8080 required: true aliases: - server - rancher_server - rancher_api - api account_key: description: - The public and secret part of the API key-pair separated by colon. - You can find all your keys in web UI. - For example: B1716C4133D3825051CB:3P2eb3QhokFKYUiXRNZLxvGNSRYgh6LHjuMicCHQ required: false mode: description: - A recognized mode how to deal with some concrete configuration task in rancher API to ease the usage. - The implemented modes so far are: 'settings': Many options under /v1/settings API url and some can be seen also under advanced options in the web UI. 'access_control': It setups user and password for the account (defaults to 'admin') and it enables the local authentication - so the web UI and API will require username/password (UI) or apikey (API). required: true aliases: - rancher_mode - api_mode choices: - settings - access_control data: description: - Dictionary with key/value pairs. The actual names and meaning of pairs depends on the used mode. - settings mode: option - Option/path in JSON API (url). value - A new value to replace the current one. - access_control mode: account_id - The unique ID of the account - the default rancher admin has '1a1'. Better way would be to just create arbitrary username and set credentials for that, but due to time constraints, the route with an ID is simpler. The designated '1a1' could be hardcoded and hidden but if the user will want to use some other account (there are many), then it can be just changed to some other ID. password - A new password in a plaintext. required: true timeout: description: - How long in seconds to wait for a response before raising error required: false default: 10.0 """ default_timeout = 10.0 class ModeError(Exception): pass def _decorate_rancher_api_request(request_method): @functools.wraps(request_method) def wrap_request(*args, **kwargs): response = request_method(*args, **kwargs) authorized = True if response.status_code == 401: authorized = False elif response.status_code != requests.codes.ok: response.raise_for_status() try: json_data = response.json() except Exception: json_data = None return json_data, authorized return wrap_request @_decorate_rancher_api_request def get_rancher_api_value(url, headers=None, timeout=default_timeout, username=None, password=None): if username and password: return requests.get(url, headers=headers, timeout=timeout, allow_redirects=False, auth=(username, password)) else: return requests.get(url, headers=headers, timeout=timeout, allow_redirects=False) @_decorate_rancher_api_request def set_rancher_api_value(url, payload, headers=None, timeout=default_timeout, username=None, password=None, method='PUT'): if method == 'PUT': request_set_method = requests.put elif method == 'POST': request_set_method = requests.post else: raise ModeError('ERROR: Wrong request method: %s' % str(method)) if username and password: return request_set_method(url, headers=headers, timeout=timeout, allow_redirects=False, data=json.dumps(payload), auth=(username, password)) else: return request_set_method(url, headers=headers, timeout=timeout, allow_redirects=False, data=json.dumps(payload)) def create_rancher_api_url(server, mode, option): request_url = server.strip('/') + '/v1/' if mode == 'raw': request_url += option.strip('/') elif mode == 'settings': request_url += 'settings/' + option.strip('/') elif mode == 'access_control': request_url += option.strip('/') return request_url def get_keypair(keypair): if keypair: keypair = keypair.split(':') if len(keypair) == 2: return keypair[0], keypair[1] return None, None def mode_access_control(api_url, data=None, headers=None, timeout=default_timeout, access_key=None, secret_key=None, dry_run=False): # returns true if local auth was enabled or false if passwd changed def is_admin_enabled(json_data, password): try: if json_data['type'] == "localAuthConfig" and \ json_data['accessMode'] == "unrestricted" and \ json_data['username'] == "admin" and \ json_data['password'] == password and \ json_data['enabled']: return True except Exception: pass try: if json_data['type'] == "error" and \ json_data['code'] == "IncorrectPassword": return False except Exception: pass # this should never happen raise ModeError('ERROR: Unknown status of the local authentication') def create_localauth_payload(password): payload = { "enabled": True, "accessMode": "unrestricted", "username": "admin", "password": password } return payload def get_admin_password_id(): # assemble request URL request_url = api_url + 'accounts/' + data['account_id'].strip('/') \ + '/credentials' # API get current value try: json_response, authorized = \ get_rancher_api_value(request_url, username=access_key, password=secret_key, headers=headers, timeout=timeout) except requests.HTTPError as e: raise ModeError(str(e)) except requests.Timeout as e: raise ModeError(str(e)) if not authorized or not json_response: raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in ' + 'the response') try: for item in json_response['data']: if item['type'] == 'password' and \ item['accountId'] == data['account_id']: return item['id'] except Exception: pass return None def remove_password(password_id, action): if action == 'deactivate': action_status = 'deactivating' elif action == 'remove': action_status = 'removing' request_url = api_url + 'passwords/' + password_id + \ '/?action=' + action try: json_response, authorized = \ set_rancher_api_value(request_url, {}, username=access_key, password=secret_key, headers=headers, method='POST', timeout=timeout) except requests.HTTPError as e: raise ModeError(str(e)) except requests.Timeout as e: raise ModeError(str(e)) if not authorized or not json_response: raise ModeError('ERROR: BAD RESPONSE (POST) - no json value in ' + 'the response') if json_response['state'] != action_status: raise ModeError("ERROR: Failed to '%s' the password: %s" % (action, password_id)) # check if data contains all required fields try: if not isinstance(data['account_id'], str) or data['account_id'] == '': raise ModeError("ERROR: 'account_id' must contain an id of the " + "affected account") except KeyError: raise ModeError("ERROR: Mode 'access_control' requires the field: " + "'account_id': %s" % str(data)) try: if not isinstance(data['password'], str) or data['password'] == '': raise ModeError("ERROR: 'password' must contain some password") except KeyError: raise ModeError("ERROR: Mode 'access_control' requires the field: " + "'password': %s" % str(data)) # assemble request URL request_url = api_url + 'localauthconfigs' # API get current value try: json_response, authorized = \ get_rancher_api_value(request_url, username=access_key, password=secret_key, headers=headers, timeout=timeout) except requests.HTTPError as e: raise ModeError(str(e)) except requests.Timeout as e: raise ModeError(str(e)) if not authorized or not json_response: raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in the ' + 'response') # we will check if local auth is enabled or not enabled = False try: for item in json_response['data']: if item['type'] == 'localAuthConfig' and \ item['accessMode'] == 'unrestricted' and \ item['enabled']: enabled = True break except Exception: enabled = False if dry_run: # we will not set anything and only signal potential change if enabled: changed = False else: changed = True else: # we will try to enable again with the same password localauth_payload = create_localauth_payload(data['password']) try: json_response, authorized = \ set_rancher_api_value(request_url, localauth_payload, username=access_key, password=secret_key, headers=headers, method='POST', timeout=timeout) except requests.HTTPError as e: raise ModeError(str(e)) except requests.Timeout as e: raise ModeError(str(e)) # here we ignore authorized status - we will try to reset password if not json_response: raise ModeError('ERROR: BAD RESPONSE (POST) - no json value in ' + 'the response') # we check if the admin was already set or not... if enabled and is_admin_enabled(json_response, data['password']): # it was enabled before and password is the same - no change changed = False elif is_admin_enabled(json_response, data['password']): # we enabled it for the first time changed = True # ...and reset password if needed (unauthorized access) else: # local auth is enabled but the password differs # we must reset the admin's password password_id = get_admin_password_id() if password_id is None: raise ModeError("ERROR: admin's password is set, but we " + "cannot identify it") # One of the way to reset the password is to remove it first # TODO - refactor this remove_password(password_id, 'deactivate') time.sleep(2) remove_password(password_id, 'remove') time.sleep(1) try: json_response, authorized = \ set_rancher_api_value(request_url, localauth_payload, username=access_key, password=secret_key, headers=headers, method='POST', timeout=timeout) except requests.HTTPError as e: raise ModeError(str(e)) except requests.Timeout as e: raise ModeError(str(e)) # finally we signal the change changed = True if changed: msg = "Local authentication is enabled, admin has assigned password" else: msg = "Local authentication was already enabled, admin's password " \ + "is unchanged" return changed, msg def mode_settings(api_url, data=None, headers=None, timeout=default_timeout, access_key=None, secret_key=None, dry_run=False): def is_valid_rancher_api_option(json_data): try: api_activeValue = json_data['activeValue'] api_source = json_data['source'] except Exception: return False if api_activeValue is None and api_source is None: return False return True def create_rancher_api_payload(json_data, new_value): payload = {} differs = False try: api_id = json_data['id'] api_activeValue = json_data['activeValue'] api_name = json_data['name'] api_source = json_data['source'] except Exception: raise ValueError payload.update({"activeValue": api_activeValue, "id": api_id, "name": api_name, "source": api_source, "value": new_value}) if api_activeValue != new_value: differs = True return differs, payload # check if data contains all required fields try: if not isinstance(data['option'], str) or data['option'] == '': raise ModeError("ERROR: 'option' must contain a name of the " + "option") except KeyError: raise ModeError("ERROR: Mode 'settings' requires the field: 'option': " + "%s" % str(data)) try: if not isinstance(data['value'], str) or data['value'] == '': raise ModeError("ERROR: 'value' must contain a value") except KeyError: raise ModeError("ERROR: Mode 'settings' requires the field: 'value': " + "%s" % str(data)) # assemble request URL request_url = api_url + 'settings/' + data['option'].strip('/') # API get current value try: json_response, authorized = \ get_rancher_api_value(request_url, username=access_key, password=secret_key, headers=headers, timeout=timeout) except requests.HTTPError as e: raise ModeError(str(e)) except requests.Timeout as e: raise ModeError(str(e)) if not authorized or not json_response: raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in the ' + 'response') if is_valid_rancher_api_option(json_response): valid = True try: differs, payload = create_rancher_api_payload(json_response, data['value']) except ValueError: raise ModeError('ERROR: INVALID JSON - missing json values in ' + 'the response') else: valid = False if valid and differs and dry_run: # ansible dry-run mode changed = True elif valid and differs: # API set new value try: json_response, authorized = \ set_rancher_api_value(request_url, payload, username=access_key, password=secret_key, headers=headers, timeout=timeout) except requests.HTTPError as e: raise ModeError(str(e)) except requests.Timeout as e: raise ModeError(str(e)) if not authorized or not json_response: raise ModeError('ERROR: BAD RESPONSE (PUT) - no json value in ' + 'the response') else: changed = True else: changed = False if changed: msg = "Option '%s' is now set to the new value: %s" \ % (data['option'], data['value']) else: msg = "Option '%s' is unchanged." % (data['option']) return changed, msg def mode_handler(server, rancher_mode, data=None, timeout=default_timeout, account_key=None, dry_run=False): changed = False msg = 'UNKNOWN: UNAPPLICABLE MODE' # check API key-pair if account_key: access_key, secret_key = get_keypair(account_key) if not (access_key and secret_key): raise ModeError('ERROR: INVALID API KEY-PAIR') # all requests share these headers http_headers = {'Content-Type': 'application/json', 'Accept': 'application/json'} # assemble API url api_url = server.strip('/') + '/v1/' if rancher_mode == 'settings': changed, msg = mode_settings(api_url, data=data, headers=http_headers, timeout=timeout, access_key=access_key, secret_key=secret_key, dry_run=dry_run) elif rancher_mode == 'access_control': changed, msg = mode_access_control(api_url, data=data, headers=http_headers, timeout=timeout, access_key=access_key, secret_key=secret_key, dry_run=dry_run) return changed, msg def main(): module = AnsibleModule( argument_spec=dict( rancher=dict(type='str', required=True, aliases=['server', 'rancher_api', 'rancher_server', 'api']), account_key=dict(type='str', required=False), mode=dict(required=True, choices=['settings', 'access_control'], aliases=['api_mode']), data=dict(type='dict', required=True), timeout=dict(type='float', default=default_timeout), ), supports_check_mode=True ) rancher_server = module.params['rancher'] rancher_account_key = module.params['account_key'] rancher_mode = module.params['mode'] rancher_data = module.params['data'] rancher_timeout = module.params['timeout'] try: changed, msg = mode_handler(rancher_server, rancher_mode, data=rancher_data, account_key=rancher_account_key, timeout=rancher_timeout, dry_run=module.check_mode) except ModeError as e: module.fail_json(msg=str(e)) module.exit_json(changed=changed, msg=msg) if __name__ == '__main__': main()