From ba9fee6c37dd9d536dc2496dd763c5fac2a6e411 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Tue, 7 May 2019 19:11:15 +0200 Subject: [PATCH] Hetzner failover IP module (#56103) * First version of Hetzner failover IP module. * Extend module. * Add comments. * Add basic unit tests. * Add more tests. * Tests for set_failover. --- .../modules/net_tools/hetzner_failover_ip.py | 243 ++++++++++++++++++ .../net_tools/test_hetzner_failover_ip.py | 237 +++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 lib/ansible/modules/net_tools/hetzner_failover_ip.py create mode 100644 test/units/modules/net_tools/test_hetzner_failover_ip.py diff --git a/lib/ansible/modules/net_tools/hetzner_failover_ip.py b/lib/ansible/modules/net_tools/hetzner_failover_ip.py new file mode 100644 index 0000000000..892dd3ecc6 --- /dev/null +++ b/lib/ansible/modules/net_tools/hetzner_failover_ip.py @@ -0,0 +1,243 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2019 Felix Fontein +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = r''' +--- +module: hetzner_failover_ip +version_added: "2.9" +short_description: Manage Hetzner's failover IPs +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. +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 + required: yes + state: + description: + - Defines whether the IP will be routed or not. + - If set to C(routed), I(value) must be specified. + type: str + choices: + - routed + - unrouted + default: routed + value: + description: + - The new value for the failover IP address. + - Required when setting I(state) to C(routed). + type: str + timeout: + description: + - Timeout to use when routing or unrouting the failover IP. + - Note that the API call returns when the failover IP has been + successfully routed to the new address, respectively successfully + unrouted. + type: int + default: 180 +''' + +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 + 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 + failover_ip: 1.2.3.4 + state: unrouted +''' + +RETURN = r''' +value: + description: + - The value of the failover IP. + - Will be C(none) if the IP is unrouted. + returned: success + type: str +state: + description: + - Will be C(routed) or C(unrouted). + returned: success + type: str +''' + +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' + ) + + +def main(): + 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), + ), + supports_check_mode=True, + required_if=( + ('state', 'routed', ['value']), + ), + ) + + failover_ip = module.params['failover_ip'] + value = get_failover(module, failover_ip) + changed = False + before = get_state(value) + + if module.params['state'] == 'routed': + new_value = module.params['value'] + else: + new_value = None + + if value != new_value: + if module.check_mode: + value = new_value + changed = True + else: + value, changed = set_failover(module, failover_ip, new_value, timeout=module.params['timeout']) + + after = get_state(value) + module.exit_json( + changed=changed, + diff=dict( + before=before, + after=after, + ), + **after + ) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/net_tools/test_hetzner_failover_ip.py b/test/units/modules/net_tools/test_hetzner_failover_ip.py new file mode 100644 index 0000000000..15919f99c8 --- /dev/null +++ b/test/units/modules/net_tools/test_hetzner_failover_ip.py @@ -0,0 +1,237 @@ +# Copyright: (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import json +import pytest + +from mock import MagicMock +from ansible.modules.net_tools import hetzner_failover_ip + + +class ModuleFailException(Exception): + def __init__(self, msg, **kwargs): + super(ModuleFailException, self).__init__(msg) + self.fail_msg = msg + self.fail_kwargs = kwargs + + +def get_module_mock(): + def f(msg, **kwargs): + raise ModuleFailException(msg, **kwargs) + + module = MagicMock() + module.fail_json = f + module.from_json = json.loads + return module + + +# ######################################################################################## + +FETCH_URL_JSON_SUCCESS = [ + ( + (None, dict( + body=json.dumps(dict( + a='b' + )).encode('utf-8'), + )), + None, + (dict( + a='b' + ), None) + ), + ( + (None, dict( + body=json.dumps(dict( + error=dict( + code="foo", + status=400, + message="bar", + ), + a='b' + )).encode('utf-8'), + )), + ['foo'], + (dict( + error=dict( + code="foo", + status=400, + message="bar", + ), + a='b' + ), 'foo') + ), +] + + +FETCH_URL_JSON_FAIL = [ + ( + (None, dict( + body=json.dumps(dict( + error=dict( + code="foo", + status=400, + message="bar", + ), + )).encode('utf-8'), + )), + None, + 'Request failed: 400 foo (bar)' + ), + ( + (None, dict( + body=json.dumps(dict( + error=dict( + code="foo", + status=400, + message="bar", + ), + )).encode('utf-8'), + )), + ['bar'], + 'Request failed: 400 foo (bar)' + ), +] + + +@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) + + assert hetzner_failover_ip.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) + + with pytest.raises(ModuleFailException) as exc: + hetzner_failover_ip.fetch_url_json(module, 'https://foo/bar', accept_errors=accept_errors) + + assert exc.value.fail_msg == result + assert exc.value.fail_kwargs == dict() + + +# ######################################################################################## + +GET_FAILOVER_SUCCESS = [ + ( + '1.2.3.4', + (None, dict( + body=json.dumps(dict( + failover=dict( + active_server_ip='1.1.1.1', + ) + )).encode('utf-8'), + )), + '1.1.1.1' + ), +] + + +GET_FAILOVER_FAIL = [ + ( + '1.2.3.4', + (None, dict( + body=json.dumps(dict( + error=dict( + code="foo", + status=400, + message="bar", + ), + )).encode('utf-8'), + )), + 'Request failed: 400 foo (bar)' + ), +] + + +@pytest.mark.parametrize("ip, return_value, result", GET_FAILOVER_SUCCESS) +def test_get_failover(monkeypatch, ip, return_value, result): + module = get_module_mock() + hetzner_failover_ip.fetch_url = MagicMock(return_value=return_value) + + assert hetzner_failover_ip.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) + + with pytest.raises(ModuleFailException) as exc: + hetzner_failover_ip.get_failover(module, ip) + + assert exc.value.fail_msg == result + assert exc.value.fail_kwargs == dict() + + +# ######################################################################################## + +SET_FAILOVER_SUCCESS = [ + ( + '1.2.3.4', + '1.1.1.1', + (None, dict( + body=json.dumps(dict( + failover=dict( + active_server_ip='1.1.1.2', + ) + )).encode('utf-8'), + )), + ('1.1.1.2', True) + ), + ( + '1.2.3.4', + '1.1.1.1', + (None, dict( + body=json.dumps(dict( + error=dict( + code="FAILOVER_ALREADY_ROUTED", + status=400, + message="Failover already routed", + ), + )).encode('utf-8'), + )), + ('1.1.1.1', False) + ), +] + + +SET_FAILOVER_FAIL = [ + ( + '1.2.3.4', + '1.1.1.1', + (None, dict( + body=json.dumps(dict( + error=dict( + code="foo", + status=400, + message="bar", + ), + )).encode('utf-8'), + )), + 'Request failed: 400 foo (bar)' + ), +] + + +@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) + + assert hetzner_failover_ip.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) + + with pytest.raises(ModuleFailException) as exc: + hetzner_failover_ip.set_failover(module, ip, value) + + assert exc.value.fail_msg == result + assert exc.value.fail_kwargs == dict()