diff --git a/Makefile b/Makefile index 6b996b5c..0ad34bae 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,20 @@ fmt: - autoflake plugins/modules/*.py - autoflake plugins/module_utils/*.py - autoflake --recursive tests/ - autoflake tools/migrationtool/*py + autoflake plugins/modules/*.py + autoflake plugins/module_utils/*.py + autoflake --recursive tests/ + autoflake tools/migrationtool/*.py - black plugins/modules/*.py - black plugins/module_utils/*.py - black tests/ - black tools/migrationtool/*.py + black plugins/modules/*.py + black plugins/module_utils/*.py + black tests/ + black tools/migrationtool/*.py - isort plugins/modules/*.py - isort plugins/module_utils/*.py - isort tests/ - isort tools/migrationtool/*.py + isort plugins/modules/*.py + isort plugins/module_utils/*.py + isort tests/ + isort tools/migrationtool/*.py - yamlfmt $(shell find . -name '*.yml' -o -name '*.yaml') + yamlfmt $(shell find . -name '*.yml' -o -name '*.yaml') install: ansible-galaxy collection install . --force @@ -63,7 +63,7 @@ galaxy_importer: build # skip the playbook which contains "password" in the file name run_examples: @for playbook in examples/*.yaml; do \ - if [[ $$playbook == *"password"* || $$playbook == *"login"* || $$playbook == *"logout"* || $$playbook == *"route"* || $$playbook == locationfile.yaml || $$playbook == nsip6.yaml || $$playbook == hanode.yaml ]]; then \ + if [[ $$playbook == *"password"* || $$playbook == *"login"* || $$playbook == *"logout"* || $$playbook == *"route"* || $$playbook == *"locationfile.yaml"* || $$playbook == *"nsip6.yaml"* || $$playbook == *"hanode.yaml"* ]]; then \ continue; \ fi; \ echo "Running $$playbook"; \ diff --git a/examples/bgprouter.yaml b/examples/bgprouter.yaml new file mode 100644 index 00000000..c85f3fa9 --- /dev/null +++ b/examples/bgprouter.yaml @@ -0,0 +1,32 @@ +--- +- name: Sample bgprouter playbook + hosts: localhost + gather_facts: false + tasks: + - name: Configure bgprouter + delegate_to: localhost + netscaler.adc.bgprouter: + state: present + + localAS: 122 + routerId: "2.2.2.2" + afParams: + - addressFamily: "ipv4" + redistribute: + - protocol: "static" + routeMap: "test" + - addressFamily: "ipv6" + neighbor: + - address: "44.1.1.33" + remoteAS: 300 + ASOriginationInterval: 11 + advertisementInterval: 34 + updateSource: "vlan101" + singlehopBfd: false + multihopBfd: false + afParams: + - addressFamily: "ipv4" + routeMap: + - name: "test" + direction: "out" + - addressFamily: "ipv6" diff --git a/meta/runtime.yml b/meta/runtime.yml index 73be80cf..84049f1f 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -981,3 +981,4 @@ action_groups: - sslechconfig.py - sslhpkekey.py - sslprofile_sslechconfig_binding.py + - bgprouter diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index 4038f0b3..39a9c05a 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -15,7 +15,11 @@ from ansible.module_utils.six.moves.urllib.parse import quote from ansible.module_utils.urls import fetch_url -from .constants import HTTP_SUCCESS_CODES +from .constants import ( + DYNAMIC_PROTOCOLS, + DYNAMIC_PROTOCOLS_ALIAS, + HTTP_SUCCESS_CODES, +) from .decorators import trace from .logger import log @@ -98,6 +102,9 @@ def url_builder( filter = filter if filter is not None else {} # Construct basic URL + if resource in DYNAMIC_PROTOCOLS: + resource = "routerDynamicRouting/" + DYNAMIC_PROTOCOLS_ALIAS[resource] + url = "%s://%s/%s/%s" % ( self._module.params["nitro_protocol"], self._module.params["nsip"], @@ -235,6 +242,7 @@ def post(self, post_data, resource, action=None): def put(self, put_data, resource=None, id=None): url = self.url_builder(resource, id=id) data = self._module.jsonify(put_data) + return self.send("PUT", url, data) @trace diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index 7312f652..03d7b1ce 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -10,6 +10,8 @@ import re from .constants import ( + DYNAMIC_PROTOCOLS, + DYNAMIC_PROTOCOLS_ALIAS, GLOBAL_BINDING_ARG_LIST, HTTP_RESOURCE_ALREADY_EXISTS, HTTP_RESOURCE_NOT_FOUND, @@ -94,16 +96,29 @@ def get_resource(client, resource_name, resource_id=None, resource_module_params args=get_args, ) else: - status_code, response_body = client.get( - resource=resource_name, - id=resource_id, - args=get_args, - ) + if resource_name in DYNAMIC_PROTOCOLS: + new_resource_name = ( + "routerDynamicRouting/" + DYNAMIC_PROTOCOLS_ALIAS[resource_name] + ) + status_code, response_body = client.get( + resource=new_resource_name, + id=resource_id, + args=get_args, + ) + else: + status_code, response_body = client.get( + resource=resource_name, + id=resource_id, + args=get_args, + ) if status_code in {HTTP_RESOURCE_NOT_FOUND}: return False, [] if status_code in HTTP_SUCCESS_CODES: # for zero bindings and some resources, the response_body will be {'errorcode': 0, 'message': 'Done', 'severity': 'NONE'} - if resource_name not in response_body: + if ( + resource_name not in response_body + and resource_name not in DYNAMIC_PROTOCOLS + ): if resource_name == "sslcipher": resource_primary_key = NITRO_RESOURCE_MAP[resource_name]["primary_key"] return True, [ @@ -112,7 +127,12 @@ def get_resource(client, resource_name, resource_id=None, resource_module_params return False, [] # `update-only` resources return a dict instead of a list. - return_response = response_body[resource_name] + if resource_name in DYNAMIC_PROTOCOLS: + return_response = response_body.get("routerDynamicRouting", {}).get( + DYNAMIC_PROTOCOLS_ALIAS[resource_name], {} + ) + else: + return_response = response_body[resource_name] # FIXME: NITRO-BUG: for some resources like `policypatset_pattern_binding`, NITRO returns keys with uppercase. eg: `String` for `string`. # So, we are converting the keys to lowercase. # except for `ping` and `traceroute`, all the othe resources returns a keys with lowercase. @@ -242,7 +262,6 @@ def _check_create_resource_params(resource_name, resource_module_params, action= key, resource_name, action.upper() ) ) - return True, None, post_data @@ -281,7 +300,11 @@ def create_resource(client, resource_name, resource_module_params, action=None): if not ok: return False, err - post_data = {resource_name: post_data} + if resource_name in DYNAMIC_PROTOCOLS: + post_data = {DYNAMIC_PROTOCOLS_ALIAS[resource_name]: post_data} + post_data = {"routerDynamicRouting": post_data} + else: + post_data = {resource_name: post_data} status_code, response_body = client.post( post_data=post_data, resource=resource_name, @@ -355,7 +378,11 @@ def update_resource(client, resource_name, resource_module_params): if not ok: return False, err - put_data = {resource_name: put_payload} + if resource_name in DYNAMIC_PROTOCOLS: + put_data = {DYNAMIC_PROTOCOLS_ALIAS[resource_name]: put_payload} + put_data = {"routerDynamicRouting": put_data} + else: + put_data = {resource_name: put_payload} status_code, response_body = client.put( put_data=put_data, diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index ce019cb8..eb95a5e4 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -151,3 +151,28 @@ "sslkeyfile", "systementitydata", ] + +# Dynamic Protocol list +DYNAMIC_PROTOCOLS = [ + "accesslist", + "bfdinterface", + "iproute", + "ospf6interface", + "routemap", + "ospf6router", + "bgprouter", + "ospfrouter", + "ospfinterface", +] + +DYNAMIC_PROTOCOLS_ALIAS = { + "accesslist": "accessList", + "bfdinterface": "bfdInterface", + "iproute": "ipRoute", + "ospf6interface": "ospf6Interface", + "routemap": "routeMap", + "ospf6router": "ospf6Router", + "bgprouter": "bgpRouter", + "ospfrouter": "ospfRouter", + "ospfinterface": "ospfInterface", +} diff --git a/plugins/module_utils/module_executor.py b/plugins/module_utils/module_executor.py index 48e36ea5..9e1df85a 100644 --- a/plugins/module_utils/module_executor.py +++ b/plugins/module_utils/module_executor.py @@ -305,17 +305,30 @@ def _add_nitro_attributes_aliases(self, payload): @trace def _filter_resource_module_params(self): + def filter_values(value): + if isinstance(value, dict): + cleaned_dict = {} + for k, v in value.items(): + cleaned_v = filter_values(v) + if cleaned_v is not None: + cleaned_dict[k] = cleaned_v + return cleaned_dict if cleaned_dict else None + elif isinstance(value, list): + cleaned_list = [] + for item in value: + cleaned_item = filter_values(item) + if cleaned_item is not None: + cleaned_list.append(cleaned_item) + return cleaned_list if cleaned_list else None + else: + return value if value is not None else None + log("DEBUG: self.module.params: %s" % self.module.params) for k, v in self.module.params.items(): - if (not k.endswith("_binding")) and ( - k - in NITRO_RESOURCE_MAP[self.resource_name]["readwrite_arguments"].keys() - ): - # self.module.params is a dict of key:value pairs. If an attribute is not - # defined in the playbook, it's value will be None. So, filter out those attributes. - # Also, filter out attributes ending with `_binding` as they are handled separately - if v is not None: - self.resource_module_params[k] = v + if not k.endswith("_binding") and k in NITRO_RESOURCE_MAP[self.resource_name]["readwrite_arguments"]: + cleaned_value = filter_values(v) + if cleaned_value is not None: + self.resource_module_params[k] = cleaned_value if self.resource_name in NITRO_ATTRIBUTES_ALIASES: self.resource_module_params = self._add_nitro_attributes_aliases( @@ -500,7 +513,7 @@ def create_or_update(self): errorcode = None message = None err_str = str(err) - regex_string = re.search(r'status_code:\s*(\d+)', err_str) + regex_string = re.search(r"status_code:\s*(\d+)", err_str) if regex_string: status_code = int(regex_string.group(1)) regex_string = re.search(r"'errorcode':\s*(\d+)", err_str) @@ -510,9 +523,9 @@ def create_or_update(self): if regex_string: message = regex_string.group(1) if not ( - status_code == 599 and - errorcode == 1065 and - message == "Internal error while adding HSM key." + status_code == 599 + and errorcode == 1065 + and message == "Internal error while adding HSM key." ): self.return_failure(err) @@ -580,15 +593,21 @@ def create_or_update(self): "INFO: Resource %s:%s exists and is different. Will be REMOVED and ADDED." % (self.resource_name, self.resource_id) ) - if self.resource_name == "systemfile": - # If the systemfile is present, we will delete it and add it again - self.delete() - ok, err = create_resource( - self.client, self.resource_name, self.resource_module_params - ) - if not ok: - self.return_failure(err) - + # If the systemfile is present, we will delete it and add it again + self.delete() + ok, err = create_resource( + self.client, self.resource_name, self.resource_module_params + ) + if not ok: + self.return_failure(err) + elif self.resource_name == "location": + # TODO: primary composite key needs to be added. + # location resource has composite primary key. 1.ipfrom 2.ipto + ok, err = create_resource( + self.client, self.resource_name, self.resource_module_params + ) + if not ok: + self.return_failure(err) elif self.resource_name.endswith("_binding"): # Generally bindings are not updated. They are removed and added again. log( @@ -707,7 +726,7 @@ def delete(self): errorcode = None message = None err_str = str(err) - regex_string = re.search(r'status_code:\s*(\d+)', err_str) + regex_string = re.search(r"status_code:\s*(\d+)", err_str) if regex_string: status_code = int(regex_string.group(1)) regex_string = re.search(r"'errorcode':\s*(\d+)", err_str) @@ -717,9 +736,9 @@ def delete(self): if regex_string: message = regex_string.group(1) if not ( - status_code == 599 and - errorcode == 1065 and - message == "Internal error while adding HSM key." + status_code == 599 + and errorcode == 1065 + and message == "Internal error while adding HSM key." ): self.return_failure(err) else: diff --git a/plugins/module_utils/nitro_resource_map.py b/plugins/module_utils/nitro_resource_map.py index 0024ae8a..5a337c5b 100644 --- a/plugins/module_utils/nitro_resource_map.py +++ b/plugins/module_utils/nitro_resource_map.py @@ -16532,6 +16532,108 @@ "singleton": False, "update_payload_keys": [], }, + "bgprouter": { + "_supported_operations": ["get", "add", "unset", "update", "delete"], + "action_payload_keys": { + "apply": [], + "create": [], + "force": [], + "import": [], + "link": [], + "switch": [], + "unlink": [], + "unset": ["afParams", "localAS", "neighbor", "routerId"], + }, + "add_payload_keys": ["afParams", "localAS", "neighbor", "routerId"], + "bindings": [], + "bindprimary_key": "", + "delete_arg_keys": ["localAS"], + "disable_payload_keys": [], + "enable_payload_keys": [], + "get_arg_keys": [], + "immutable_keys": [], + "password_keys": [], + "primary_key": "", + "primary_key_composite": [], + "update_payload_keys": ["afParams", "localAS", "neighbor", "routerId"], + "readwrite_arguments": { + "localAS": {"no_log": False, "type": "int"}, + "routerId": {"no_log": False, "type": "str"}, + "afParams": { + "type": "list", + "elements": "dict", + "options": { + "addressFamily": { + "no_log": False, + "type": "str", + "choices": ["ipv4", "ipv6"], + }, + "redistribute": { + "type": "list", + "elements": "dict", + "options": { + "protocol": { + "no_log": False, + "type": "str", + "choices": [ + "kernel", + "connected", + "static", + "rip", + "ospf", + "isis", + "intranet", + ], + }, + "routeMap": {"no_log": False, "type": "str"}, + }, + }, + }, + }, + "neighbor": { + "type": "list", + "elements": "dict", + "options": { + "ASOriginationInterval": {"no_log": False, "type": "int"}, + "address": {"no_log": False, "type": "str"}, + "advertisementInterval": {"no_log": False, "type": "int"}, + "afParams": { + "type": "list", + "elements": "dict", + "options": { + "activate": {"no_log": False, "type": "bool"}, + "addressFamily": { + "no_log": False, + "type": "str", + "choices": ["ipv4", "ipv6"], + }, + "routeMap": { + "type": "list", + "elements": "dict", + "options": { + "direction": { + "no_log": False, + "type": "str", + "choices": ["in", "out"], + }, + "name": {"no_log": False, "type": "str"}, + }, + }, + }, + }, + "connectTimer": {"no_log": False, "type": "int"}, + "holdTimerConfig": {"no_log": False, "type": "int"}, + "keepaliveTimerConfig": {"no_log": False, "type": "int"}, + "md5Password": {"no_log": True, "type": "str"}, + "multihopBfd": {"no_log": False, "type": "bool"}, + "remoteAS": {"no_log": False, "type": "int"}, + "singlehopBfd": {"no_log": False, "type": "bool"}, + "updateSource": {"no_log": False, "type": "str"}, + }, + }, + }, + "singleton": False, + }, "botglobal_botpolicy_binding": { "_supported_operations": ["add", "count", "delete", "get"], "action_payload_keys": { diff --git a/plugins/modules/bgprouter.py b/plugins/modules/bgprouter.py new file mode 100644 index 00000000..162c2fed --- /dev/null +++ b/plugins/modules/bgprouter.py @@ -0,0 +1,264 @@ +#!/usr/bin/python + +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 Cloud Software Group, Inc. +# MIT License (see LICENSE or https://opensource.org/licenses/MIT) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +ANSIBLE_METADATA = { + "metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community", +} + +DOCUMENTATION = r""" +--- +module: bgprouter +short_description: Configuration for BGP Router resource. +description: Configuration for BGP Router resource. +version_added: 2.0.0 +author: + - Sumanth Lingappa (@sumanth-lingappa) + - Shiva Shankar Vaddepally (@shivashankar-vaddepally) +options: + state: + type: str + choices: + - present + - absent + - unset + default: present + description: + - The state of the resource being configured by the module on the NetScaler ADC node. + - When C(present), the resource will be added/updated configured according to the module's parameters. + - When C(absent), the resource will be deleted from the NetScaler ADC node. + - When C(unset), the resource will be unset on the NetScaler ADC node. + remove_non_updatable_params: + type: str + choices: + - 'yes' + - 'no' + default: 'no' + description: + - When given yes, the module will remove any parameters that are not updatable in the resource. + - If no, the module will return error if any non-updatable parameters are provided. + localAS: + type: int + description: + - Autonomous system number for the BGP router. + routerId: + type: str + description: + - Router identifier for the BGP routing instance. + afParams: + type: list + elements: dict + description: + - Address family parameters for the BGP router. + suboptions: + addressFamily: + type: str + description: + - Address family of the routes. + choices: + - ipv4 + - ipv6 + redistribute: + type: list + elements: dict + description: + - Redistribution settings per protocol. + suboptions: + protocol: + type: str + description: + - The protocol from which routes need to be redistributed. + choices: + - kernel + - connected + - static + - rip + - ospf + - isis + - intranet + routeMap: + type: str + description: + - Route map reference for redistribution. + neighbor: + type: list + elements: dict + description: + - Neighbor router configuration. + suboptions: + ASOriginationInterval: + type: int + description: + - Minimum interval between sending AS-origination routing updates. + address: + type: str + description: + - The IPv4 or IPv6 address of the neighboring router. + advertisementInterval: + type: int + description: + - Minimum interval between sending BGP routing updates. + afParams: + type: list + elements: dict + description: + - Address family parameters for the neighbor. + suboptions: + activate: + type: bool + description: + - Enable the address family for the neighbor. + addressFamily: + type: str + description: + - Address family identifier. + choices: + - ipv4 + - ipv6 + routeMap: + type: list + description: + - Route map configuration. + elements: dict + suboptions: + name: + type: str + description: + - Name of the route map. + direction: + type: str + description: + - Direction for the route map. + choices: + - in + - out + connectTimer: + type: int + description: + - Time interval (in seconds) for the ConnectRetry timer. + holdTimerConfig: + type: int + description: + - Configured hold time for the neighbor. + keepaliveTimerConfig: + type: int + description: + - Configured keepalive time for the neighbor. + md5Password: + type: str + description: + - MD5 password for the neighbor. + multihopBfd: + type: bool + description: + - Enable BFD for multihop sessions. + remoteAS: + type: int + description: + - AS number of the neighbor. + singlehopBfd: + type: bool + description: + - Enable BFD on this neighbor. + updateSource: + type: str + description: + - Source of routing updates. + +extends_documentation_fragment: netscaler.adc.netscaler_adc + +""" + +EXAMPLES = r""" +--- +- name: Sample bgprouter playbook + hosts: localhost + gather_facts: false + tasks: + - name: Configure bgprouter + delegate_to: localhost + netscaler.adc.bgprouter: + state: present + + localAS: 122 + routerId: "2.2.2.2" + afParams: + - addressFamily: "ipv4" + redistribute: + - protocol: "static" + routeMap: "test" + - addressFamily: "ipv6" + neighbor: + - address: "44.1.1.33" + remoteAS: 300 + ASOriginationInterval: 11 + advertisementInterval: 34 + updateSource: "vlan101" + singlehopBfd: false + multihopBfd: false + afParams: + - addressFamily: "ipv4" + routeMap: + - name: "test" + direction: "out" + - addressFamily: "ipv6" +""" + +RETURN = r""" +--- +changed: + description: Indicates if any change is made by the module + returned: always + type: bool + sample: true +diff: + description: Dictionary of before and after changes + returned: always + type: dict + sample: {'before': {'key1': 'xyz'}, 'after': {'key2': 'pqr'}, 'prepared': 'changes + done'} +diff_list: + description: List of differences between the actual configured object and the configuration + specified in the module + returned: when changed + type: list + sample: ["Attribute `key1` differs. Desired: () XYZ. Existing: () PQR"] +failed: + description: Indicates if the module failed or not + returned: always + type: bool + sample: false +loglines: + description: list of logged messages by the module + returned: always + type: list + sample: ['message 1', 'message 2'] + +""" + + +import os + +from ..module_utils.module_executor import ModuleExecutor + +RESOURCE_NAME = os.path.basename(__file__).replace(".py", "") + + +def main(): + executor = ModuleExecutor(RESOURCE_NAME) + executor.main() + + +if __name__ == "__main__": + main() diff --git a/tests/sanity/ignore-2.15.txt b/tests/sanity/ignore-2.15.txt index cf19e3b4..4ebbf769 100644 --- a/tests/sanity/ignore-2.15.txt +++ b/tests/sanity/ignore-2.15.txt @@ -947,4 +947,5 @@ plugins/modules/nslaslicense.py validate-modules:missing-gplv3-license # We use plugins/modules/sslechconfig.py validate-modules:missing-gplv3-license # We use MIT license plugins/modules/sslhpkekey.py validate-modules:missing-gplv3-license # We use MIT license plugins/modules/sslprofile_sslechconfig_binding.py validate-modules:missing-gplv3-license # We use MIT license -plugins/modules/authenticationprotecteduseraction.py validate-modules:missing-gplv3-license # We use MIT license \ No newline at end of file +plugins/modules/authenticationprotecteduseraction.py validate-modules:missing-gplv3-license # We use MIT license +plugins/modules/bgprouter.py validate-modules:missing-gplv3-license # We use MIT license \ No newline at end of file diff --git a/tests/sanity/ignore-2.16.txt b/tests/sanity/ignore-2.16.txt index cf19e3b4..4ebbf769 100644 --- a/tests/sanity/ignore-2.16.txt +++ b/tests/sanity/ignore-2.16.txt @@ -947,4 +947,5 @@ plugins/modules/nslaslicense.py validate-modules:missing-gplv3-license # We use plugins/modules/sslechconfig.py validate-modules:missing-gplv3-license # We use MIT license plugins/modules/sslhpkekey.py validate-modules:missing-gplv3-license # We use MIT license plugins/modules/sslprofile_sslechconfig_binding.py validate-modules:missing-gplv3-license # We use MIT license -plugins/modules/authenticationprotecteduseraction.py validate-modules:missing-gplv3-license # We use MIT license \ No newline at end of file +plugins/modules/authenticationprotecteduseraction.py validate-modules:missing-gplv3-license # We use MIT license +plugins/modules/bgprouter.py validate-modules:missing-gplv3-license # We use MIT license \ No newline at end of file diff --git a/tests/sanity/ignore-2.17.txt b/tests/sanity/ignore-2.17.txt index cf19e3b4..4ebbf769 100644 --- a/tests/sanity/ignore-2.17.txt +++ b/tests/sanity/ignore-2.17.txt @@ -947,4 +947,5 @@ plugins/modules/nslaslicense.py validate-modules:missing-gplv3-license # We use plugins/modules/sslechconfig.py validate-modules:missing-gplv3-license # We use MIT license plugins/modules/sslhpkekey.py validate-modules:missing-gplv3-license # We use MIT license plugins/modules/sslprofile_sslechconfig_binding.py validate-modules:missing-gplv3-license # We use MIT license -plugins/modules/authenticationprotecteduseraction.py validate-modules:missing-gplv3-license # We use MIT license \ No newline at end of file +plugins/modules/authenticationprotecteduseraction.py validate-modules:missing-gplv3-license # We use MIT license +plugins/modules/bgprouter.py validate-modules:missing-gplv3-license # We use MIT license \ No newline at end of file