From a00089c34107a2cbc2587f32a3619f4761c72143 Mon Sep 17 00:00:00 2001
From: George Nikolopoulos <giorgos.nikolopoulos@citrix.com>
Date: Thu, 8 Jun 2017 17:33:32 +0300
Subject: [PATCH] New module: manage Citrix Netscaler service configuration
 (network/netscaler/netscaler_service)  (#25129)

* netscaler_service initial implementation

* Changes as requested by reviewers

* Skip some tests if under python2.6 and importing requests library

* Change option "operation" to "state"

* Remove print statements from netscaler module utils

* Catch all exceptions during login

* Fix fail message

* Add common option save_config
---
 .../dev_guide/developing_module_utilities.rst |   1 +
 lib/ansible/module_utils/netscaler.py         | 324 ++++++
 .../modules/network/netscaler/__init__.py     |   0
 .../network/netscaler/netscaler_service.py    | 944 ++++++++++++++++++
 .../utils/module_docs_fragments/netscaler.py  |  52 +
 test/integration/netscaler.yaml               |  11 +
 .../netscaler_service/defaults/main.yaml      |   6 +
 .../roles/netscaler_service/sample_inventory  |   5 +
 .../roles/netscaler_service/tasks/main.yaml   |   2 +
 .../roles/netscaler_service/tasks/nitro.yaml  |  14 +
 .../tests/nitro/adns_service.yaml             |  57 ++
 .../tests/nitro/adns_service/remove.yaml      |  14 +
 .../tests/nitro/adns_service/setup.yaml       |  17 +
 .../tests/nitro/http_service.yaml             |  85 ++
 .../tests/nitro/http_service/remove.yaml      |  16 +
 .../tests/nitro/http_service/setup.yaml       |  53 +
 .../tests/nitro/http_service/update.yaml      |  51 +
 .../tests/nitro/ssl_service.yaml              |  57 ++
 .../tests/nitro/ssl_service/remove.yaml       |  14 +
 .../tests/nitro/ssl_service/setup.yaml        |  18 +
 .../modules/network/netscaler/__init__.py     |   0
 .../network/netscaler/netscaler_module.py     |  69 ++
 .../netscaler/test_netscaler_module_utils.py  | 175 ++++
 .../netscaler/test_netscaler_service.py       | 343 +++++++
 24 files changed, 2328 insertions(+)
 create mode 100644 lib/ansible/module_utils/netscaler.py
 create mode 100644 lib/ansible/modules/network/netscaler/__init__.py
 create mode 100644 lib/ansible/modules/network/netscaler/netscaler_service.py
 create mode 100644 lib/ansible/utils/module_docs_fragments/netscaler.py
 create mode 100644 test/integration/netscaler.yaml
 create mode 100644 test/integration/roles/netscaler_service/defaults/main.yaml
 create mode 100644 test/integration/roles/netscaler_service/sample_inventory
 create mode 100644 test/integration/roles/netscaler_service/tasks/main.yaml
 create mode 100644 test/integration/roles/netscaler_service/tasks/nitro.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/adns_service.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/adns_service/remove.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/adns_service/setup.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/http_service.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/http_service/remove.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/http_service/setup.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/http_service/update.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/ssl_service.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/ssl_service/remove.yaml
 create mode 100644 test/integration/roles/netscaler_service/tests/nitro/ssl_service/setup.yaml
 create mode 100644 test/units/modules/network/netscaler/__init__.py
 create mode 100644 test/units/modules/network/netscaler/netscaler_module.py
 create mode 100644 test/units/modules/network/netscaler/test_netscaler_module_utils.py
 create mode 100644 test/units/modules/network/netscaler/test_netscaler_service.py

diff --git a/docs/docsite/rst/dev_guide/developing_module_utilities.rst b/docs/docsite/rst/dev_guide/developing_module_utilities.rst
index 3ecaa56ddaf..15a0b26ca6d 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 00000000000..214a75f30c0
--- /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 00000000000..e69de29bb2d
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 00000000000..dbcb49dad4d
--- /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 <http://www.gnu.org/licenses/>.
+#
+
+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)) == "<class 'requests.exceptions.ConnectionError'>":
+            module.fail_json(msg='Connection error %s' % str(e))
+        elif str(type(e)) == "<class 'requests.exceptions.SSLError'>":
+            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 00000000000..025ee2328fd
--- /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 00000000000..262b0ad22a4
--- /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 00000000000..641801f6600
--- /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 00000000000..7da2dbbdb84
--- /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 00000000000..729619a17c8
--- /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 00000000000..00ab502dda9
--- /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 00000000000..97b9ccb0c99
--- /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 00000000000..3b3e0f9e231
--- /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 00000000000..c44e1a8dd50
--- /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 00000000000..4111bd4a2a3
--- /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 00000000000..0be82a9041b
--- /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 00000000000..c55aaac95b9
--- /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 00000000000..21640617a7f
--- /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 00000000000..535918a5448
--- /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 00000000000..3f787d2224e
--- /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 00000000000..565816b5c63
--- /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 00000000000..e69de29bb2d
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 00000000000..9b2b2e83e12
--- /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 00000000000..2a2d62113b2
--- /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 <http://www.gnu.org/licenses/>.
+#
+
+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 00000000000..9b9fb98e052
--- /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 <http://www.gnu.org/licenses/>.
+#
+
+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'
+            )