diff --git a/docs/docsite/rst/dev_guide/developing_module_utilities.rst b/docs/docsite/rst/dev_guide/developing_module_utilities.rst index 3ecaa56dda..15a0b26ca6 100644 --- a/docs/docsite/rst/dev_guide/developing_module_utilities.rst +++ b/docs/docsite/rst/dev_guide/developing_module_utilities.rst @@ -31,6 +31,7 @@ The following is a list of module_utils files and a general description. The mod - netapp.py - Functions and utilities for modules that work with the NetApp storage platforms. - netcfg.py - Configuration utility functions for use by networking modules - netcmd.py - Defines commands and comparison operators for use in networking modules +- netscaler.py - Utilities specifically for the netscaler network modules. - network.py - Functions for running commands on networking devices - nxos.py - Contains definitions and helper functions specific to Cisco NXOS networking devices - openstack.py - Utilities for modules that work with Openstack instances. diff --git a/lib/ansible/module_utils/netscaler.py b/lib/ansible/module_utils/netscaler.py new file mode 100644 index 0000000000..214a75f30c --- /dev/null +++ b/lib/ansible/module_utils/netscaler.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Citrix Systems +# +# 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. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# + +import json +import re + +from ansible.module_utils.basic import env_fallback + + +class ConfigProxy(object): + + def __init__(self, actual, client, attribute_values_dict, readwrite_attrs, transforms={}, readonly_attrs=[], immutable_attrs=[], json_encodes=[]): + + # Actual config object from nitro sdk + self.actual = actual + + # nitro client + self.client = client + + # ansible attribute_values_dict + self.attribute_values_dict = attribute_values_dict + + self.readwrite_attrs = readwrite_attrs + self.readonly_attrs = readonly_attrs + self.immutable_attrs = immutable_attrs + self.json_encodes = json_encodes + self.transforms = transforms + + self.attribute_values_processed = {} + for attribute, value in self.attribute_values_dict.items(): + if attribute in transforms: + for transform in self.transforms[attribute]: + if transform == 'bool_yes_no': + value = 'YES' if value is True else 'NO' + elif transform == 'bool_on_off': + value = 'ON' if value is True else 'OFF' + elif callable(transform): + value = transform(value) + else: + raise Exception('Invalid transform %s' % transform) + self.attribute_values_processed[attribute] = value + + self._copy_attributes_to_actual() + + def _copy_attributes_to_actual(self): + for attribute in self.readwrite_attrs: + if attribute in self.attribute_values_processed: + attribute_value = self.attribute_values_processed[attribute] + + if attribute_value is None: + continue + + # Fallthrough + if attribute in self.json_encodes: + attribute_value = json.JSONEncoder().encode(attribute_value).strip('"') + setattr(self.actual, attribute, attribute_value) + + def __getattr__(self, name): + if name in self.attribute_values_dict: + return self.attribute_values_dict[name] + else: + raise AttributeError('No attribute %s found' % name) + + def add(self): + self.actual.__class__.add(self.client, self.actual) + + def update(self): + return self.actual.__class__.update(self.client, self.actual) + + def delete(self): + self.actual.__class__.delete(self.client, self.actual) + + def get(self, *args, **kwargs): + result = self.actual.__class__.get(self.client, *args, **kwargs) + + return result + + def has_equal_attributes(self, other): + if self.diff_object(other) == {}: + return True + else: + return False + + def diff_object(self, other): + diff_dict = {} + for attribute in self.attribute_values_processed: + # Skip readonly attributes + if attribute not in self.readwrite_attrs: + continue + + # Skip attributes not present in module arguments + if self.attribute_values_processed[attribute] is None: + continue + + # Check existence + if hasattr(other, attribute): + attribute_value = getattr(other, attribute) + else: + diff_dict[attribute] = 'missing from other' + continue + + # Compare values + param_type = self.attribute_values_processed[attribute].__class__ + if param_type(attribute_value) != self.attribute_values_processed[attribute]: + str_tuple = ( + type(self.attribute_values_processed[attribute]), + self.attribute_values_processed[attribute], + type(attribute_value), + attribute_value, + ) + diff_dict[attribute] = 'difference. ours: (%s) %s other: (%s) %s' % str_tuple + return diff_dict + + def get_actual_rw_attributes(self, filter='name'): + if self.actual.__class__.count_filtered(self.client, '%s:%s' % (filter, self.attribute_values_dict[filter])) == 0: + return {} + server_list = self.actual.__class__.get_filtered(self.client, '%s:%s' % (filter, self.attribute_values_dict[filter])) + actual_instance = server_list[0] + ret_val = {} + for attribute in self.readwrite_attrs: + if not hasattr(actual_instance, attribute): + continue + ret_val[attribute] = getattr(actual_instance, attribute) + return ret_val + + def get_actual_ro_attributes(self, filter='name'): + if self.actual.__class__.count_filtered(self.client, '%s:%s' % (filter, self.attribute_values_dict[filter])) == 0: + return {} + server_list = self.actual.__class__.get_filtered(self.client, '%s:%s' % (filter, self.attribute_values_dict[filter])) + actual_instance = server_list[0] + ret_val = {} + for attribute in self.readonly_attrs: + if not hasattr(actual_instance, attribute): + continue + ret_val[attribute] = getattr(actual_instance, attribute) + return ret_val + + def get_missing_rw_attributes(self): + return list(set(self.readwrite_attrs) - set(self.get_actual_rw_attributes().keys())) + + def get_missing_ro_attributes(self): + return list(set(self.readonly_attrs) - set(self.get_actual_ro_attributes().keys())) + + +def get_immutables_intersection(config_proxy, keys): + immutables_set = set(config_proxy.immutable_attrs) + keys_set = set(keys) + # Return list of sets' intersection + return list(immutables_set & keys_set) + + +def ensure_feature_is_enabled(client, feature_str): + enabled_features = client.get_enabled_features() + if feature_str not in enabled_features: + client.enable_features(feature_str) + client.save_config() + + +def get_nitro_client(module): + from nssrc.com.citrix.netscaler.nitro.service.nitro_service import nitro_service + + client = nitro_service(module.params['nsip'], module.params['nitro_protocol']) + client.set_credential(module.params['nitro_user'], module.params['nitro_pass']) + client.timeout = float(module.params['nitro_timeout']) + client.certvalidation = module.params['validate_certs'] + return client + + +netscaler_common_arguments = dict( + nsip=dict( + required=True, + fallback=(env_fallback, ['NETSCALER_NSIP']), + ), + nitro_user=dict( + required=True, + fallback=(env_fallback, ['NETSCALER_NITRO_USER']), + no_log=True + ), + nitro_pass=dict( + required=True, + fallback=(env_fallback, ['NETSCALER_NITRO_PASS']), + no_log=True + ), + nitro_protocol=dict( + choices=['http', 'https'], + fallback=(env_fallback, ['NETSCALER_NITRO_PROTOCOL']), + default='http' + ), + validate_certs=dict( + default=True, + type='bool' + ), + nitro_timeout=dict(default=310, type='float'), + state=dict( + choices=[ + 'present', + 'absent', + ], + default='present', + ), + save_config=dict( + type='bool', + default=True, + ), +) + + +loglines = [] + + +def complete_missing_attributes(actual, attrs_list, fill_value=None): + for attribute in attrs_list: + if not hasattr(actual, attribute): + setattr(actual, attribute, fill_value) + + +def log(msg): + loglines.append(msg) + + +def get_ns_version(client): + from nssrc.com.citrix.netscaler.nitro.resource.config.ns.nsversion import nsversion + result = nsversion.get(client) + m = re.match(r'^.*NS(\d+)\.(\d+).*$', result[0].version) + if m is None: + return None + else: + return int(m.group(1)), int(m.group(2)) + + +def monkey_patch_nitro_api(): + + from nssrc.com.citrix.netscaler.nitro.resource.base.Json import Json + + def new_resource_to_string_convert(self, resrc): + try: + # Line below is the actual patch + dict_valid_values = dict((k.replace('_', '', 1), v) for k, v in resrc.__dict__.items() if v) + return json.dumps(dict_valid_values) + except Exception as e: + raise e + Json.resource_to_string_convert = new_resource_to_string_convert + + from nssrc.com.citrix.netscaler.nitro.util.nitro_util import nitro_util + + @classmethod + def object_to_string_new(cls, obj): + try: + str_ = "" + flds = obj.__dict__ + # Line below is the actual patch + flds = dict((k.replace('_', '', 1), v) for k, v in flds.items() if v) + if (flds): + for k, v in flds.items(): + str_ = str_ + "\"" + k + "\":" + if type(v) is unicode: + v = v.encode('utf8') + if type(v) is bool: + str_ = str_ + v + elif type(v) is str: + str_ = str_ + "\"" + v + "\"" + elif type(v) is int: + str_ = str_ + "\"" + str(v) + "\"" + if str_: + str_ = str_ + "," + return str_ + except Exception as e: + raise e + + @classmethod + def object_to_string_withoutquotes_new(cls, obj): + try: + str_ = "" + flds = obj.__dict__ + # Line below is the actual patch + flds = dict((k.replace('_', '', 1), v) for k, v in flds.items() if v) + i = 0 + if (flds): + for k, v in flds.items(): + str_ = str_ + k + ":" + if type(v) is unicode: + v = v.encode('utf8') + if type(v) is bool: + str_ = str_ + v + elif type(v) is str: + str_ = str_ + cls.encode(v) + elif type(v) is int: + str_ = str_ + str(v) + i = i + 1 + if i != (len(flds.items())) and str_: + str_ = str_ + "," + return str_ + except Exception as e: + raise e + + nitro_util.object_to_string = object_to_string_new + nitro_util.object_to_string_withoutquotes = object_to_string_withoutquotes_new diff --git a/lib/ansible/modules/network/netscaler/__init__.py b/lib/ansible/modules/network/netscaler/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/modules/network/netscaler/netscaler_service.py b/lib/ansible/modules/network/netscaler/netscaler_service.py new file mode 100644 index 0000000000..dbcb49dad4 --- /dev/null +++ b/lib/ansible/modules/network/netscaler/netscaler_service.py @@ -0,0 +1,944 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Citrix Systems +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# + +ANSIBLE_METADATA = {'metadata_version': '1.0', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: netscaler_service +short_description: Manage service configuration in Netscaler +description: + - Manage service configuration in Netscaler. + - This module allows the creation, deletion and modification of Netscaler services. + - This module is intended to run either on the ansible control node or a bastion (jumpserver) with access to the actual netscaler instance. + - This module supports check mode. + +version_added: "2.4.0" + +author: George Nikolopoulos (@giorgos-nikolopoulos) + +options: + + name: + description: + - >- + Name for the service. Must begin with an ASCII alphabetic or underscore C(_) character, and must + contain only ASCII alphanumeric, underscore C(_), hash C(#), period C(.), space C( ), colon C(:), at C(@), equals + C(=), and hyphen C(-) characters. Cannot be changed after the service has been created. + - "Minimum length = 1" + + ip: + description: + - "IP to assign to the service." + - "Minimum length = 1" + + servername: + description: + - "Name of the server that hosts the service." + - "Minimum length = 1" + + servicetype: + choices: + - 'HTTP' + - 'FTP' + - 'TCP' + - 'UDP' + - 'SSL' + - 'SSL_BRIDGE' + - 'SSL_TCP' + - 'DTLS' + - 'NNTP' + - 'RPCSVR' + - 'DNS' + - 'ADNS' + - 'SNMP' + - 'RTSP' + - 'DHCPRA' + - 'ANY' + - 'SIP_UDP' + - 'SIP_TCP' + - 'SIP_SSL' + - 'DNS_TCP' + - 'ADNS_TCP' + - 'MYSQL' + - 'MSSQL' + - 'ORACLE' + - 'RADIUS' + - 'RADIUSListener' + - 'RDP' + - 'DIAMETER' + - 'SSL_DIAMETER' + - 'TFTP' + - 'SMPP' + - 'PPTP' + - 'GRE' + - 'SYSLOGTCP' + - 'SYSLOGUDP' + - 'FIX' + - 'SSL_FIX' + description: + - "Protocol in which data is exchanged with the service." + + port: + description: + - "Port number of the service." + - "Range 1 - 65535" + - "* in CLI is represented as 65535 in NITRO API" + + cleartextport: + description: + - >- + Port to which clear text data must be sent after the appliance decrypts incoming SSL traffic. + Applicable to transparent SSL services. + - "Minimum value = 1" + + cachetype: + choices: + - 'TRANSPARENT' + - 'REVERSE' + - 'FORWARD' + description: + - "Cache type supported by the cache server." + + maxclient: + description: + - "Maximum number of simultaneous open connections to the service." + - "Minimum value = 0" + - "Maximum value = 4294967294" + + healthmonitor: + description: + - "Monitor the health of this service" + default: yes + + maxreq: + description: + - "Maximum number of requests that can be sent on a persistent connection to the service." + - "Note: Connection requests beyond this value are rejected." + - "Minimum value = 0" + - "Maximum value = 65535" + + cacheable: + description: + - "Use the transparent cache redirection virtual server to forward requests to the cache server." + - "Note: Do not specify this parameter if you set the Cache Type parameter." + default: no + + cip: + choices: + - 'ENABLED' + - 'DISABLED' + description: + - >- + Before forwarding a request to the service, insert an HTTP header with the client's IPv4 or IPv6 + address as its value. Used if the server needs the client's IP address for security, accounting, or + other purposes, and setting the Use Source IP parameter is not a viable option. + + cipheader: + description: + - >- + Name for the HTTP header whose value must be set to the IP address of the client. Used with the + Client IP parameter. If you set the Client IP parameter, and you do not specify a name for the + header, the appliance uses the header name specified for the global Client IP Header parameter (the + cipHeader parameter in the set ns param CLI command or the Client IP Header parameter in the + Configure HTTP Parameters dialog box at System > Settings > Change HTTP parameters). If the global + Client IP Header parameter is not specified, the appliance inserts a header with the name + "client-ip.". + - "Minimum length = 1" + + usip: + description: + - >- + Use the client's IP address as the source IP address when initiating a connection to the server. When + creating a service, if you do not set this parameter, the service inherits the global Use Source IP + setting (available in the enable ns mode and disable ns mode CLI commands, or in the System > + Settings > Configure modes > Configure Modes dialog box). However, you can override this setting + after you create the service. + + pathmonitor: + description: + - "Path monitoring for clustering." + + pathmonitorindv: + description: + - "Individual Path monitoring decisions." + + useproxyport: + description: + - >- + Use the proxy port as the source port when initiating connections with the server. With the NO + setting, the client-side connection port is used as the source port for the server-side connection. + - "Note: This parameter is available only when the Use Source IP (USIP) parameter is set to YES." + + sc: + description: + - "State of SureConnect for the service." + default: off + + sp: + description: + - "Enable surge protection for the service." + + rtspsessionidremap: + description: + - "Enable RTSP session ID mapping for the service." + default: off + + clttimeout: + description: + - "Time, in seconds, after which to terminate an idle client connection." + - "Minimum value = 0" + - "Maximum value = 31536000" + + svrtimeout: + description: + - "Time, in seconds, after which to terminate an idle server connection." + - "Minimum value = 0" + - "Maximum value = 31536000" + + customserverid: + description: + - >- + Unique identifier for the service. Used when the persistency type for the virtual server is set to + Custom Server ID. + default: 'None' + + serverid: + description: + - "The identifier for the service. This is used when the persistency type is set to Custom Server ID." + + cka: + description: + - "Enable client keep-alive for the service." + + tcpb: + description: + - "Enable TCP buffering for the service." + + cmp: + description: + - "Enable compression for the service." + + maxbandwidth: + description: + - "Maximum bandwidth, in Kbps, allocated to the service." + - "Minimum value = 0" + - "Maximum value = 4294967287" + + accessdown: + description: + - >- + Use Layer 2 mode to bridge the packets sent to this service if it is marked as DOWN. If the service + is DOWN, and this parameter is disabled, the packets are dropped. + default: no + + monthreshold: + description: + - >- + Minimum sum of weights of the monitors that are bound to this service. Used to determine whether to + mark a service as UP or DOWN. + - "Minimum value = 0" + - "Maximum value = 65535" + + downstateflush: + choices: + - 'ENABLED' + - 'DISABLED' + description: + - >- + Flush all active transactions associated with a service whose state transitions from UP to DOWN. Do + not enable this option for applications that must complete their transactions. + default: ENABLED + + tcpprofilename: + description: + - "Name of the TCP profile that contains TCP configuration settings for the service." + - "Minimum length = 1" + - "Maximum length = 127" + + httpprofilename: + description: + - "Name of the HTTP profile that contains HTTP configuration settings for the service." + - "Minimum length = 1" + - "Maximum length = 127" + + hashid: + description: + - >- + A numerical identifier that can be used by hash based load balancing methods. Must be unique for each + service. + - "Minimum value = 1" + + comment: + description: + - "Any information about the service." + + appflowlog: + choices: + - 'ENABLED' + - 'DISABLED' + description: + - "Enable logging of AppFlow information." + default: ENABLED + + netprofile: + description: + - "Network profile to use for the service." + - "Minimum length = 1" + - "Maximum length = 127" + + td: + description: + - >- + Integer value that uniquely identifies the traffic domain in which you want to configure the entity. + If you do not specify an ID, the entity becomes part of the default traffic domain, which has an ID + of 0. + - "Minimum value = 0" + - "Maximum value = 4094" + + processlocal: + choices: + - 'ENABLED' + - 'DISABLED' + description: + - >- + By turning on this option packets destined to a service in a cluster will not under go any steering. + Turn this option for single packet request response mode or when the upstream device is performing a + proper RSS for connection based distribution. + default: DISABLED + + dnsprofilename: + description: + - >- + Name of the DNS profile to be associated with the service. DNS profile properties will applied to the + transactions processed by a service. This parameter is valid only for ADNS and ADNS-TCP services. + - "Minimum length = 1" + - "Maximum length = 127" + + ipaddress: + description: + - "The new IP address of the service." + + graceful: + description: + - >- + Shut down gracefully, not accepting any new connections, and disabling the service when all of its + connections are closed. + default: no + + monitor_bindings: + description: + - A list of load balancing monitors to bind to this service. + - Each monitor entry is a dictionary which may contain the following options. + - Note that if not using the built in monitors they must first be setup. + suboptions: + monitorname: + description: + - Name of the monitor. + weight: + description: + - Weight to assign to the binding between the monitor and service. + dup_state: + choices: + - 'ENABLED' + - 'DISABLED' + description: + - State of the monitor. + - The state setting for a monitor of a given type affects all monitors of that type. + - For example, if an HTTP monitor is enabled, all HTTP monitors on the appliance are (or remain) enabled. + - If an HTTP monitor is disabled, all HTTP monitors on the appliance are disabled. + dup_weight: + description: + - Weight to assign to the binding between the monitor and service. + +extends_documentation_fragment: netscaler +requirements: + - nitro python sdk +''' + +EXAMPLES = ''' +# Monitor monitor-1 must have been already setup + +- name: Setup http service + gather_facts: False + delegate_to: localhost + netscaler_service: + nsip: 172.18.0.2 + nitro_user: nsroot + nitro_pass: nsroot + + state: present + + name: service-http-1 + servicetype: HTTP + ipaddress: 10.78.0.1 + port: 80 + + monitor_bindings: + - monitor-1 +''' + +RETURN = ''' +loglines: + description: list of logged messages by the module + returned: always + type: list + sample: "['message 1', 'message 2']" + +diff: + description: A dictionary with a list of differences between the actual configured object and the configuration specified in the module + returned: failure + type: dict + sample: "{ 'clttimeout': 'difference. ours: (float) 10.0 other: (float) 20.0' }" +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.netscaler import (ConfigProxy, get_nitro_client, netscaler_common_arguments, log, loglines, get_immutables_intersection) +import copy + +try: + from nssrc.com.citrix.netscaler.nitro.resource.config.basic.service import service + from nssrc.com.citrix.netscaler.nitro.resource.config.basic.service_lbmonitor_binding import service_lbmonitor_binding + from nssrc.com.citrix.netscaler.nitro.resource.config.lb.lbmonitor_service_binding import lbmonitor_service_binding + from nssrc.com.citrix.netscaler.nitro.exception.nitro_exception import nitro_exception + PYTHON_SDK_IMPORTED = True +except ImportError as e: + PYTHON_SDK_IMPORTED = False + + +def service_exists(client, module): + if service.count_filtered(client, 'name:%s' % module.params['name']) > 0: + return True + else: + return False + + +def service_identical(client, module, service_proxy): + service_list = service.get_filtered(client, 'name:%s' % module.params['name']) + diff_dict = service_proxy.diff_object(service_list[0]) + # the actual ip address is stored in the ipaddress attribute + # of the retrieved object + if 'ip' in diff_dict: + del diff_dict['ip'] + if len(diff_dict) == 0: + return True + else: + return False + + +def diff(client, module, service_proxy): + service_list = service.get_filtered(client, 'name:%s' % module.params['name']) + diff_object = service_proxy.diff_object(service_list[0]) + if 'ip' in diff_object: + del diff_object['ip'] + return diff_object + + +def get_configured_monitor_bindings(client, module, monitor_bindings_rw_attrs): + bindings = {} + if module.params['monitor_bindings'] is not None: + for binding in module.params['monitor_bindings']: + attribute_values_dict = copy.deepcopy(binding) + # attribute_values_dict['servicename'] = module.params['name'] + attribute_values_dict['servicegroupname'] = module.params['name'] + binding_proxy = ConfigProxy( + actual=lbmonitor_service_binding(), + client=client, + attribute_values_dict=attribute_values_dict, + readwrite_attrs=monitor_bindings_rw_attrs, + ) + key = binding_proxy.monitorname + bindings[key] = binding_proxy + return bindings + + +def get_actual_monitor_bindings(client, module): + bindings = {} + if service_lbmonitor_binding.count(client, module.params['name']) == 0: + return bindings + + # Fallthrough to rest of execution + for binding in service_lbmonitor_binding.get(client, module.params['name']): + # Excluding default monitors since we cannot operate on them + if binding.monitor_name in ('tcp-default', 'ping-default'): + continue + key = binding.monitor_name + actual = lbmonitor_service_binding() + actual.weight = binding.weight + actual.monitorname = binding.monitor_name + actual.dup_weight = binding.dup_weight + actual.servicename = module.params['name'] + bindings[key] = actual + + return bindings + + +def monitor_bindings_identical(client, module, monitor_bindings_rw_attrs): + configured_proxys = get_configured_monitor_bindings(client, module, monitor_bindings_rw_attrs) + actual_bindings = get_actual_monitor_bindings(client, module) + + configured_key_set = set(configured_proxys.keys()) + actual_key_set = set(actual_bindings.keys()) + symmetrical_diff = configured_key_set ^ actual_key_set + if len(symmetrical_diff) > 0: + return False + + # Compare key to key + for monitor_name in configured_key_set: + proxy = configured_proxys[monitor_name] + actual = actual_bindings[monitor_name] + diff_dict = proxy.diff_object(actual) + if 'servicegroupname' in diff_dict: + if proxy.servicegroupname == actual.servicename: + del diff_dict['servicegroupname'] + if len(diff_dict) > 0: + return False + + # Fallthrought to success + return True + + +def sync_monitor_bindings(client, module, monitor_bindings_rw_attrs): + configured_proxys = get_configured_monitor_bindings(client, module, monitor_bindings_rw_attrs) + actual_bindings = get_actual_monitor_bindings(client, module) + configured_keyset = set(configured_proxys.keys()) + actual_keyset = set(actual_bindings.keys()) + + # Delete extra + delete_keys = list(actual_keyset - configured_keyset) + for monitor_name in delete_keys: + log('Deleting binding for monitor %s' % monitor_name) + lbmonitor_service_binding.delete(client, actual_bindings[monitor_name]) + + # Delete and re-add modified + common_keyset = list(configured_keyset & actual_keyset) + for monitor_name in common_keyset: + proxy = configured_proxys[monitor_name] + actual = actual_bindings[monitor_name] + if not proxy.has_equal_attributes(actual): + log('Deleting and re adding binding for monitor %s' % monitor_name) + lbmonitor_service_binding.delete(client, actual) + proxy.add() + + # Add new + new_keys = list(configured_keyset - actual_keyset) + for monitor_name in new_keys: + log('Adding binding for monitor %s' % monitor_name) + configured_proxys[monitor_name].add() + + +def all_identical(client, module, service_proxy, monitor_bindings_rw_attrs): + return service_identical(client, module, service_proxy) and monitor_bindings_identical(client, module, monitor_bindings_rw_attrs) + + +def main(): + + module_specific_arguments = dict( + name=dict(type='str'), + ip=dict(type='str'), + servername=dict(type='str'), + servicetype=dict( + type='str', + choices=[ + 'HTTP', + 'FTP', + 'TCP', + 'UDP', + 'SSL', + 'SSL_BRIDGE', + 'SSL_TCP', + 'DTLS', + 'NNTP', + 'RPCSVR', + 'DNS', + 'ADNS', + 'SNMP', + 'RTSP', + 'DHCPRA', + 'ANY', + 'SIP_UDP', + 'SIP_TCP', + 'SIP_SSL', + 'DNS_TCP', + 'ADNS_TCP', + 'MYSQL', + 'MSSQL', + 'ORACLE', + 'RADIUS', + 'RADIUSListener', + 'RDP', + 'DIAMETER', + 'SSL_DIAMETER', + 'TFTP', + 'SMPP', + 'PPTP', + 'GRE', + 'SYSLOGTCP', + 'SYSLOGUDP', + 'FIX', + 'SSL_FIX' + ] + ), + port=dict(type='int'), + cleartextport=dict(type='int'), + cachetype=dict( + type='str', + choices=[ + 'TRANSPARENT', + 'REVERSE', + 'FORWARD', + ] + ), + maxclient=dict(type='float'), + healthmonitor=dict( + type='bool', + default=True, + ), + maxreq=dict(type='float'), + cacheable=dict( + type='bool', + default=False, + ), + cip=dict( + type='str', + choices=[ + 'ENABLED', + 'DISABLED', + ] + ), + cipheader=dict(type='str'), + usip=dict(type='bool'), + useproxyport=dict(type='bool'), + sc=dict( + type='bool', + default=False, + ), + sp=dict(type='bool'), + rtspsessionidremap=dict( + type='bool', + default=False, + ), + clttimeout=dict(type='float'), + svrtimeout=dict(type='float'), + customserverid=dict( + type='str', + default='None', + ), + cka=dict(type='bool'), + tcpb=dict(type='bool'), + cmp=dict(type='bool'), + maxbandwidth=dict(type='float'), + accessdown=dict( + type='bool', + default=False + ), + monthreshold=dict(type='float'), + downstateflush=dict( + type='str', + choices=[ + 'ENABLED', + 'DISABLED', + ], + default='ENABLED', + ), + tcpprofilename=dict(type='str'), + httpprofilename=dict(type='str'), + hashid=dict(type='float'), + comment=dict(type='str'), + appflowlog=dict( + type='str', + choices=[ + 'ENABLED', + 'DISABLED', + ], + default='ENABLED', + ), + netprofile=dict(type='str'), + processlocal=dict( + type='str', + choices=[ + 'ENABLED', + 'DISABLED', + ], + default='DISABLED', + ), + dnsprofilename=dict(type='str'), + ipaddress=dict(type='str'), + graceful=dict( + type='bool', + default=False, + ), + ) + + hand_inserted_arguments = dict( + monitor_bindings=dict(type='list'), + ) + + argument_spec = dict() + + argument_spec.update(netscaler_common_arguments) + + argument_spec.update(module_specific_arguments) + + argument_spec.update(hand_inserted_arguments) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + module_result = dict( + changed=False, + failed=False, + loglines=loglines, + ) + + # Fail the module if imports failed + if not PYTHON_SDK_IMPORTED: + module.fail_json(msg='Could not load nitro python sdk') + + client = get_nitro_client(module) + + try: + client.login() + except nitro_exception as e: + msg = "nitro exception during login. errorcode=%s, message=%s" % (str(e.errorcode), e.message) + module.fail_json(msg=msg) + except Exception as e: + if str(type(e)) == "": + module.fail_json(msg='Connection error %s' % str(e)) + elif str(type(e)) == "": + module.fail_json(msg='SSL Error %s' % str(e)) + else: + module.fail_json(msg='Unexpected error during login %s' % str(e)) + + # Fallthrough to rest of execution + + # Instantiate Service Config object + readwrite_attrs = [ + 'name', + 'ip', + 'servername', + 'servicetype', + 'port', + 'cleartextport', + 'cachetype', + 'maxclient', + 'healthmonitor', + 'maxreq', + 'cacheable', + 'cip', + 'cipheader', + 'usip', + 'useproxyport', + 'sc', + 'sp', + 'rtspsessionidremap', + 'clttimeout', + 'svrtimeout', + 'customserverid', + 'cka', + 'tcpb', + 'cmp', + 'maxbandwidth', + 'accessdown', + 'monthreshold', + 'downstateflush', + 'tcpprofilename', + 'httpprofilename', + 'hashid', + 'comment', + 'appflowlog', + 'netprofile', + 'processlocal', + 'dnsprofilename', + 'ipaddress', + 'graceful', + ] + + readonly_attrs = [ + 'numofconnections', + 'policyname', + 'serviceconftype', + 'serviceconftype2', + 'value', + 'gslb', + 'dup_state', + 'publicip', + 'publicport', + 'svrstate', + 'monitor_state', + 'monstatcode', + 'lastresponse', + 'responsetime', + 'riseapbrstatsmsgcode2', + 'monstatparam1', + 'monstatparam2', + 'monstatparam3', + 'statechangetimesec', + 'statechangetimemsec', + 'tickssincelaststatechange', + 'stateupdatereason', + 'clmonowner', + 'clmonview', + 'serviceipstr', + 'oracleserverversion', + ] + + immutable_attrs = [ + 'name', + 'ip', + 'servername', + 'servicetype', + 'port', + 'cleartextport', + 'cachetype', + 'cipheader', + 'serverid', + 'state', + 'td', + 'monitor_name_svc', + 'riseapbrstatsmsgcode', + 'graceful', + 'all', + 'Internal', + 'newname', + ] + + transforms = { + 'pathmonitorindv': ['bool_yes_no'], + 'cacheable': ['bool_yes_no'], + 'cka': ['bool_yes_no'], + 'pathmonitor': ['bool_yes_no'], + 'tcpb': ['bool_yes_no'], + 'sp': ['bool_on_off'], + 'graceful': ['bool_yes_no'], + 'usip': ['bool_yes_no'], + 'healthmonitor': ['bool_yes_no'], + 'useproxyport': ['bool_yes_no'], + 'rtspsessionidremap': ['bool_on_off'], + 'sc': ['bool_on_off'], + 'accessdown': ['bool_yes_no'], + 'cmp': ['bool_yes_no'], + } + + monitor_bindings_rw_attrs = [ + 'servicename', + 'servicegroupname', + 'dup_state', + 'dup_weight', + 'monitorname', + 'weight', + ] + + # Translate module arguments to correspondign config oject attributes + if module.params['ip'] is None: + module.params['ip'] = module.params['ipaddress'] + + service_proxy = ConfigProxy( + actual=service(), + client=client, + attribute_values_dict=module.params, + readwrite_attrs=readwrite_attrs, + readonly_attrs=readonly_attrs, + immutable_attrs=immutable_attrs, + transforms=transforms, + ) + + try: + + # Apply appropriate state + if module.params['state'] == 'present': + log('Applying actions for state present') + if not service_exists(client, module): + if not module.check_mode: + service_proxy.add() + sync_monitor_bindings(client, module, monitor_bindings_rw_attrs) + if module.params['save_config']: + client.save_config() + module_result['changed'] = True + elif not all_identical(client, module, service_proxy, monitor_bindings_rw_attrs): + + # Check if we try to change value of immutable attributes + diff_dict = diff(client, module, service_proxy) + immutables_changed = get_immutables_intersection(service_proxy, diff_dict.keys()) + if immutables_changed != []: + msg = 'Cannot update immutable attributes %s. Must delete and recreate entity.' % (immutables_changed,) + module.fail_json(msg=msg, diff=diff_dict, **module_result) + + # Service sync + if not service_identical(client, module, service_proxy): + if not module.check_mode: + service_proxy.update() + + # Monitor bindings sync + if not monitor_bindings_identical(client, module, monitor_bindings_rw_attrs): + if not module.check_mode: + sync_monitor_bindings(client, module, monitor_bindings_rw_attrs) + + module_result['changed'] = True + if not module.check_mode: + if module.params['save_config']: + client.save_config() + else: + module_result['changed'] = False + + # Sanity check for state + if not module.check_mode: + log('Sanity checks for state present') + if not service_exists(client, module): + module.fail_json(msg='Service does not exist', **module_result) + + if not service_identical(client, module, service_proxy): + module.fail_json(msg='Service differs from configured', diff=diff(client, module, service_proxy), **module_result) + + if not monitor_bindings_identical(client, module, monitor_bindings_rw_attrs): + module.fail_json(msg='Monitor bindings are not identical', **module_result) + + elif module.params['state'] == 'absent': + log('Applying actions for state absent') + if service_exists(client, module): + if not module.check_mode: + service_proxy.delete() + if module.params['save_config']: + client.save_config() + module_result['changed'] = True + else: + module_result['changed'] = False + + # Sanity check for state + if not module.check_mode: + log('Sanity checks for state absent') + if service_exists(client, module): + module.fail_json(msg='Service still exists', **module_result) + + except nitro_exception as e: + msg = "nitro exception errorcode=%s, message=%s" % (str(e.errorcode), e.message) + module.fail_json(msg=msg, **module_result) + + client.logout() + module.exit_json(**module_result) + + +if __name__ == "__main__": + main() diff --git a/lib/ansible/utils/module_docs_fragments/netscaler.py b/lib/ansible/utils/module_docs_fragments/netscaler.py new file mode 100644 index 0000000000..025ee2328f --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/netscaler.py @@ -0,0 +1,52 @@ +class ModuleDocFragment(object): + DOCUMENTATION = ''' + +options: + nsip: + description: + - The ip address of the netscaler appliance where the nitro API calls will be made. + - "The port can be specified with the colon (:). E.g. 192.168.1.1:555." + required: True + + nitro_user: + description: + - The username with which to authenticate to the netscaler node. + required: True + + nitro_pass: + description: + - The password with which to authenticate to the netscaler node. + required: True + + nitro_protocol: + choices: [ 'http', 'https' ] + default: http + description: + - Which protocol to use when accessing the nitro API objects. + + validate_certs: + description: + - If C(no), SSL certificates will not be validated. This should only be used on personally controlled sites using self-signed certificates. + required: false + default: 'yes' + + nitro_timeout: + description: + - Time in seconds until a timeout error is thrown when establishing a new session with Netscaler + default: 310 + + state: + choices: ['present', 'absent'] + default: 'present' + description: + - The state of the resource being configured by the module on the netscaler node. + - When present the resource will be created if needed and configured according to the module's parameters. + - When absent the resource will be deleted from the netscaler node. + + save_config: + description: + - If true the module will save the configuration on the netscaler node if it makes any changes. + - The module will not save the configuration on the netscaler node if it made no changes. + type: bool + default: true +''' diff --git a/test/integration/netscaler.yaml b/test/integration/netscaler.yaml new file mode 100644 index 0000000000..262b0ad22a --- /dev/null +++ b/test/integration/netscaler.yaml @@ -0,0 +1,11 @@ +- hosts: netscaler + + gather_facts: no + connection: local + + vars: + limit_to: "*" + debug: false + + roles: + - { role: netscaler_service, when: "limit_to in ['*', 'netscaler_service']" } diff --git a/test/integration/roles/netscaler_service/defaults/main.yaml b/test/integration/roles/netscaler_service/defaults/main.yaml new file mode 100644 index 0000000000..641801f660 --- /dev/null +++ b/test/integration/roles/netscaler_service/defaults/main.yaml @@ -0,0 +1,6 @@ +--- +testcase: "*" +test_cases: [] + +nitro_user: nsroot +nitro_pass: nsroot diff --git a/test/integration/roles/netscaler_service/sample_inventory b/test/integration/roles/netscaler_service/sample_inventory new file mode 100644 index 0000000000..7da2dbbdb8 --- /dev/null +++ b/test/integration/roles/netscaler_service/sample_inventory @@ -0,0 +1,5 @@ + + +[netscaler] + +172.18.0.2 nsip=172.18.0.2 nitro_user=nsroot nitro_pass=nsroot diff --git a/test/integration/roles/netscaler_service/tasks/main.yaml b/test/integration/roles/netscaler_service/tasks/main.yaml new file mode 100644 index 0000000000..729619a17c --- /dev/null +++ b/test/integration/roles/netscaler_service/tasks/main.yaml @@ -0,0 +1,2 @@ +--- +- { include: nitro.yaml, tags: ['nitro'] } diff --git a/test/integration/roles/netscaler_service/tasks/nitro.yaml b/test/integration/roles/netscaler_service/tasks/nitro.yaml new file mode 100644 index 0000000000..00ab502dda --- /dev/null +++ b/test/integration/roles/netscaler_service/tasks/nitro.yaml @@ -0,0 +1,14 @@ +- name: collect all nitro test cases + find: + paths: "{{ role_path }}/tests/nitro" + patterns: "{{ testcase }}.yaml" + register: test_cases + +- name: set test_items + set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}" + +- name: run test case + include: "{{ test_case_to_run }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/test/integration/roles/netscaler_service/tests/nitro/adns_service.yaml b/test/integration/roles/netscaler_service/tests/nitro/adns_service.yaml new file mode 100644 index 0000000000..97b9ccb0c9 --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/adns_service.yaml @@ -0,0 +1,57 @@ +--- + +- include: "{{ role_path }}/tests/nitro/adns_service/setup.yaml" + vars: + check_mode: yes + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/adns_service/setup.yaml" + vars: + check_mode: no + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/adns_service/setup.yaml" + vars: + check_mode: yes + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/adns_service/setup.yaml" + vars: + check_mode: no + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/adns_service/remove.yaml" + vars: + check_mode: yes + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/adns_service/remove.yaml" + vars: + check_mode: no + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/adns_service/remove.yaml" + vars: + check_mode: yes + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/adns_service/remove.yaml" + vars: + check_mode: no + +- assert: + that: not result|changed diff --git a/test/integration/roles/netscaler_service/tests/nitro/adns_service/remove.yaml b/test/integration/roles/netscaler_service/tests/nitro/adns_service/remove.yaml new file mode 100644 index 0000000000..3b3e0f9e23 --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/adns_service/remove.yaml @@ -0,0 +1,14 @@ +--- + +- name: Remove adns service + delegate_to: localhost + register: result + check_mode: "{{ check_mode }}" + netscaler_service: + + nitro_user: "{{nitro_user}}" + nitro_pass: "{{nitro_pass}}" + nsip: "{{nsip}}" + + state: absent + name: service-adns diff --git a/test/integration/roles/netscaler_service/tests/nitro/adns_service/setup.yaml b/test/integration/roles/netscaler_service/tests/nitro/adns_service/setup.yaml new file mode 100644 index 0000000000..c44e1a8dd5 --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/adns_service/setup.yaml @@ -0,0 +1,17 @@ +--- + +- name: Setup adns service + delegate_to: localhost + register: result + check_mode: "{{ check_mode }}" + netscaler_service: + + nitro_user: "{{nitro_user}}" + nitro_pass: "{{nitro_pass}}" + nsip: "{{nsip}}" + state: present + + name: service-adns + ipaddress: 192.168.1.3 + port: 80 + servicetype: ADNS diff --git a/test/integration/roles/netscaler_service/tests/nitro/http_service.yaml b/test/integration/roles/netscaler_service/tests/nitro/http_service.yaml new file mode 100644 index 0000000000..4111bd4a2a --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/http_service.yaml @@ -0,0 +1,85 @@ +--- + +- include: "{{ role_path }}/tests/nitro/http_service/setup.yaml" + vars: + check_mode: yes + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/setup.yaml" + vars: + check_mode: no + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/setup.yaml" + vars: + check_mode: yes + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/setup.yaml" + vars: + check_mode: no + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/update.yaml" + vars: + check_mode: yes + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/update.yaml" + vars: + check_mode: no + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/update.yaml" + vars: + check_mode: yes + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/update.yaml" + vars: + check_mode: no + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/remove.yaml" + vars: + check_mode: yes + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/remove.yaml" + vars: + check_mode: no + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/remove.yaml" + vars: + check_mode: yes + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/http_service/remove.yaml" + vars: + check_mode: no + +- assert: + that: not result|changed diff --git a/test/integration/roles/netscaler_service/tests/nitro/http_service/remove.yaml b/test/integration/roles/netscaler_service/tests/nitro/http_service/remove.yaml new file mode 100644 index 0000000000..0be82a9041 --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/http_service/remove.yaml @@ -0,0 +1,16 @@ +--- + +- name: Remove htttp service + netscaler_service: + + nitro_user: "{{nitro_user}}" + nitro_pass: "{{nitro_pass}}" + nsip: "{{nsip}}" + + state: absent + + name: service-http + + delegate_to: localhost + register: result + check_mode: "{{ check_mode }}" diff --git a/test/integration/roles/netscaler_service/tests/nitro/http_service/setup.yaml b/test/integration/roles/netscaler_service/tests/nitro/http_service/setup.yaml new file mode 100644 index 0000000000..c55aaac95b --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/http_service/setup.yaml @@ -0,0 +1,53 @@ +--- + +- name: Setup http service + netscaler_service: + + nitro_user: "{{nitro_user}}" + nitro_pass: "{{nitro_pass}}" + nsip: "{{nsip}}" + + state: present + + name: service-http + ip: 192.168.1.1 + ipaddress: 192.168.1.1 + port: 80 + servicetype: HTTP + cachetype: TRANSPARENT + maxclient: 100 + healthmonitor: no + maxreq: 200 + cacheable: no + cip: ENABLED + cipheader: client-ip + usip: yes + useproxyport: yes + sc: off + sp: off + rtspsessionidremap: off + clttimeout: 100 + svrtimeout: 100 + customserverid: 476 + cka: yes + tcpb: yes + cmp: no + maxbandwidth: 10000 + accessdown: "NO" + monthreshold: 100 + downstateflush: ENABLED + hashid: 10 + comment: some comment + appflowlog: ENABLED + processlocal: ENABLED + graceful: no + + monitor_bindings: + - monitorname: ping + weight: 50 + - monitorname: http + weight: 50 + + delegate_to: localhost + register: result + check_mode: "{{ check_mode }}" diff --git a/test/integration/roles/netscaler_service/tests/nitro/http_service/update.yaml b/test/integration/roles/netscaler_service/tests/nitro/http_service/update.yaml new file mode 100644 index 0000000000..21640617a7 --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/http_service/update.yaml @@ -0,0 +1,51 @@ +--- + +- name: Update http service + netscaler_service: + + nitro_user: "{{nitro_user}}" + nitro_pass: "{{nitro_pass}}" + nsip: "{{nsip}}" + + state: present + + name: service-http + ip: 192.168.1.1 + ipaddress: 192.168.1.1 + port: 80 + servicetype: HTTP + cachetype: TRANSPARENT + maxclient: 100 + healthmonitor: no + maxreq: 200 + cacheable: no + cip: ENABLED + cipheader: client-ip + usip: yes + useproxyport: yes + sc: off + sp: off + rtspsessionidremap: off + clttimeout: 100 + svrtimeout: 100 + customserverid: 476 + cka: yes + tcpb: yes + cmp: no + maxbandwidth: 20000 + accessdown: "NO" + monthreshold: 100 + downstateflush: ENABLED + hashid: 10 + comment: some comment + appflowlog: ENABLED + processlocal: ENABLED + netprofile: net-profile-1 + + monitor_bindings: + - monitorname: http + weight: 100 + + delegate_to: localhost + register: result + check_mode: "{{ check_mode }}" diff --git a/test/integration/roles/netscaler_service/tests/nitro/ssl_service.yaml b/test/integration/roles/netscaler_service/tests/nitro/ssl_service.yaml new file mode 100644 index 0000000000..535918a544 --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/ssl_service.yaml @@ -0,0 +1,57 @@ +--- + +- include: "{{ role_path }}/tests/nitro/ssl_service/setup.yaml" + vars: + check_mode: yes + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/ssl_service/setup.yaml" + vars: + check_mode: no + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/ssl_service/setup.yaml" + vars: + check_mode: yes + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/ssl_service/setup.yaml" + vars: + check_mode: no + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/ssl_service/remove.yaml" + vars: + check_mode: yes + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/ssl_service/remove.yaml" + vars: + check_mode: no + +- assert: + that: result|changed + +- include: "{{ role_path }}/tests/nitro/ssl_service/remove.yaml" + vars: + check_mode: yes + +- assert: + that: not result|changed + +- include: "{{ role_path }}/tests/nitro/ssl_service/remove.yaml" + vars: + check_mode: no + +- assert: + that: not result|changed diff --git a/test/integration/roles/netscaler_service/tests/nitro/ssl_service/remove.yaml b/test/integration/roles/netscaler_service/tests/nitro/ssl_service/remove.yaml new file mode 100644 index 0000000000..3f787d2224 --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/ssl_service/remove.yaml @@ -0,0 +1,14 @@ +--- + +- name: Remove ssl service + delegate_to: localhost + register: result + check_mode: "{{ check_mode }}" + netscaler_service: + + nitro_user: "{{nitro_user}}" + nitro_pass: "{{nitro_pass}}" + nsip: "{{nsip}}" + + state: absent + name: service-ssl diff --git a/test/integration/roles/netscaler_service/tests/nitro/ssl_service/setup.yaml b/test/integration/roles/netscaler_service/tests/nitro/ssl_service/setup.yaml new file mode 100644 index 0000000000..565816b5c6 --- /dev/null +++ b/test/integration/roles/netscaler_service/tests/nitro/ssl_service/setup.yaml @@ -0,0 +1,18 @@ +--- + +- name: Setup ssl service + delegate_to: localhost + register: result + check_mode: "{{ check_mode }}" + netscaler_service: + + nitro_user: "{{nitro_user}}" + nitro_pass: "{{nitro_pass}}" + nsip: "{{nsip}}" + + state: present + name: service-ssl + ipaddress: 192.168.1.2 + port: 80 + servicetype: SSL + cleartextport: 88 diff --git a/test/units/modules/network/netscaler/__init__.py b/test/units/modules/network/netscaler/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/units/modules/network/netscaler/netscaler_module.py b/test/units/modules/network/netscaler/netscaler_module.py new file mode 100644 index 0000000000..9b2b2e83e1 --- /dev/null +++ b/test/units/modules/network/netscaler/netscaler_module.py @@ -0,0 +1,69 @@ +import sys + +from ansible.compat.tests.mock import patch, Mock +from ansible.compat.tests import unittest +from ansible.module_utils import basic +import json +from ansible.module_utils._text import to_bytes + +base_modules_mock = Mock() +nitro_service_mock = Mock() +nitro_exception_mock = Mock() + + +base_modules_to_mock = { + 'nssrc': base_modules_mock, + 'nssrc.com': base_modules_mock, + 'nssrc.com.citrix': base_modules_mock, + 'nssrc.com.citrix.netscaler': base_modules_mock, + 'nssrc.com.citrix.netscaler.nitro': base_modules_mock, + 'nssrc.com.citrix.netscaler.nitro.resource': base_modules_mock, + 'nssrc.com.citrix.netscaler.nitro.resource.config': base_modules_mock, + 'nssrc.com.citrix.netscaler.nitro.exception': base_modules_mock, + 'nssrc.com.citrix.netscaler.nitro.exception.nitro_exception': base_modules_mock, + 'nssrc.com.citrix.netscaler.nitro.exception.nitro_exception.nitro_exception': nitro_exception_mock, + 'nssrc.com.citrix.netscaler.nitro.service': base_modules_mock, + 'nssrc.com.citrix.netscaler.nitro.service.nitro_service': base_modules_mock, + 'nssrc.com.citrix.netscaler.nitro.service.nitro_service.nitro_service': nitro_service_mock, +} + +nitro_base_patcher = patch.dict(sys.modules, base_modules_to_mock) + + +def set_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class AnsibleExitJson(Exception): + pass + + +class AnsibleFailJson(Exception): + pass + + +class TestModule(unittest.TestCase): + def failed(self): + def fail_json(*args, **kwargs): + kwargs['failed'] = True + raise AnsibleFailJson(kwargs) + + with patch.object(basic.AnsibleModule, 'fail_json', fail_json): + with self.assertRaises(AnsibleFailJson) as exc: + self.module.main() + + result = exc.exception.args[0] + self.assertTrue(result['failed'], result) + return result + + def exited(self, changed=False): + def exit_json(*args, **kwargs): + raise AnsibleExitJson(kwargs) + + with patch.object(basic.AnsibleModule, 'exit_json', exit_json): + with self.assertRaises(AnsibleExitJson) as exc: + self.module.main() + + result = exc.exception.args[0] + return result diff --git a/test/units/modules/network/netscaler/test_netscaler_module_utils.py b/test/units/modules/network/netscaler/test_netscaler_module_utils.py new file mode 100644 index 0000000000..2a2d62113b --- /dev/null +++ b/test/units/modules/network/netscaler/test_netscaler_module_utils.py @@ -0,0 +1,175 @@ + +# Copyright (c) 2017 Citrix Systems +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import Mock + + +from ansible.module_utils.netscaler import ConfigProxy, get_immutables_intersection, ensure_feature_is_enabled, log, loglines + + +class TestNetscalerConfigProxy(unittest.TestCase): + + def test_values_copied_to_actual(self): + actual = Mock() + client = Mock() + values = { + 'some_key': 'some_value', + } + ConfigProxy( + actual=actual, + client=client, + attribute_values_dict=values, + readwrite_attrs=['some_key'] + ) + self.assertEqual(actual.some_key, values['some_key'], msg='Failed to pass correct value from values dict') + + def test_none_values_not_copied_to_actual(self): + actual = Mock() + client = Mock() + actual.key_for_none = 'initial' + print('actual %s' % actual.key_for_none) + values = { + 'key_for_none': None, + } + print('value %s' % actual.key_for_none) + ConfigProxy( + actual=actual, + client=client, + attribute_values_dict=values, + readwrite_attrs=['key_for_none'] + ) + self.assertEqual(actual.key_for_none, 'initial') + + def test_missing_from_values_dict_not_copied_to_actual(self): + actual = Mock() + client = Mock() + values = { + 'irrelevant_key': 'irrelevant_value', + } + print('value %s' % actual.key_for_none) + ConfigProxy( + actual=actual, + client=client, + attribute_values_dict=values, + readwrite_attrs=['key_for_none'] + ) + print('none %s' % getattr(actual, 'key_for_none')) + self.assertIsInstance(actual.key_for_none, Mock) + + def test_bool_yes_no_transform(self): + actual = Mock() + client = Mock() + values = { + 'yes_key': True, + 'no_key': False, + } + transforms = { + 'yes_key': ['bool_yes_no'], + 'no_key': ['bool_yes_no'] + } + ConfigProxy( + actual=actual, + client=client, + attribute_values_dict=values, + readwrite_attrs=['yes_key', 'no_key'], + transforms=transforms, + ) + actual_values = [actual.yes_key, actual.no_key] + self.assertListEqual(actual_values, ['YES', 'NO']) + + def test_bool_on_off_transform(self): + actual = Mock() + client = Mock() + values = { + 'on_key': True, + 'off_key': False, + } + transforms = { + 'on_key': ['bool_on_off'], + 'off_key': ['bool_on_off'] + } + ConfigProxy( + actual=actual, + client=client, + attribute_values_dict=values, + readwrite_attrs=['on_key', 'off_key'], + transforms=transforms, + ) + actual_values = [actual.on_key, actual.off_key] + self.assertListEqual(actual_values, ['ON', 'OFF']) + + def test_callable_transform(self): + actual = Mock() + client = Mock() + values = { + 'transform_key': 'hello', + 'transform_chain': 'hello', + } + transforms = { + 'transform_key': [lambda v: v.upper()], + 'transform_chain': [lambda v: v.upper(), lambda v: v[:4]] + } + ConfigProxy( + actual=actual, + client=client, + attribute_values_dict=values, + readwrite_attrs=['transform_key', 'transform_chain'], + transforms=transforms, + ) + actual_values = [actual.transform_key, actual.transform_chain] + self.assertListEqual(actual_values, ['HELLO', 'HELL']) + + +class TestNetscalerModuleUtils(unittest.TestCase): + + def test_immutables_intersection(self): + actual = Mock() + client = Mock() + values = { + 'mutable_key': 'some value', + 'immutable_key': 'some other value', + } + proxy = ConfigProxy( + actual=actual, + client=client, + attribute_values_dict=values, + readwrite_attrs=['mutable_key', 'immutable_key'], + immutable_attrs=['immutable_key'], + ) + keys_to_check = ['mutable_key', 'immutable_key', 'non_existant_key'] + result = get_immutables_intersection(proxy, keys_to_check) + self.assertListEqual(result, ['immutable_key']) + + def test_ensure_feature_is_enabled(self): + client = Mock() + attrs = {'get_enabled_features.return_value': ['GSLB']} + client.configure_mock(**attrs) + ensure_feature_is_enabled(client, 'GSLB') + ensure_feature_is_enabled(client, 'LB') + client.enable_features.assert_called_once_with('LB') + + def test_log_function(self): + messages = [ + 'First message', + 'Second message', + ] + log(messages[0]) + log(messages[1]) + self.assertListEqual(messages, loglines, msg='Log messages not recorded correctly') diff --git a/test/units/modules/network/netscaler/test_netscaler_service.py b/test/units/modules/network/netscaler/test_netscaler_service.py new file mode 100644 index 0000000000..9b9fb98e05 --- /dev/null +++ b/test/units/modules/network/netscaler/test_netscaler_service.py @@ -0,0 +1,343 @@ + +# Copyright (c) 2017 Citrix Systems +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# + +from ansible.compat.tests.mock import patch, Mock, MagicMock, call + +import sys + +if sys.version_info[:2] != (2, 6): + import requests + + +from .netscaler_module import TestModule, nitro_base_patcher, set_module_args + + +class TestNetscalerServiceModule(TestModule): + + @classmethod + def setUpClass(cls): + m = MagicMock() + cls.service_mock = MagicMock() + cls.service_mock.__class__ = MagicMock() + cls.service_lbmonitor_binding_mock = MagicMock() + cls.lbmonitor_service_binding_mock = MagicMock() + nssrc_modules_mock = { + 'nssrc.com.citrix.netscaler.nitro.resource.config.basic': m, + 'nssrc.com.citrix.netscaler.nitro.resource.config.basic.service': m, + 'nssrc.com.citrix.netscaler.nitro.resource.config.basic.service.service': cls.service_mock, + 'nssrc.com.citrix.netscaler.nitro.resource.config.basic.service_lbmonitor_binding': cls.service_lbmonitor_binding_mock, + 'nssrc.com.citrix.netscaler.nitro.resource.config.basic.service_lbmonitor_binding.service_lbmonitor_binding': m, + 'nssrc.com.citrix.netscaler.nitro.resource.config.lb': m, + 'nssrc.com.citrix.netscaler.nitro.resource.config.lb.lbmonitor_service_binding': m, + 'nssrc.com.citrix.netscaler.nitro.resource.config.lb.lbmonitor_service_binding.lbmonitor_service_binding': cls.lbmonitor_service_binding_mock, + } + + cls.nitro_specific_patcher = patch.dict(sys.modules, nssrc_modules_mock) + cls.nitro_base_patcher = nitro_base_patcher + + @classmethod + def tearDownClass(cls): + cls.nitro_base_patcher.stop() + cls.nitro_specific_patcher.stop() + + def set_module_state(self, state): + set_module_args(dict( + nitro_user='user', + nitro_pass='pass', + nsip='1.1.1.1', + state=state, + )) + + def setUp(self): + self.nitro_base_patcher.start() + self.nitro_specific_patcher.start() + + # Setup minimal required arguments to pass AnsibleModule argument parsing + + def tearDown(self): + self.nitro_base_patcher.stop() + self.nitro_specific_patcher.stop() + + def test_graceful_nitro_api_import_error(self): + # Stop nitro api patching to cause ImportError + self.set_module_state('present') + self.nitro_base_patcher.stop() + self.nitro_specific_patcher.stop() + from ansible.modules.network.netscaler import netscaler_service + self.module = netscaler_service + result = self.failed() + self.assertEqual(result['msg'], 'Could not load nitro python sdk') + + def test_graceful_nitro_error_on_login(self): + self.set_module_state('present') + from ansible.modules.network.netscaler import netscaler_service + + class MockException(Exception): + def __init__(self, *args, **kwargs): + self.errorcode = 0 + self.message = '' + + client_mock = Mock() + client_mock.login = Mock(side_effect=MockException) + m = Mock(return_value=client_mock) + with patch('ansible.modules.network.netscaler.netscaler_service.get_nitro_client', m): + with patch('ansible.modules.network.netscaler.netscaler_service.nitro_exception', MockException): + self.module = netscaler_service + result = self.failed() + self.assertTrue(result['msg'].startswith('nitro exception'), msg='nitro exception during login not handled properly') + + def test_graceful_no_connection_error(self): + + if sys.version_info[:2] == (2, 6): + self.skipTest('requests library not available under python2.6') + self.set_module_state('present') + from ansible.modules.network.netscaler import netscaler_service + + class MockException(Exception): + pass + client_mock = Mock() + attrs = {'login.side_effect': requests.exceptions.ConnectionError} + client_mock.configure_mock(**attrs) + m = Mock(return_value=client_mock) + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + get_nitro_client=m, + nitro_exception=MockException, + ): + self.module = netscaler_service + result = self.failed() + self.assertTrue(result['msg'].startswith('Connection error'), msg='Connection error was not handled gracefully') + + def test_graceful_login_error(self): + self.set_module_state('present') + from ansible.modules.network.netscaler import netscaler_service + + if sys.version_info[:2] == (2, 6): + self.skipTest('requests library not available under python2.6') + + class MockException(Exception): + pass + client_mock = Mock() + attrs = {'login.side_effect': requests.exceptions.SSLError} + client_mock.configure_mock(**attrs) + m = Mock(return_value=client_mock) + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + get_nitro_client=m, + nitro_exception=MockException, + ): + self.module = netscaler_service + result = self.failed() + self.assertTrue(result['msg'].startswith('SSL Error'), msg='SSL Error was not handled gracefully') + + def test_create_non_existing_service(self): + self.set_module_state('present') + from ansible.modules.network.netscaler import netscaler_service + service_proxy_mock = MagicMock() + attrs = { + 'diff_object.return_value': {}, + } + service_proxy_mock.configure_mock(**attrs) + + m = MagicMock(return_value=service_proxy_mock) + service_exists_mock = Mock(side_effect=[False, True]) + + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + ConfigProxy=m, + service_exists=service_exists_mock, + ): + self.module = netscaler_service + result = self.exited() + service_proxy_mock.assert_has_calls([call.add()]) + self.assertTrue(result['changed'], msg='Change not recorded') + + def test_update_service_when_service_differs(self): + self.set_module_state('present') + from ansible.modules.network.netscaler import netscaler_service + service_proxy_mock = MagicMock() + attrs = { + 'diff_object.return_value': {}, + } + service_proxy_mock.configure_mock(**attrs) + + m = MagicMock(return_value=service_proxy_mock) + service_exists_mock = Mock(side_effect=[True, True]) + service_identical_mock = Mock(side_effect=[False, True]) + monitor_bindings_identical_mock = Mock(side_effect=[True, True]) + all_identical_mock = Mock(side_effect=[False]) + + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + ConfigProxy=m, + service_exists=service_exists_mock, + service_identical=service_identical_mock, + monitor_bindings_identical=monitor_bindings_identical_mock, + all_identical=all_identical_mock, + ): + self.module = netscaler_service + result = self.exited() + service_proxy_mock.assert_has_calls([call.update()]) + self.assertTrue(result['changed'], msg='Change not recorded') + + def test_update_service_when_monitor_bindings_differ(self): + self.set_module_state('present') + from ansible.modules.network.netscaler import netscaler_service + service_proxy_mock = MagicMock() + attrs = { + 'diff_object.return_value': {}, + } + service_proxy_mock.configure_mock(**attrs) + + m = MagicMock(return_value=service_proxy_mock) + service_exists_mock = Mock(side_effect=[True, True]) + service_identical_mock = Mock(side_effect=[True, True]) + monitor_bindings_identical_mock = Mock(side_effect=[False, True]) + all_identical_mock = Mock(side_effect=[False]) + sync_monitor_bindings_mock = Mock() + + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + ConfigProxy=m, + service_exists=service_exists_mock, + service_identical=service_identical_mock, + monitor_bindings_identical=monitor_bindings_identical_mock, + all_identical=all_identical_mock, + sync_monitor_bindings=sync_monitor_bindings_mock, + ): + self.module = netscaler_service + result = self.exited() + # poor man's assert_called_once since python3.5 does not implement that mock method + self.assertEqual(len(sync_monitor_bindings_mock.mock_calls), 1, msg='sync monitor bindings not called once') + self.assertTrue(result['changed'], msg='Change not recorded') + + def test_no_change_to_module_when_all_identical(self): + self.set_module_state('present') + from ansible.modules.network.netscaler import netscaler_service + service_proxy_mock = MagicMock() + attrs = { + 'diff_object.return_value': {}, + } + service_proxy_mock.configure_mock(**attrs) + + m = MagicMock(return_value=service_proxy_mock) + service_exists_mock = Mock(side_effect=[True, True]) + service_identical_mock = Mock(side_effect=[True, True]) + monitor_bindings_identical_mock = Mock(side_effect=[True, True]) + + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + ConfigProxy=m, + service_exists=service_exists_mock, + service_identical=service_identical_mock, + monitor_bindings_identical=monitor_bindings_identical_mock, + ): + self.module = netscaler_service + result = self.exited() + self.assertFalse(result['changed'], msg='Erroneous changed status update') + + def test_absent_operation(self): + self.set_module_state('absent') + from ansible.modules.network.netscaler import netscaler_service + service_proxy_mock = MagicMock() + attrs = { + 'diff_object.return_value': {}, + } + service_proxy_mock.configure_mock(**attrs) + + m = MagicMock(return_value=service_proxy_mock) + service_exists_mock = Mock(side_effect=[True, False]) + + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + ConfigProxy=m, + service_exists=service_exists_mock, + + ): + self.module = netscaler_service + result = self.exited() + service_proxy_mock.assert_has_calls([call.delete()]) + self.assertTrue(result['changed'], msg='Changed status not set correctly') + + def test_absent_operation_no_change(self): + self.set_module_state('absent') + from ansible.modules.network.netscaler import netscaler_service + service_proxy_mock = MagicMock() + attrs = { + 'diff_object.return_value': {}, + } + service_proxy_mock.configure_mock(**attrs) + + m = MagicMock(return_value=service_proxy_mock) + service_exists_mock = Mock(side_effect=[False, False]) + + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + ConfigProxy=m, + service_exists=service_exists_mock, + + ): + self.module = netscaler_service + result = self.exited() + service_proxy_mock.assert_not_called() + self.assertFalse(result['changed'], msg='Changed status not set correctly') + + def test_graceful_nitro_exception_operation_present(self): + self.set_module_state('present') + from ansible.modules.network.netscaler import netscaler_service + + class MockException(Exception): + def __init__(self, *args, **kwargs): + self.errorcode = 0 + self.message = '' + + m = Mock(side_effect=MockException) + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + service_exists=m, + nitro_exception=MockException + ): + self.module = netscaler_service + result = self.failed() + self.assertTrue( + result['msg'].startswith('nitro exception'), + msg='Nitro exception not caught on operation present' + ) + + def test_graceful_nitro_exception_operation_absent(self): + self.set_module_state('absent') + from ansible.modules.network.netscaler import netscaler_service + + class MockException(Exception): + def __init__(self, *args, **kwargs): + self.errorcode = 0 + self.message = '' + + m = Mock(side_effect=MockException) + with patch.multiple( + 'ansible.modules.network.netscaler.netscaler_service', + service_exists=m, + nitro_exception=MockException + ): + self.module = netscaler_service + result = self.failed() + self.assertTrue( + result['msg'].startswith('nitro exception'), + msg='Nitro exception not caught on operation absent' + )