diff --git a/.github/workflows/unit_testing.yml b/.github/workflows/unit_testing.yml index 5c7b90a59..8d088cfbc 100644 --- a/.github/workflows/unit_testing.yml +++ b/.github/workflows/unit_testing.yml @@ -66,7 +66,7 @@ jobs: run: | cd /home/${USER}/.ansible/collections/ansible_collections/${{ env.NAMESPACE }}/${{ env.COLLECTION_NAME }} ansible-test units --docker default --python ${{ matrix.python-version }} --coverage -v - ansible-test coverage report --include */plugins/* --omit */utils.py* > coverage.txt + ansible-test coverage report --include */plugins/* --omit */utils.py,_fetch_url* > coverage.txt - name: Code Coverage Check run: | cd /home/${USER}/.ansible/collections/ansible_collections/${{ env.NAMESPACE }}/${{ env.COLLECTION_NAME }} diff --git a/plugins/module_utils/prism/subnets.py b/plugins/module_utils/prism/subnets.py index f12210168..bbd64161a 100644 --- a/plugins/module_utils/prism/subnets.py +++ b/plugins/module_utils/prism/subnets.py @@ -138,3 +138,20 @@ def _get_default_dhcp_spec(self): "tftp_server_name": "", } ) + + +# Helper functions + + +def get_subnet_uuid(config, module): + if "name" in config or "subnet_name" in config: + subnet = Subnet(module) + name = config.get("name") or config.get("subnet_name") + uuid = subnet.get_uuid(name) + if not uuid: + error = "Subnet {0} not found.".format(name) + return None, error + elif "uuid" in config or "subnet_uuid" in config: + uuid = config.get("uuid") or config.get("subnet_uuid") + + return uuid, None diff --git a/plugins/module_utils/prism/vpcs.py b/plugins/module_utils/prism/vpcs.py index f029df81d..1be60cf2c 100644 --- a/plugins/module_utils/prism/vpcs.py +++ b/plugins/module_utils/prism/vpcs.py @@ -14,19 +14,69 @@ def __init__(self, module): resource_type = "/vpcs" super(Vpc, self).__init__(module, resource_type=resource_type) self.build_spec_methods = { - # TODO. This is a Map of - # ansible attirbute and corresponding API spec generation method - # Example: method name should start with _build_spec_ - # name: _build_spec_name + "name": self._build_spec_name, + "external_subnets": self._build_spec_external_subnet, + "routable_ips": self._build_spec_routable_ips, + "dns_servers": self._build_dns_servers, } def _get_default_spec(self): return deepcopy( { - # TODO: Default API spec + "api_version": "3.1.0", + "metadata": {"kind": "vpc", "categories": {}}, + "spec": { + "name": None, + "resources": { + "common_domain_name_server_ip_list": [], + "external_subnet_list": [], + "externally_routable_prefix_list": [], + }, + }, } ) + def _build_spec_name(self, payload, name): + payload["spec"]["name"] = name + return payload, None + + def _build_spec_external_subnet(self, payload, subnets): + from .subnets import get_subnet_uuid + + external_subnets = [] + for subnet in subnets: + uuid, error = get_subnet_uuid(subnet, self.module) + if error: + return None, error + subnet_ref_spec = self._get_external_subnet_ref_spec(uuid) + external_subnets.append(subnet_ref_spec) + + payload["spec"]["resources"]["external_subnet_list"] = external_subnets + return payload, None + + def _build_spec_routable_ips(self, payload, ips): + routable_ips = [] + for ip in ips: + routable_ip_ref_spec = self._get_routable_ip_spec( + ip["network_ip"], ip["network_prefix"] + ) + routable_ips.append(routable_ip_ref_spec) + + payload["spec"]["resources"]["externally_routable_prefix_list"] = routable_ips + return payload, None + + def _build_dns_servers(self, payload, dns_servers): + payload["spec"]["resources"]["common_domain_name_server_ip_list"] = [ + {"ip": i} for i in dns_servers + ] + return payload, None + + def _get_external_subnet_ref_spec(self, uuid): + return deepcopy({"external_subnet_reference": {"kind": "subnet", "uuid": uuid}}) + + def _get_routable_ip_spec(self, ip, prefix): + return deepcopy({"ip": ip, "prefix_length": prefix}) + # Helper functions diff --git a/plugins/modules/ntnx_vpcs.py b/plugins/modules/ntnx_vpcs.py new file mode 100644 index 000000000..f2c73f685 --- /dev/null +++ b/plugins/modules/ntnx_vpcs.py @@ -0,0 +1,230 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Prem Karat +# 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: ntnx_vpcs +short_description: vpcs module which suports vpc CRUD operations +version_added: 1.0.0 +description: 'Create, Update, Delete vpcs' +options: + nutanix_host: + description: + - PC hostname or IP address + type: str + required: true + nutanix_port: + description: + - PC port + type: str + default: 9440 + required: false + nutanix_username: + description: + - PC username + type: str + required: true + nutanix_password: + description: + - PC password; + required: true + type: str + validate_certs: + description: + - Set value to C(False) to skip validation for self signed certificates + - This is not recommended for production setup + type: bool + default: true + state: + description: + - Specify state of vpc + - If C(state) is set to C(present) then vpc is created. + - >- + If C(state) is set to C(absent) and if the vpc exists, then + vpc is removed. + choices: + - present + - absent + type: str + default: present + wait: + description: Wait for vpc CRUD operation to complete. + type: bool + required: false + default: True + name: + description: vpc Name + type: str + vpc_uuid: + description: vpc uuid + type: str + dns_servers: + description: List of DNS servers IPs + type: list + elements: str + routable_ips: + description: Address space within the VPC which can talk externally without NAT. These are in effect when No-NAT External subnet is used. + type: list + elements: dict + suboptions: + network_ip: + description: ip address + type: str + network_prefix: + description: Subnet ip address prefix length + type: int + external_subnets: + description: A subnet with external connectivity + type: list + elements: dict + suboptions: + subnet_uuid: + description: Subnet UUID + type: str + subnet_name: + description: Subnet Name + type: str +author: + - Prem Karat (@premkarat) + - Gevorg Khachatryan (@Gevorg-Khachatryan-97) + - Alaa Bishtawi (@alaa-bish) + - Dina AbuHijleh (@dina-abuhijleh) +""" + +EXAMPLES = r""" +# TODO +""" + +RETURN = r""" +# TODO +""" + +from ..module_utils.base_module import BaseModule # noqa: E402 +from ..module_utils.prism.tasks import Task # noqa: E402 +from ..module_utils.prism.vpcs import Vpc # noqa: E402 +from ..module_utils.utils import remove_param_with_none_value # noqa: E402 + + +def get_module_spec(): + external_subnets_spec = dict( + subnet_name=dict(type="str"), subnet_uuid=dict(type="str") + ) + routable_ips_spec = dict( + network_ip=dict(type="str"), network_prefix=dict(type="int") + ) + module_args = dict( + name=dict(type="str"), + vpc_uuid=dict(type="str"), + external_subnets=dict( + type="list", + elements="dict", + options=external_subnets_spec, + mutually_exclusive=[("subnet_name", "subnet_uuid")], + ), + routable_ips=dict(type="list", elements="dict", options=routable_ips_spec), + dns_servers=dict(type="list", elements="str"), + ) + + return module_args + + +def create_vpc(module, result): + vpc = Vpc(module) + spec, error = vpc.get_spec() + if error: + result["error"] = error + module.fail_json(msg="Failed generating vpc spec", **result) + + if module.check_mode: + result["response"] = spec + return + + resp, status = vpc.create(spec) + if status["error"]: + result["error"] = status["error"] + result["response"] = resp + module.fail_json(msg="Failed creating vpc", **result) + + vpc_uuid = resp["metadata"]["uuid"] + result["changed"] = True + result["response"] = resp + result["vpc_uuid"] = vpc_uuid + result["task_uuid"] = resp["status"]["execution_context"]["task_uuid"] + + if module.params.get("wait"): + wait_for_task_completion(module, result) + resp, tmp = vpc.read(vpc_uuid) + result["response"] = resp + + +def delete_vpc(module, result): + vpc_uuid = module.params["vpc_uuid"] + if not vpc_uuid: + result["error"] = "Missing parameter vpc_uuid in playbook" + module.fail_json(msg="Failed deleting vpc", **result) + + vpc = Vpc(module) + resp, status = vpc.delete(vpc_uuid) + if status["error"]: + result["error"] = status["error"] + result["response"] = resp + module.fail_json(msg="Failed deleting vpc", **result) + + result["changed"] = True + result["response"] = resp + result["vpc_uuid"] = vpc_uuid + result["task_uuid"] = resp["status"]["execution_context"]["task_uuid"] + + if module.params.get("wait"): + wait_for_task_completion(module, result) + + +def wait_for_task_completion(module, result): + task = Task(module) + task_uuid = result["task_uuid"] + resp, status = task.wait_for_completion(task_uuid) + result["response"] = resp + if status["error"]: + result["error"] = status["error"] + result["response"] = resp + module.fail_json(msg="Failed creating vpc", **result) + + +def run_module(): + module = BaseModule( + argument_spec=get_module_spec(), + supports_check_mode=True, + required_if=[ + ("state", "present", ("name",)), + ("state", "absent", ("vpc_uuid",)), + ], + ) + remove_param_with_none_value(module.params) + result = { + "changed": False, + "error": None, + "response": None, + "vpc_uuid": None, + "task_uuid": None, + } + state = module.params["state"] + if state == "present": + create_vpc(module, result) + elif state == "absent": + delete_vpc(module, result) + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/scripts/create_module.py b/scripts/create_module.py index f81cb7324..6b6548de5 100644 --- a/scripts/create_module.py +++ b/scripts/create_module.py @@ -217,14 +217,14 @@ def _get_default_spec(self): def create_module(name): - with open("plugins/modules/ntnx_{0}s.py".format(name), "w") as f: + with open("plugins/modules/ntnx_{0}s.py".format(name), "wb") as f: f.write( module_content.replace("object", name.lower()).replace( "Object", name.capitalize() ) ) - with open("plugins/module_utils/prism/{0}s.py".format(name), "w") as f: + with open("plugins/module_utils/prism/{0}s.py".format(name), "wb") as f: f.write( object_content.replace("object", name.lower()).replace( "Object", name.capitalize() diff --git a/tests/integration/targets/nutanix_vpcs/tasks/create_vpcs.yml b/tests/integration/targets/nutanix_vpcs/tasks/create_vpcs.yml new file mode 100644 index 000000000..428ac02f0 --- /dev/null +++ b/tests/integration/targets/nutanix_vpcs/tasks/create_vpcs.yml @@ -0,0 +1,114 @@ +- name: create Min VPC + ntnx_vpcs: + validate_certs: False + state: present + nutanix_host: "{{ IP }}" + nutanix_username: "{{ username }}" + nutanix_password: "{{ password }}" + name: MinVPC + external_subnets: + - subnet_name: "{{external_subnet.subnet_name}}" + register: result + ignore_errors: True + +- name: Creation Status + assert: + that: + - result.response is defined + - result.response.status.state == 'COMPLETE' + fail_msg: " Unable to create Min vpc " + success_msg: " Min vpc created successfully " + +- set_fact: + todelete: "{{ todelete + [ result.vpc_uuid ] }}" + + +- name: create VPC with routable_ips + ntnx_vpcs: + validate_certs: False + state: present + nutanix_host: "{{ IP }}" + nutanix_username: "{{ username }}" + nutanix_password: "{{ password }}" + name: vpc_with_routable_ips + external_subnets: + - subnet_name: "{{external_subnet.subnet_name}}" + routable_ips: + - network_ip: "{{routable_ips.network_ip}}" + network_prefix: "{{routable_ips.network_prefix}}" + register: result + ignore_errors: True + +- name: Creation Status + assert: + that: + - result.response is defined + - result.response.status.state == 'COMPLETE' + fail_msg: " Unable to create vpc with routable_ips " + success_msg: " vpc with routable_ips created successfully " + +- set_fact: + todelete: "{{ todelete + [ result.vpc_uuid ] }}" + + +- name: create VPC with dns_servers + ntnx_vpcs: + validate_certs: False + state: present + nutanix_host: "{{ IP }}" + nutanix_username: "{{ username }}" + nutanix_password: "{{ password }}" + name: vpc_with_dns_servers + external_subnets: + - subnet_name: "{{external_subnet.subnet_name}}" + dns_servers: "{{dns_servers}}" + register: result + ignore_errors: True +- name: Creation Status + assert: + that: + - result.response is defined + - result.response.status.state == 'COMPLETE' + fail_msg: " Unable to create vpc with dns_servers " + success_msg: " vpc with dns_servers created successfully " + +- set_fact: + todelete: "{{ todelete + [ result.vpc_uuid ] }}" + + +- name: create VPC with All specfactions + ntnx_vpcs: + validate_certs: False + state: present + nutanix_host: "{{ IP }}" + nutanix_username: "{{ username }}" + nutanix_password: "{{ password }}" + name: vpc_with_add_specfactions + external_subnets: + - subnet_name: "{{external_subnet.subnet_name}}" + dns_servers: "{{dns_servers}}" + register: result + ignore_errors: True +- name: Creation Status + assert: + that: + - result.response is defined + - result.response.status.state == 'COMPLETE' + fail_msg: " Unable to create vpc all specfactions " + success_msg: " vpc with all specfactions created successfully " + +- set_fact: + todelete: "{{ todelete + [ result.vpc_uuid ] }}" + + +- name: Delete all Created vpcs + ntnx_vpcs: + state: absent + nutanix_host: "{{ IP }}" + nutanix_username: "{{ username }}" + nutanix_password: "{{ password }}" + validate_certs: false + vpc_uuid: "{{ item }}" + register: result + loop: "{{ todelete }}" + ignore_errors: True diff --git a/tests/integration/targets/nutanix_vpcs/tasks/main.yml b/tests/integration/targets/nutanix_vpcs/tasks/main.yml new file mode 100644 index 000000000..767da5e3a --- /dev/null +++ b/tests/integration/targets/nutanix_vpcs/tasks/main.yml @@ -0,0 +1,3 @@ +- block: + - import_tasks: "create_vpcs.yml" + diff --git a/tests/integration/targets/nutanix_vpcs/vars/main.yml b/tests/integration/targets/nutanix_vpcs/vars/main.yml new file mode 100644 index 000000000..dd2f74cc0 --- /dev/null +++ b/tests/integration/targets/nutanix_vpcs/vars/main.yml @@ -0,0 +1,13 @@ +IP: 10.44.76.88 +username: admin +password: Nutanix.123 +external_subnet: + subnet_name: ET_2 + subnet_uuiid: 57959f75-6b21-431c-b76b-516447d52621 +dns_servers: + - 8.8.8.8 + - 8.8.4.4 +routable_ips: + network_ip: 192.168.2.0 + network_prefix: 24 +todelete: [] diff --git a/tests/unit/plugins/module_utils/test_entity.py b/tests/unit/plugins/module_utils/test_entity.py index ea921e80c..33f2a19f9 100644 --- a/tests/unit/plugins/module_utils/test_entity.py +++ b/tests/unit/plugins/module_utils/test_entity.py @@ -39,9 +39,12 @@ def _fetch_url(url, method, data=None, **kwargs): "status": {"state": "succeeded"}, "status_code": 200, "request": {"method": method, "url": url, "data": data}, + "entities": [ + {"spec": {"name": "test_name"}, "metadata": {"uuid": "test_uuid"}} + ], } - return response + return response, 200 def exit_json(*args, **kwargs): @@ -59,6 +62,10 @@ class TestEntity(ModuleTestCase): def setUp(self): self.module = Module() Entity._fetch_url = MagicMock(side_effect=_fetch_url) + Entity._get_default_spec = MagicMock(side_effect=lambda: {}) + Entity.build_spec_methods = { + "test_param": lambda s, v: ({"test_param": v}, None) + } self.entity = Entity(self.module, resource_type="/test") self.module.exit_json = MagicMock(side_effect=exit_json) self.module.fail_json = MagicMock(side_effect=fail_json) @@ -66,7 +73,7 @@ def setUp(self): def test_create_action(self): data = {} req = {"method": "POST", "url": "https://99.99.99.99:9999/test", "data": data} - result = self.entity.create(data) + result, status = self.entity.create(data) self.assertEqual(result["request"], req) def test_negative_create_action(self): @@ -75,14 +82,14 @@ def test_negative_create_action(self): entity = Entity(self.module, resource_type="") req = {"method": "POST", "url": "https://None/", "data": data} - result = entity.create(data) + result, status = entity.create(data) self.assertEqual(result["request"], req) self.assertEqual(entity.headers.get("Authorization"), None) def test_update_action(self): data = {} req = {"method": "PUT", "url": "https://99.99.99.99:9999/test", "data": data} - result = self.entity.update(data) + result, status = self.entity.update(data) self.assertEqual(result["request"], req) def test_negative_update_action(self): @@ -91,7 +98,7 @@ def test_negative_update_action(self): entity = Entity(self.module, resource_type="") req = {"method": "PUT", "url": "https://None/", "data": data} - result = entity.update(data) + result, status = entity.update(data) self.assertEqual(result["request"], req) self.assertEqual(entity.headers.get("Authorization"), None) @@ -102,7 +109,7 @@ def test_list_action(self): "url": "https://99.99.99.99:9999/test/list", "data": data, } - result = self.entity.list(data) + result, status = self.entity.list(data) self.assertEqual(result["request"], req) def test_negative_list_action(self): @@ -111,7 +118,27 @@ def test_negative_list_action(self): entity = Entity(self.module, resource_type="") req = {"method": "POST", "url": "https://None//list", "data": data} - result = entity.list(data) + result, status = entity.list(data) + self.assertEqual(result["request"], req) + self.assertEqual(entity.headers.get("Authorization"), None) + + def test_raed_action(self): + uuid = "test_uuid" + req = { + "method": "GET", + "url": "https://99.99.99.99:9999/test/{0}".format(uuid), + "data": None, + } + result, status = self.entity.read(uuid=uuid) + self.assertEqual(result["request"], req) + + def test_negative_read_action(self): + data = None + self.module.params = {} + entity = Entity(self.module, resource_type="") + + req = {"method": "GET", "url": "https://None/", "data": data} + result, status = entity.read(data) self.assertEqual(result["request"], req) self.assertEqual(entity.headers.get("Authorization"), None) @@ -122,7 +149,7 @@ def test_delete_action(self): "url": "https://99.99.99.99:9999/test/{0}".format(uuid), "data": None, } - result = self.entity.delete(uuid=uuid) + result, status = self.entity.delete(uuid=uuid) self.assertEqual(result["request"], req) def test_negative_delete_action(self): @@ -131,7 +158,7 @@ def test_negative_delete_action(self): entity = Entity(self.module, resource_type="") req = {"method": "DELETE", "url": "https://None/", "data": data} - result = entity.delete(data) + result, status = entity.delete(data) self.assertEqual(result["request"], req) self.assertEqual(entity.headers.get("Authorization"), None) @@ -174,3 +201,29 @@ def test_build_headers(self): generated_headers = self.entity._build_headers(self.module, additional_headers) self.assertEqual(actual_headers, generated_headers) + + def test_get_uuid(self): + name = "test_name" + result = self.entity.get_uuid(name=name) + self.assertEqual(result, "test_uuid") + + def test_negative_get_uuid(self): + name = "wrong_test_name" + result = self.entity.get_uuid(name=name) + self.assertEqual(result, None) + + def test_get_spec(self): + result = self.entity.get_spec() + self.assertEqual(result, ({}, None)) + + def test_build_spec_methods(self): + self.module.params = {"test_param": "test_value"} + entity = Entity(self.module, resource_type="/test") + result = entity.get_spec() + self.assertEqual(result, ({"test_param": "test_value"}, None)) + + def test_negative_build_spec_methods(self): + self.module.params = {"wrong_param": "test_value"} + entity = Entity(self.module, resource_type="/test") + result = entity.get_spec() + self.assertNotEqual(result, ({"wrong_param": "test_value"}, None))