From bd47e64bc78e44c5037ef7af958b41fb1c135acf Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Fri, 10 May 2019 08:33:51 +0200 Subject: [PATCH] Hetzner failover IP: refactoring (#56203) * Rename helper function. * Extract hetzner.py module_utils. * Rewrite docs. * Add module docs fragment. * Split up get_failover function. * Add tests for new function. * hetzner_pass -> hetzner_password * Move common argspec to module_utils. --- lib/ansible/module_utils/hetzner.py | 134 ++++++++++++++++ .../modules/net_tools/hetzner_failover_ip.py | 147 +++--------------- lib/ansible/plugins/doc_fragments/hetzner.py | 20 +++ .../test_hetzner.py} | 60 +++++-- 4 files changed, 222 insertions(+), 139 deletions(-) create mode 100644 lib/ansible/module_utils/hetzner.py create mode 100644 lib/ansible/plugins/doc_fragments/hetzner.py rename test/units/{modules/net_tools/test_hetzner_failover_ip.py => module_utils/test_hetzner.py} (72%) diff --git a/lib/ansible/module_utils/hetzner.py b/lib/ansible/module_utils/hetzner.py new file mode 100644 index 00000000000..87de79a5a5a --- /dev/null +++ b/lib/ansible/module_utils/hetzner.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- + +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# Copyright (c), Felix Fontein , 2019 +# +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +from ansible.module_utils.urls import fetch_url +from ansible.module_utils.six.moves.urllib.parse import urlencode + +HETZNER_DEFAULT_ARGUMENT_SPEC = dict( + hetzner_user=dict(type='str', required=True), + hetzner_password=dict(type='str', required=True, no_log=True), +) + +# The API endpoint is fixed. +BASE_URL = "https://robot-ws.your-server.de" + + +def fetch_url_json(module, url, method='GET', timeout=10, data=None, headers=None, accept_errors=None): + ''' + Make general request to Hetzner's JSON robot API. + ''' + module.params['url_username'] = module.params['hetzner_user'] + module.params['url_password'] = module.params['hetzner_password'] + resp, info = fetch_url(module, url, method=method, timeout=timeout, data=data, headers=headers) + try: + content = resp.read() + except AttributeError: + content = info.pop('body', None) + + if not content: + module.fail_json(msg='Cannot retrieve content from {0}'.format(url)) + + try: + result = module.from_json(content.decode('utf8')) + if 'error' in result: + if accept_errors: + if result['error']['code'] in accept_errors: + return result, result['error']['code'] + module.fail_json(msg='Request failed: {0} {1} ({2})'.format( + result['error']['status'], + result['error']['code'], + result['error']['message'] + )) + return result, None + except ValueError: + module.fail_json(msg='Cannot decode content retrieved from {0}'.format(url)) + + +# ##################################################################################### +# ## FAILOVER IP ###################################################################### + +def get_failover_record(module, ip): + ''' + Get information record of failover IP. + + See https://robot.your-server.de/doc/webservice/en.html#get-failover-failover-ip + ''' + url = "{0}/failover/{1}".format(BASE_URL, ip) + result, error = fetch_url_json(module, url) + if 'failover' not in result: + module.fail_json(msg='Cannot interpret result: {0}'.format(result)) + return result['failover'] + + +def get_failover(module, ip): + ''' + Get current routing target of failover IP. + + The value ``None`` represents unrouted. + + See https://robot.your-server.de/doc/webservice/en.html#get-failover-failover-ip + ''' + return get_failover_record(module, ip)['active_server_ip'] + + +def set_failover(module, ip, value, timeout=180): + ''' + Set current routing target of failover IP. + + Return a pair ``(value, changed)``. The value ``None`` for ``value`` represents unrouted. + + See https://robot.your-server.de/doc/webservice/en.html#post-failover-failover-ip + and https://robot.your-server.de/doc/webservice/en.html#delete-failover-failover-ip + ''' + url = "{0}/failover/{1}".format(BASE_URL, ip) + if value is None: + result, error = fetch_url_json( + module, + url, + method='DELETE', + timeout=timeout, + accept_errors=['FAILOVER_ALREADY_ROUTED'] + ) + else: + headers = {"Content-type": "application/x-www-form-urlencoded"} + data = dict( + active_server_ip=value, + ) + result, error = fetch_url_json( + module, + url, + method='POST', + timeout=timeout, + data=urlencode(data), + headers=headers, + accept_errors=['FAILOVER_ALREADY_ROUTED'] + ) + if error is not None: + return value, False + else: + return result['failover']['active_server_ip'], True + + +def get_failover_state(value): + ''' + Create result dictionary for failover IP's value. + + The value ``None`` represents unrouted. + ''' + return dict( + value=value, + state='routed' if value else 'unrouted' + ) diff --git a/lib/ansible/modules/net_tools/hetzner_failover_ip.py b/lib/ansible/modules/net_tools/hetzner_failover_ip.py index 892dd3ecc68..eedbe619da6 100644 --- a/lib/ansible/modules/net_tools/hetzner_failover_ip.py +++ b/lib/ansible/modules/net_tools/hetzner_failover_ip.py @@ -22,17 +22,13 @@ author: - Felix Fontein (@felixfontein) description: - Manage Hetzner's failover IPs. - - See L(https://wiki.hetzner.de/index.php/Failover/en,Hetzner's documentation) for details - on failover IPs. +seealso: + - name: Failover IP documentation + description: Hetzner's documentation on failover IPs. + link: https://wiki.hetzner.de/index.php/Failover/en +extends_documentation_fragment: + - hetzner options: - hetzner_user: - description: The username for the Robot webservice user. - type: str - required: yes - hetzner_pass: - description: The password for the Robot webservice user. - type: str - required: yes failover_ip: description: The failover IP address. type: str @@ -65,14 +61,14 @@ EXAMPLES = r''' - name: Set value of failover IP 1.2.3.4 to 5.6.7.8 hetzner_failover_ip: hetzner_user: foo - hetzner_pass: bar + hetzner_password: bar failover_ip: 1.2.3.4 value: 5.6.7.8 - name: Set value of failover IP 1.2.3.4 to unrouted hetzner_failover_ip: hetzner_user: foo - hetzner_pass: bar + hetzner_password: bar failover_ip: 1.2.3.4 state: unrouted ''' @@ -92,119 +88,24 @@ state: ''' from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.urls import fetch_url -from ansible.module_utils.six.moves.urllib.parse import urlencode - -# The API endpoint is fixed. -BASE_URL = "https://robot-ws.your-server.de" - - -def fetch_url_json(module, url, method='GET', timeout=10, data=None, headers=None, accept_errors=None): - ''' - Make general request to Hetzner's JSON robot API. - ''' - module.params['url_username'] = module.params['hetzner_user'] - module.params['url_password'] = module.params['hetzner_pass'] - resp, info = fetch_url(module, url, method=method, timeout=timeout, data=data, headers=headers) - try: - content = resp.read() - except AttributeError: - content = info.pop('body', None) - - if not content: - module.fail_json(msg='Cannot retrieve content from {0}'.format(url)) - - try: - result = module.from_json(content.decode('utf8')) - if 'error' in result: - if accept_errors: - if result['error']['code'] in accept_errors: - return result, result['error']['code'] - module.fail_json(msg='Request failed: {0} {1} ({2})'.format( - result['error']['status'], - result['error']['code'], - result['error']['message'] - )) - return result, None - except ValueError: - module.fail_json(msg='Cannot decode content retrieved from {0}'.format(url)) - - -def get_failover(module, ip): - ''' - Get current routing target of failover IP. - - The value ``None`` represents unrouted. - - See https://robot.your-server.de/doc/webservice/en.html#get-failover-failover-ip - ''' - url = "{0}/failover/{1}".format(BASE_URL, ip) - result, error = fetch_url_json(module, url) - if 'failover' not in result: - module.fail_json(msg='Cannot interpret result: {0}'.format(result)) - return result['failover']['active_server_ip'] - - -def set_failover(module, ip, value, timeout=180): - ''' - Set current routing target of failover IP. - - Return a pair ``(value, changed)``. The value ``None`` for ``value`` represents unrouted. - - See https://robot.your-server.de/doc/webservice/en.html#post-failover-failover-ip - and https://robot.your-server.de/doc/webservice/en.html#delete-failover-failover-ip - ''' - url = "{0}/failover/{1}".format(BASE_URL, ip) - if value is None: - result, error = fetch_url_json( - module, - url, - method='DELETE', - timeout=timeout, - accept_errors=['FAILOVER_ALREADY_ROUTED'] - ) - else: - headers = {"Content-type": "application/x-www-form-urlencoded"} - data = dict( - active_server_ip=value, - ) - result, error = fetch_url_json( - module, - url, - method='POST', - timeout=timeout, - data=urlencode(data), - headers=headers, - accept_errors=['FAILOVER_ALREADY_ROUTED'] - ) - if error is not None: - return value, False - else: - return result['failover']['active_server_ip'], True - - -def get_state(value): - ''' - Create result dictionary for failover IP's value. - - The value ``None`` represents unrouted. - ''' - return dict( - value=value, - state='routed' if value else 'unrouted' - ) +from ansible.module_utils.hetzner import ( + HETZNER_DEFAULT_ARGUMENT_SPEC, + get_failover, + set_failover, + get_failover_state, +) def main(): + argument_spec = dict( + failover_ip=dict(type='str', required=True), + state=dict(type='str', default='routed', choices=['routed', 'unrouted']), + value=dict(type='str'), + timeout=dict(type='int', default=180), + ) + argument_spec.update(HETZNER_DEFAULT_ARGUMENT_SPEC) module = AnsibleModule( - argument_spec=dict( - hetzner_user=dict(type='str', required=True), - hetzner_pass=dict(type='str', required=True, no_log=True), - failover_ip=dict(type='str', required=True), - state=dict(type='str', default='routed', choices=['routed', 'unrouted']), - value=dict(type='str'), - timeout=dict(type='int', default=180), - ), + argument_spec=argument_spec, supports_check_mode=True, required_if=( ('state', 'routed', ['value']), @@ -214,7 +115,7 @@ def main(): failover_ip = module.params['failover_ip'] value = get_failover(module, failover_ip) changed = False - before = get_state(value) + before = get_failover_state(value) if module.params['state'] == 'routed': new_value = module.params['value'] @@ -228,7 +129,7 @@ def main(): else: value, changed = set_failover(module, failover_ip, new_value, timeout=module.params['timeout']) - after = get_state(value) + after = get_failover_state(value) module.exit_json( changed=changed, diff=dict( diff --git a/lib/ansible/plugins/doc_fragments/hetzner.py b/lib/ansible/plugins/doc_fragments/hetzner.py new file mode 100644 index 00000000000..536e1a50d62 --- /dev/null +++ b/lib/ansible/plugins/doc_fragments/hetzner.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +options: + hetzner_user: + description: The username for the Robot webservice user. + type: str + required: yes + hetzner_password: + description: The password for the Robot webservice user. + type: str + required: yes +''' diff --git a/test/units/modules/net_tools/test_hetzner_failover_ip.py b/test/units/module_utils/test_hetzner.py similarity index 72% rename from test/units/modules/net_tools/test_hetzner_failover_ip.py rename to test/units/module_utils/test_hetzner.py index 15919f99c8c..fa099c39717 100644 --- a/test/units/modules/net_tools/test_hetzner_failover_ip.py +++ b/test/units/module_utils/test_hetzner.py @@ -1,11 +1,12 @@ # Copyright: (c) 2017 Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +import copy import json import pytest from mock import MagicMock -from ansible.modules.net_tools import hetzner_failover_ip +from ansible.module_utils import hetzner class ModuleFailException(Exception): @@ -96,18 +97,18 @@ FETCH_URL_JSON_FAIL = [ @pytest.mark.parametrize("return_value, accept_errors, result", FETCH_URL_JSON_SUCCESS) def test_fetch_url_json(monkeypatch, return_value, accept_errors, result): module = get_module_mock() - hetzner_failover_ip.fetch_url = MagicMock(return_value=return_value) + hetzner.fetch_url = MagicMock(return_value=return_value) - assert hetzner_failover_ip.fetch_url_json(module, 'https://foo/bar', accept_errors=accept_errors) == result + assert hetzner.fetch_url_json(module, 'https://foo/bar', accept_errors=accept_errors) == result @pytest.mark.parametrize("return_value, accept_errors, result", FETCH_URL_JSON_FAIL) def test_fetch_url_json_fail(monkeypatch, return_value, accept_errors, result): module = get_module_mock() - hetzner_failover_ip.fetch_url = MagicMock(return_value=return_value) + hetzner.fetch_url = MagicMock(return_value=return_value) with pytest.raises(ModuleFailException) as exc: - hetzner_failover_ip.fetch_url_json(module, 'https://foo/bar', accept_errors=accept_errors) + hetzner.fetch_url_json(module, 'https://foo/bar', accept_errors=accept_errors) assert exc.value.fail_msg == result assert exc.value.fail_kwargs == dict() @@ -122,10 +123,17 @@ GET_FAILOVER_SUCCESS = [ body=json.dumps(dict( failover=dict( active_server_ip='1.1.1.1', + ip='1.2.3.4', + netmask='255.255.255.255', ) )).encode('utf-8'), )), - '1.1.1.1' + '1.1.1.1', + dict( + active_server_ip='1.1.1.1', + ip='1.2.3.4', + netmask='255.255.255.255', + ) ), ] @@ -147,21 +155,41 @@ GET_FAILOVER_FAIL = [ ] -@pytest.mark.parametrize("ip, return_value, result", GET_FAILOVER_SUCCESS) -def test_get_failover(monkeypatch, ip, return_value, result): +@pytest.mark.parametrize("ip, return_value, result, record", GET_FAILOVER_SUCCESS) +def test_get_failover_record(monkeypatch, ip, return_value, result, record): + module = get_module_mock() + hetzner.fetch_url = MagicMock(return_value=copy.deepcopy(return_value)) + + assert hetzner.get_failover_record(module, ip) == record + + +@pytest.mark.parametrize("ip, return_value, result", GET_FAILOVER_FAIL) +def test_get_failover_record_fail(monkeypatch, ip, return_value, result): + module = get_module_mock() + hetzner.fetch_url = MagicMock(return_value=copy.deepcopy(return_value)) + + with pytest.raises(ModuleFailException) as exc: + hetzner.get_failover_record(module, ip) + + assert exc.value.fail_msg == result + assert exc.value.fail_kwargs == dict() + + +@pytest.mark.parametrize("ip, return_value, result, record", GET_FAILOVER_SUCCESS) +def test_get_failover(monkeypatch, ip, return_value, result, record): module = get_module_mock() - hetzner_failover_ip.fetch_url = MagicMock(return_value=return_value) + hetzner.fetch_url = MagicMock(return_value=copy.deepcopy(return_value)) - assert hetzner_failover_ip.get_failover(module, ip) == result + assert hetzner.get_failover(module, ip) == result @pytest.mark.parametrize("ip, return_value, result", GET_FAILOVER_FAIL) def test_get_failover_fail(monkeypatch, ip, return_value, result): module = get_module_mock() - hetzner_failover_ip.fetch_url = MagicMock(return_value=return_value) + hetzner.fetch_url = MagicMock(return_value=copy.deepcopy(return_value)) with pytest.raises(ModuleFailException) as exc: - hetzner_failover_ip.get_failover(module, ip) + hetzner.get_failover(module, ip) assert exc.value.fail_msg == result assert exc.value.fail_kwargs == dict() @@ -220,18 +248,18 @@ SET_FAILOVER_FAIL = [ @pytest.mark.parametrize("ip, value, return_value, result", SET_FAILOVER_SUCCESS) def test_set_failover(monkeypatch, ip, value, return_value, result): module = get_module_mock() - hetzner_failover_ip.fetch_url = MagicMock(return_value=return_value) + hetzner.fetch_url = MagicMock(return_value=copy.deepcopy(return_value)) - assert hetzner_failover_ip.set_failover(module, ip, value) == result + assert hetzner.set_failover(module, ip, value) == result @pytest.mark.parametrize("ip, value, return_value, result", SET_FAILOVER_FAIL) def test_set_failover_fail(monkeypatch, ip, value, return_value, result): module = get_module_mock() - hetzner_failover_ip.fetch_url = MagicMock(return_value=return_value) + hetzner.fetch_url = MagicMock(return_value=copy.deepcopy(return_value)) with pytest.raises(ModuleFailException) as exc: - hetzner_failover_ip.set_failover(module, ip, value) + hetzner.set_failover(module, ip, value) assert exc.value.fail_msg == result assert exc.value.fail_kwargs == dict()