From 19f72d423d8cc84fc4e68ab26992a80adc35a14b Mon Sep 17 00:00:00 2001 From: Arkadiy Shinkarev Date: Fri, 2 Dec 2022 01:56:09 +0300 Subject: [PATCH] Added Static Route module --- plugins/module_utils/static_route.py | 121 ++++++++++ plugins/modules/static_route.py | 210 ++++++++++++++++++ .../targets/static_route/tasks/main.yml | 125 +++++++++++ .../plugins/module_utils/test_static_route.py | 74 ++++++ .../unit/plugins/modules/test_static_route.py | 118 ++++++++++ 5 files changed, 648 insertions(+) create mode 100644 plugins/module_utils/static_route.py create mode 100644 plugins/modules/static_route.py create mode 100644 tests/integration/targets/static_route/tasks/main.yml create mode 100644 tests/unit/plugins/module_utils/test_static_route.py create mode 100644 tests/unit/plugins/modules/test_static_route.py diff --git a/plugins/module_utils/static_route.py b/plugins/module_utils/static_route.py new file mode 100644 index 0000000..119b510 --- /dev/null +++ b/plugins/module_utils/static_route.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2022, Arkadiy Shinkarev | Tinkoff +# +# 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 + +from .utils import ( + get_query, + MaasValueMapper, +) +from . import errors +from .rest_client import RestClient +from .client import Client + + +MAAS_RESOURCE = "/api/2.0/static-routes" + + +class StaticRoute(MaasValueMapper): + def __init__( + self, + source=None, + destination=None, + gateway_ip=None, + metric=None, + id=None, + resource_uri=None, + ): + self.source = source + self.destination = destination + self.gateway_ip = gateway_ip + self.metric = metric + self.id = id + self.resource_uri = resource_uri + + @classmethod + def get_by_spec(cls, module, client: Client, must_exist=False): + rest_client = RestClient(client=client) + fields = {"source", "destination"} + ansible_maas_map_dict = {k: k for k in fields} + query = get_query( + module, + *fields, + ansible_maas_map=ansible_maas_map_dict, + ) + maas_dict = rest_client.get_record( + f"{MAAS_RESOURCE}/", query, must_exist=must_exist + ) + if maas_dict: + static_route = cls.from_maas(maas_dict) + return static_route + + @classmethod + def from_ansible(cls, module): + return + + @classmethod + def from_maas(cls, maas_dict): + obj = cls() + try: + obj.source = maas_dict["source"] + obj.destination = maas_dict["destination"] + obj.gateway_ip = maas_dict["gateway_ip"] + obj.metric = maas_dict["metric"] + obj.id = maas_dict["id"] + obj.resource_uri = maas_dict["resource_uri"] + except KeyError as e: + raise errors.MissingValueMAAS(e) + return obj + + def to_maas(self): + return + + def to_ansible(self): + return dict( + source=self.source, + destination=self.destination, + gateway_ip=self.gateway_ip, + metric=self.metric, + id=self.id, + resource_uri=self.resource_uri, + ) + + def delete(self, module, client): + rest_client = RestClient(client=client) + rest_client.delete_record(f"{MAAS_RESOURCE}/{self.id}/", module.check_mode) + + def update(self, module, client, payload): + rest_client = RestClient(client=client) + return rest_client.put_record( + f"{MAAS_RESOURCE}/{self.id}/", + payload=payload, + check_mode=module.check_mode, + ).json + + @classmethod + def create(cls, client, payload): + static_route_maas_dict = client.post( + f"{MAAS_RESOURCE}/", + data=payload, + timeout=60, # Sometimes we get timeout error thus changing timeout from 20s to 60s + ).json + static_route = cls.from_maas(static_route_maas_dict) + return static_route + + def __eq__(self, other): + """One DHCP Snippet is equal to another if it has all attributes exactly the same""" + return all( + ( + self.source == other.source, + self.destination == other.destination, + self.gateway_ip == other.gateway_ip, + self.metric == other.metric, + self.id == other.id, + self.resource_uri == other.resource_uri, + ) + ) diff --git a/plugins/modules/static_route.py b/plugins/modules/static_route.py new file mode 100644 index 0000000..49b1fc5 --- /dev/null +++ b/plugins/modules/static_route.py @@ -0,0 +1,210 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2022, Arkadiy Shinkarev | Tinkoff +# +# 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 + +DOCUMENTATION = r""" +module: static_route + +author: + - Arkadiy Shinkarev (@k3nny0ne) +short_description: Creates, updates or deletes Static Routes. +description: + - If I(state) is C(present) and all required options is provided but not found, + new Static Route with specified configuration is created. + - If I(state) is C(present) and static route is found based on specified options, updates an existing Static Route. + - If I(state) is C(absent) Static Route selected by I(name) is deleted. +version_added: 1.0.0 +extends_documentation_fragment: + - canonical.maas.cluster_instance +seealso: [] +options: + state: + description: + - Desired state of the Static Route. + choices: [ present, absent ] + type: str + required: True + source: + description: + - Source subnet name for the route. + type: str + required: True + destination: + description: + - Destination subnet name for the route. + type: str + required: True + gateway_ip: + description: + - IP address of the gateway on the source subnet. + type: str + required: True + metric: + description: + - Weight of the route on a deployed machine. + type: int + required: False +""" + +EXAMPLES = r""" +- name: Create Static Route + canonical.maas.static_route: + state: present + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "192.168.1.1" + metric: 100 + +- name: Update Static Route + canonical.maas.static_route: + state: present + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "192.168.1.1" + metric: 0 + +- name: Remove Static Route using specification + canonical.maas.static_route: + state: absent + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "192.168.1.1" + metric: 0 +""" + +RETURN = r""" +record: + description: + - Created or updated Static Route. + returned: success + type: dict + sample: + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "192.168.1.1" + metric: 0 + id: 4 + resource_uri: /MAAS/api/2.0/static-routes/4/ +""" + + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils import arguments, errors +from ..module_utils.client import Client +from ..module_utils.static_route import StaticRoute +from ..module_utils.cluster_instance import get_oauth1_client + + +def data_for_create_static_route(module): + data = {} + data["source"] = module.params["source"] # required + data["destination"] = module.params["destination"] # required + data["gateway_ip"] = module.params["gateway_ip"] # required + + if "metric" in module.params and module.params["metric"]: + data["metric"] = module.params["metric"] + return data + + +def create_static_route(module, client: Client): + data = data_for_create_static_route(module) + static_route = StaticRoute.create(client, data) + return ( + True, + static_route.to_ansible(), + dict(before={}, after=static_route.to_ansible()), + ) + + +def data_for_update_static_route(module, static_route): + data = {} + + if module.params["metric"]: + if static_route.metric != module.params["metric"]: + data["metric"] = module.params["metric"] + if module.params["gateway_ip"]: + if static_route.gateway_ip != module.params["gateway_ip"]: + data["gateway_ip"] = module.params["gateway_ip"] + + if ( + static_route.source + and isinstance(static_route.source, dict) + and static_route.source["name"] != module.params["source"] + ): + data["source"] = module.params["source"] + + if ( + static_route.destination + and isinstance(static_route.destination, dict) + and static_route.destination["name"] != module.params["destination"] + ): + data["destination"] = module.params["destination"] + + return data + + +def update_static_route(module, client: Client, static_route: StaticRoute): + data = data_for_update_static_route(module, static_route) + if data: + updated = static_route.update(module, client, data) + after = StaticRoute.from_maas(updated) + return ( + True, + after.to_ansible(), + dict(before=static_route.to_ansible(), after=after.to_ansible()), + ) + return ( + False, + static_route.to_ansible(), + dict(before=static_route.to_ansible(), after=static_route.to_ansible()), + ) + + +def delete_static_route(module, client: Client): + static_route = StaticRoute.get_by_spec(module, client, must_exist=False) + if static_route: + static_route.delete(module, client) + return True, dict(), dict(before=static_route.to_ansible(), after={}) + return False, dict(), dict(before={}, after={}) + + +def run(module, client: Client): + if module.params["state"] == "present": + static_route = StaticRoute.get_by_spec(module, client, must_exist=False) + if static_route: + return update_static_route(module, client, static_route) + return create_static_route(module, client) + if module.params["state"] == "absent": + return delete_static_route(module, client) + + +def main(): + module = AnsibleModule( + supports_check_mode=True, + argument_spec=dict( + arguments.get_spec("cluster_instance"), + state=dict(type="str", choices=["present", "absent"], required=True), + source=dict(type="str", required=True), + destination=dict(type="str", required=True), + gateway_ip=dict(type="str", required=True), + metric=dict(type="int"), + ), + ) + + try: + client = get_oauth1_client(module.params) + changed, record, diff = run(module, client) + module.exit_json(changed=changed, record=record, diff=diff) + except errors.MaasError as e: + module.fail_json(msg=str(e)) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/static_route/tasks/main.yml b/tests/integration/targets/static_route/tasks/main.yml new file mode 100644 index 0000000..608091b --- /dev/null +++ b/tests/integration/targets/static_route/tasks/main.yml @@ -0,0 +1,125 @@ +--- +- environment: + MAAS_HOST: "{{ host }}" + MAAS_TOKEN_KEY: "{{ token_key }}" + MAAS_TOKEN_SECRET: "{{ token_secret }}" + MAAS_CUSTOMER_KEY: "{{ customer_key }}" + + + block: + - name: Delete static route + canonical.maas.static_route: + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "172.16.31.1" + metric: 100 + state: absent + + - name: Delete static route + canonical.maas.static_route: + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "172.16.31.254" + metric: 100 + state: absent + + - name: Add static route - missing parameter + canonical.maas.static_route: + ignore_errors: true + register: static_route + + - name: Add static route - missing parameter + ansible.builtin.assert: + that: + - static_route is failed + - "'missing required arguments: destination, gateway_ip, source, state' in static_route.msg" + + - name: Add static route - without metric + canonical.maas.static_route: + state: present + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "172.16.31.254" + register: static_route + + - name: Add static route - without metric + ansible.builtin.assert: + that: + - static_route is changed + - static_route.record.source["name"] == "subnet-1" + - static_route.record.destination["name"] == "subnet-2" + - static_route.record.gateway_ip == "172.16.31.254" + + - name: Delete static route + canonical.maas.static_route: + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "172.16.31.254" + state: absent + register: static_route + + - name: Delete static route + ansible.builtin.assert: + that: + - static_route is changed + + - name: Add static route - with metric + canonical.maas.static_route: + state: present + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "172.16.31.254" + metric: 100 + register: static_route + + - name: Add static route - with metric + ansible.builtin.assert: + that: + - static_route is changed + - static_route.record.source["name"] == "subnet-1" + - static_route.record.destination["name"] == "subnet-2" + - static_route.record.gateway_ip == "172.16.31.254" + - static_route.record.metric == 100 + + - name: Update static route + canonical.maas.static_route: + state: present + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "172.16.31.254" + metric: 10 + register: static_route + + - name: Update static route + ansible.builtin.assert: + that: + - static_route is changed + - static_route.record.source["name"] == "subnet-1" + - static_route.record.destination["name"] == "subnet-2" + - static_route.record.gateway_ip == "172.16.31.254" + - static_route.record.metric == 10 + + - name: Update static_route - idempotence + canonical.maas.static_route: + state: present + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "172.16.31.254" + metric: 10 + register: static_route + + - name: Update static_route - idempotence + ansible.builtin.assert: + that: + - static_route is not changed + - static_route.record.source["name"] == "subnet-1" + - static_route.record.destination["name"] == "subnet-2" + - static_route.record.gateway_ip == "172.16.31.254" + - static_route.record.metric == 10 + + - name: Delete static route + canonical.maas.static_route: + source: "subnet-1" + destination: "subnet-2" + gateway_ip: "172.16.31.1" + state: absent diff --git a/tests/unit/plugins/module_utils/test_static_route.py b/tests/unit/plugins/module_utils/test_static_route.py new file mode 100644 index 0000000..562b28a --- /dev/null +++ b/tests/unit/plugins/module_utils/test_static_route.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2022, Arkadiy Shinkarev | Tinkoff +# +# 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 + +import sys + +import pytest + +from ansible_collections.canonical.maas.plugins.module_utils.static_route import ( + StaticRoute, +) + +pytestmark = pytest.mark.skipif( + sys.version_info < (2, 7), reason="requires python2.7 or higher" +) + + +class TestMapper: + def test_from_maas(self): + maas_static_route_dict = get_static_route() + static_route = StaticRoute(**get_static_route()) + results = StaticRoute.from_maas(maas_static_route_dict) + assert results == static_route + + def test_to_ansible(self): + static_route = StaticRoute(**get_static_route()) + + ansible_dict = get_static_route() + + results = static_route.to_ansible() + assert results == ansible_dict + + +class TestGet: + def test_get_by_spec(self, create_module, mocker, client): + module = create_module( + params=dict( + cluster_instance=dict( + host="https://0.0.0.0", + token_key="URCfn6EhdZ", + token_secret="PhXz3ncACvkcK", + customer_key="nzW4EBWjyDe", + ), + state="present", + source="subnet-1", + destination="subnet-2", + gateway_ip="192.168.1.1", + metric=100, + ) + ) + + mocker.patch( + "ansible_collections.canonical.maas.plugins.module_utils.machine.RestClient.get_record" + ).return_value = get_static_route() + + assert StaticRoute.get_by_spec(module, client, True) == StaticRoute( + **get_static_route() + ) + + +def get_static_route(): + return dict( + source="subnet-1", + destination="subnet-2", + gateway_ip="192.168.1.1", + metric=100, + id=1, + resource_uri="/MAAS/api/2.0/static-routes/1/", + ) diff --git a/tests/unit/plugins/modules/test_static_route.py b/tests/unit/plugins/modules/test_static_route.py new file mode 100644 index 0000000..c1b92cd --- /dev/null +++ b/tests/unit/plugins/modules/test_static_route.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2022, Arkadiy Shinkarev | Tinkoff +# +# 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 + +import sys +import pytest + +from ansible_collections.canonical.maas.plugins.modules import static_route +from ansible_collections.canonical.maas.plugins.module_utils.static_route import ( + StaticRoute, +) + +pytestmark = pytest.mark.skipif( + sys.version_info < (2, 7), reason="requires python2.7 or higher" +) + + +class TestMain: + def test_all_params(self, run_main): + params = dict( + cluster_instance=dict( + host="https://0.0.0.0", + token_key="URCfn6EhdZ", + token_secret="PhXz3ncACvkcK", + customer_key="nzW4EBWjyDe", + ), + source="subnet-1", + destination="subnet-2", + gateway_ip="192.168.1.1", + metric=100, + state="present", + ) + + success, result = run_main(static_route, params) + + assert success is True + + def test_minimal_set_of_params(self, run_main): + params = dict( + cluster_instance=dict( + host="https://0.0.0.0", + token_key="URCfn6EhdZ", + token_secret="PhXz3ncACvkcK", + customer_key="nzW4EBWjyDe", + ), + source="subnet-1", + destination="subnet-2", + gateway_ip="192.168.1.1", + state="present", + ) + + success, result = run_main(static_route, params) + + assert success is True + + def test_fail(self, run_main): + success, result = run_main(static_route) + + assert success is False + assert ( + "missing required arguments: destination, gateway_ip, source, state" + in result["msg"] + ) + + +class TestDataForCreateStaticRoute: + def test_data_for_create_static_route(self, create_module): + module = create_module( + params=dict( + cluster_instance=dict( + host="https://0.0.0.0", + token_key="URCfn6EhdZ", + token_secret="PhXz3ncACvkcK", + customer_key="nzW4EBWjyDe", + ), + source="subnet-1", + destination="subnet-2", + gateway_ip="192.168.1.1", + ) + ) + data = static_route.data_for_create_static_route(module) + + assert data == dict( + source="subnet-1", + destination="subnet-2", + gateway_ip="192.168.1.1", + ) + + +class TestDataForUpdateStaticRoute: + def test_data_for_update_static_route(self, create_module): + module = create_module( + params=dict( + cluster_instance=dict( + host="https://0.0.0.0", + token_key="URCfn6EhdZ", + token_secret="PhXz3ncACvkcK", + customer_key="nzW4EBWjyDe", + ), + source="subnet-1", + destination="subnet-2", + gateway_ip="192.168.1.1", + metric=100, + ) + ) + old_static_route = StaticRoute( + source="subnet-1", + destination="subnet-2", + gateway_ip="192.168.1.1", + ) + data = static_route.data_for_update_static_route(module, old_static_route) + + assert data == dict(metric=100)