diff --git a/galaxy.yml b/galaxy.yml index 4ec17c75a..0ef4a09b0 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -25,6 +25,8 @@ tags: - collection - networking - sdn +dependencies: + "ansible.netcommon": "*" repository: https://github.com/CiscoDevNet/ansible-aci documentation: https://docs.ansible.com/ansible/latest/scenario_guides/guide_aci.html homepage: https://github.com/CiscoDevNet/ansible-aci diff --git a/plugins/doc_fragments/aci.py b/plugins/doc_fragments/aci.py index 2bed3dc59..e6b18a289 100644 --- a/plugins/doc_fragments/aci.py +++ b/plugins/doc_fragments/aci.py @@ -18,7 +18,6 @@ class ModuleDocFragment(object): - IP Address or hostname of APIC resolvable by Ansible control host. - If the value is not specified in the task, the value of environment variable C(ACI_HOST) will be used instead. type: str - required: true aliases: [ hostname ] port: description: @@ -30,8 +29,8 @@ class ModuleDocFragment(object): description: - The username to use for authentication. - If the value is not specified in the task, the value of environment variables C(ACI_USERNAME) or C(ANSIBLE_NET_USERNAME) will be used instead. + - The default value is admin. type: str - default: admin aliases: [ user ] password: description: @@ -69,27 +68,27 @@ class ModuleDocFragment(object): description: - The socket level timeout in seconds. - If the value is not specified in the task, the value of environment variable C(ACI_TIMEOUT) will be used instead. + - The default value is 30. type: int - default: 30 use_proxy: description: - If C(false), it will not use a proxy, even if one is defined in an environment variable on the target hosts. - If the value is not specified in the task, the value of environment variable C(ACI_USE_PROXY) will be used instead. + - The default value is true. type: bool - default: true use_ssl: description: - If C(false), an HTTP connection will be used instead of the default HTTPS connection. - If the value is not specified in the task, the value of environment variable C(ACI_USE_SSL) will be used instead. + - The default value is true when the connection is local. type: bool - default: true validate_certs: description: - If C(false), SSL certificates will not be validated. - This should only set to C(false) when used on personally controlled sites using self-signed certificates. - If the value is not specified in the task, the value of environment variable C(ACI_VALIDATE_CERTS) will be used instead. + - The default value is true. type: bool - default: true output_path: description: - Path to a file that will be used to dump the ACI JSON configuration objects generated by the module. diff --git a/plugins/httpapi/aci.py b/plugins/httpapi/aci.py new file mode 100644 index 000000000..5105223cf --- /dev/null +++ b/plugins/httpapi/aci.py @@ -0,0 +1,339 @@ +# Copyright (c) 2020 Cisco and/or its affiliates. +# Copyright: (c) 2020, Shreyas Srish (@shrsr) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +name: aci +author: +- Shreyas Srish (@shrsr) +short_description: Ansible ACI HTTPAPI Plugin. +description: + - This ACI plugin provides the HTTPAPI methods needed to initiate + a connection to the APIC, send API requests and process the + response from the controller. +""" + +import ast +import base64 +import json +import os +import re + +from ansible.module_utils._text import to_text, to_native +from ansible.module_utils.connection import ConnectionError +from ansible.plugins.httpapi import HttpApiBase +from copy import copy, deepcopy + +# Optional, only used for APIC signature-based authentication +try: + from OpenSSL.crypto import FILETYPE_PEM, load_privatekey, sign + + HAS_OPENSSL = True +except ImportError: + HAS_OPENSSL = False + +# Signature-based authentication using cryptography +try: + from cryptography.hazmat.primitives import serialization, hashes + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.backends import default_backend + + HAS_CRYPTOGRAPHY = True +except ImportError: + HAS_CRYPTOGRAPHY = False + +CONNECTION_MAP = {"username": "remote_user", "timeout": "persistent_command_timeout"} +RESET_KEYS = ["username", "password", "port"] +CONNECTION_KEYS = RESET_KEYS + ["timeout", "use_proxy", "use_ssl", "validate_certs"] + + +class HttpApi(HttpApiBase): + def __init__(self, *args, **kwargs): + super(HttpApi, self).__init__(*args, **kwargs) + self.params = None + self.result = {} + self.backup_hosts = None + self.connection_error_check = False + self.connection_parameters = {} + self.current_host = None + self.provided_hosts = None + self.inventory_hosts = None + + def set_params(self, params): + self.params = params + + # Login function is executed until connection to a host is established or until all the hosts in the list are exhausted + def login(self, username, password): + """Log in to APIC""" + # Perform login request + self.connection.queue_message("debug", "Establishing login for {0} to {1}".format(username, self.connection.get_option("host"))) + method = "POST" + path = "/api/aaaLogin.json" + payload = {"aaaUser": {"attributes": {"name": username, "pwd": password}}} + data = json.dumps(payload) + self.connection._connected = True + try: + response, response_data = self.connection.send(path, data, method=method) + response_value = self._get_response_value(response_data) + self.connection._auth = { + "Cookie": "APIC-Cookie={0}".format(self._response_to_json(response_value).get("imdata")[0]["aaaLogin"]["attributes"]["token"]) + } + self.connection.queue_message("debug", "Connection to {0} was successful".format(self.connection.get_option("host"))) + except Exception as exc_login: + self.connection._connected = False + exc_login.path = path + raise + + def logout(self): + method = "POST" + path = "/api/aaaLogout.json" + payload = {"aaaUser": {"attributes": {"name": self.connection.get_option("remote_user")}}} + data = json.dumps(payload) + try: + response, response_data = self.connection.send(path, data, method=method) + except Exception as exc_logout: + msg = "Error on attempt to logout from APIC. {0}".format(exc_logout) + raise ConnectionError(self._return_info("", method, path, msg)) + self.connection._auth = None + self._verify_response(response, method, response_data) + + def set_parameters(self): + connection_parameters = {} + for key in CONNECTION_KEYS: + value = self.params.get(key) if self.params.get(key) is not None else self.connection.get_option(CONNECTION_MAP.get(key, key)) + if key == "username" and value is None: + value = "admin" + self.connection.set_option(CONNECTION_MAP.get(key, key), value) + if key == "timeout" and self.connection.get_option("persistent_connect_timeout") <= value: + self.connection.set_option("persistent_connect_timeout", value + 30) + + connection_parameters[key] = value + if self.connection_parameters and value != self.connection_parameters.get(key) and key in RESET_KEYS: + self.connection._connected = False + self.connection.queue_message("debug", "Re-setting connection due to change in the {0}".format(key)) + + if self.params.get("private_key") is not None: + self.connection.set_option("session_key", None) + connection_parameters["certificate_name"] = self.params.get("certificate_name") + connection_parameters["private_key"] = self.params.get("private_key") + elif self.connection.get_option("session_key") is not None and self.params.get("password") is None: + connection_parameters["certificate_name"] = list(self.connection.get_option("session_key").keys())[0] + connection_parameters["private_key"] = list(self.connection.get_option("session_key").values())[0] + else: + if self.connection_parameters.get("private_key") is not None: + self.connection._connected = False + self.connection.queue_message( + "debug", "Re-setting connection due to change from private/session key authentication to password authentication" + ) + self.connection.set_option("session_key", None) + connection_parameters["private_key"] = None + connection_parameters["certificate_name"] = None + + if self.connection_parameters != connection_parameters: + self.connection_parameters = copy(connection_parameters) + + self.set_hosts() + + def set_hosts(self): + if self.params.get("host") is not None: + hosts = ast.literal_eval(self.params.get("host")) if "[" in self.params.get("host") else self.params.get("host").split(",") + else: + if self.inventory_hosts is None: + self.inventory_hosts = re.sub(r"[[\]]", "", self.connection.get_option("host")).split(",") + hosts = self.inventory_hosts + + if self.provided_hosts is None: + self.provided_hosts = deepcopy(hosts) + self.connection.queue_message("debug", "Provided Hosts: {0}".format(self.provided_hosts)) + self.backup_hosts = deepcopy(hosts) + self.current_host = self.backup_hosts.pop(0) + self.connection.queue_message("debug", "Initializing operation on {0}".format(self.current_host)) + elif self.provided_hosts != hosts: + self.provided_hosts = deepcopy(hosts) + self.connection.queue_message("debug", "Provided Hosts have changed: {0}".format(self.provided_hosts)) + self.backup_hosts = deepcopy(hosts) + try: + self.backup_hosts.pop(self.backup_hosts.index(self.current_host)) + self.connection.queue_message("debug", "Connected host {0} found in the provided hosts. Continuing with it.".format(self.current_host)) + except Exception: + self.current_host = self.backup_hosts.pop(0) + self.connection._connected = False + self.connection.queue_message("debug", "Initializing operation on {0}".format(self.current_host)) + self.connection.set_option("host", self.current_host) + + # One API call is made via each call to send_request from aci.py in module_utils + # As long as a host is active in the list the API call will go through + def send_request(self, method, path, data): + """This method handles all APIC REST API requests other than login""" + + self.set_parameters() + + if self.connection_parameters.get("private_key") is not None: + try: + self.connection._auth = {"Cookie": "{0}".format(self.cert_auth(method, path, data).get("Cookie"))} + self.connection._connected = True + except Exception as exc_response: + self.connection._connected = False + return self._return_info("", method, self.validate_url(self.connection._url + path), str(exc_response)) + + try: + if self.connection._connected is False: + self.login(self.connection.get_option("remote_user"), self.connection.get_option("password")) + self.connection.queue_message("debug", "Sending {0} request to {1}".format(method, self.connection._url + path)) + response, response_data = self.connection.send(path, data, method=method) + self.connection.queue_message( + "debug", "Received response from {0} for {1} operation with HTTP: {2}".format(self.connection.get_option("host"), method, response.getcode()) + ) + except Exception as exc_response: + self.connection.queue_message("debug", "Connection to {0} has failed: {1}".format(self.connection.get_option("host"), exc_response)) + if len(self.backup_hosts) == 0: + self.provided_hosts = None + self.connection._connected = False + error = dict( + code=-1, text="No hosts left in the cluster to continue operation! Error on final host {0}".format(self.connection.get_option("host")) + ) + if "path" in dir(exc_response): + path = exc_response.path + return self._return_info("", method, self.validate_url(self.connection._url + path), str(exc_response), error=error) + else: + self.current_host = self.backup_hosts.pop(0) + self.connection.queue_message("debug", "Switching host from {0} to {1}".format(self.connection.get_option("host"), self.current_host)) + self.connection.set_option("host", self.current_host) + # recurse through function for retrying the request + return self.send_request(method, path, data) + # return statement executed upon each successful response from the request function + return self._verify_response(response, method, response_data) + + # Built-in-function + def handle_httperror(self, exc): + self.connection.queue_message("debug", "Failed to receive response from {0} with {1}".format(self.connection.get_option("host"), exc)) + if exc.code == 401: + raise ConnectionError(exc) + elif exc.code == 403 and self.connection_parameters.get("private_key") is None: + self.connection._auth = None + self.login(self.connection.get_option("remote_user"), self.connection.get_option("password")) + return True + return exc + + def validate_url(self, url): + validated_url = re.match(r"^.*?\.json|^.*?\.xml", url).group(0) + if self.connection_parameters.get("port") is None: + return validated_url.replace(re.match(r"(https?:\/\/.*)(:\d*)\/?(.*)", url).group(2), "") + else: + return validated_url + + def _verify_response(self, response, method, response_data): + """Process the return code and response object from APIC""" + response_value = self._get_response_value(response_data) + response_code = response.getcode() + # Response check to remain consistent with fetch_url's response + if str(response) == "HTTP Error 400: Bad Request": + msg = "{0}".format(response) + else: + msg = "{0} ({1} bytes)".format(response.msg, len(response_value)) + return self._return_info(response_code, method, "", msg, respond_data=response_value) + + def _get_response_value(self, response_data): + """Extract string data from response_data returned from APIC""" + return to_text(response_data.getvalue()) + + def _response_to_json(self, response_text): + """Convert response_text to json format""" + try: + return json.loads(response_text) if response_text else {} + # JSONDecodeError only available on Python 3.5+ + except Exception: + return "Invalid JSON response: {0}".format(response_text) + + def _return_info(self, response_code, method, path, msg, respond_data=None, error=None): + """Format success/error data and return with consistent format""" + info = {} + info["status"] = response_code + info["method"] = method + info["url"] = path + info["msg"] = msg + if error is not None: + info["error"] = error + else: + info["error"] = {} + # Response check to trigger key error if response_data is invalid + if respond_data is not None: + info["body"] = respond_data + return info + + def cert_auth(self, method, path, payload=""): + """Perform APIC signature-based authentication, not the expected SSL client certificate authentication.""" + + if payload is None: + payload = "" + + headers = dict() + + try: + if HAS_CRYPTOGRAPHY: + key = self.connection_parameters.get("private_key").encode() + sig_key = serialization.load_pem_private_key( + key, + password=None, + backend=default_backend(), + ) + else: + sig_key = load_privatekey(FILETYPE_PEM, self.connection_parameters.get("private_key")) + except Exception: + if os.path.exists(self.connection_parameters.get("private_key")): + try: + permission = "r" + if HAS_CRYPTOGRAPHY: + permission = "rb" + with open(self.connection_parameters.get("private_key"), permission) as fh: + private_key_content = fh.read() + except Exception: + raise ConnectionError("Cannot open private key file {0}".format(self.connection_parameters.get("private_key"))) + try: + if HAS_CRYPTOGRAPHY: + sig_key = serialization.load_pem_private_key(private_key_content, password=None, backend=default_backend()) + else: + sig_key = load_privatekey(FILETYPE_PEM, private_key_content) + except Exception: + raise ConnectionError("Cannot load private key file {0}".format(self.connection_parameters.get("private_key"))) + if self.connection_parameters.get("certificate_name") is None: + self.connection_parameters["certificate_name"] = os.path.basename(os.path.splitext(self.connection_parameters.get("private_key"))[0]) + else: + raise ConnectionError( + "Provided private key {0} does not appear to be a private key or provided file does not exist.".format( + self.connection_parameters.get("private_key") + ) + ) + if self.connection_parameters.get("certificate_name") is None: + self.connection_parameters["certificate_name"] = self.connection.get_option("remote_user") + sig_request = method + path + payload + if HAS_CRYPTOGRAPHY: + sig_signature = sig_key.sign(sig_request.encode(), padding.PKCS1v15(), hashes.SHA256()) + else: + sig_signature = sign(sig_key, sig_request, "sha256") + sig_dn = "uni/userext/user-{0}/usercert-{1}".format(self.connection.get_option("remote_user"), self.connection_parameters.get("certificate_name")) + headers["Cookie"] = ( + "APIC-Certificate-Algorithm=v1.0; " + + "APIC-Certificate-DN={0}; ".format(sig_dn) + + "APIC-Certificate-Fingerprint=fingerprint; " + + "APIC-Request-Signature={0}".format(to_native(base64.b64encode(sig_signature))) + ) + return headers diff --git a/plugins/module_utils/aci.py b/plugins/module_utils/aci.py index aa96eedde..7a274d498 100644 --- a/plugins/module_utils/aci.py +++ b/plugins/module_utils/aci.py @@ -14,6 +14,7 @@ # Copyright: (c) 2020, Lionel Hercot (@lhercot) # Copyright: (c) 2020, Anvitha Jain (@anvitha-jain) # Copyright: (c) 2023, Gaspard Micol (@gmicol) +# Copyright: (c) 2023, Shreyas Srish (@shrsr) # All rights reserved. # Redistribution and use in source and binary forms, with or without modification, @@ -47,6 +48,7 @@ from ansible.module_utils.urls import fetch_url from ansible.module_utils._text import to_bytes, to_native from ansible.module_utils.basic import env_fallback +from ansible.module_utils.connection import Connection # Optional, only used for APIC signature-based authentication try: @@ -82,19 +84,24 @@ except ImportError: HAS_XMLJSON_COBRA = False +try: + from ansible.module_utils.six.moves.urllib.parse import urlparse + + HAS_URLPARSE = True +except Exception: + HAS_URLPARSE = False + def aci_argument_spec(): return dict( host=dict( type="str", - required=True, aliases=["hostname"], fallback=(env_fallback, ["ACI_HOST"]), ), port=dict(type="int", required=False, fallback=(env_fallback, ["ACI_PORT"])), username=dict( type="str", - default="admin", aliases=["user"], fallback=(env_fallback, ["ACI_USERNAME", "ANSIBLE_NET_USERNAME"]), ), @@ -122,10 +129,10 @@ def aci_argument_spec(): choices=["debug", "info", "normal"], fallback=(env_fallback, ["ACI_OUTPUT_LEVEL"]), ), - timeout=dict(type="int", default=30, fallback=(env_fallback, ["ACI_TIMEOUT"])), - use_proxy=dict(type="bool", default=True, fallback=(env_fallback, ["ACI_USE_PROXY"])), - use_ssl=dict(type="bool", default=True, fallback=(env_fallback, ["ACI_USE_SSL"])), - validate_certs=dict(type="bool", default=True, fallback=(env_fallback, ["ACI_VALIDATE_CERTS"])), + timeout=dict(type="int", fallback=(env_fallback, ["ACI_TIMEOUT"])), + use_proxy=dict(type="bool", fallback=(env_fallback, ["ACI_USE_PROXY"])), + use_ssl=dict(type="bool", fallback=(env_fallback, ["ACI_USE_SSL"])), + validate_certs=dict(type="bool", fallback=(env_fallback, ["ACI_VALIDATE_CERTS"])), output_path=dict(type="str", fallback=(env_fallback, ["ACI_OUTPUT_PATH"])), ) @@ -296,6 +303,18 @@ def destination_epg_spec(): ) +def ospf_spec(): + return dict( + area_cost=dict(type="int"), + area_ctrl=dict(type="list", elements="str", choices=["redistribute", "summary", "suppress-fa", "unspecified"]), + area_id=dict(type="str"), + area_type=dict(type="str", choices=["nssa", "regular", "stub"]), + description=dict(type="str", aliases=["descr"]), + multipod_internal=dict(type="str", choices=["no", "yes"]), + name_alias=dict(type="str"), + ) + + class ACIModule(object): def __init__(self, module): self.module = module @@ -303,6 +322,7 @@ def __init__(self, module): self.result = dict(changed=False) self.headers = dict() self.child_classes = set() + self.connection = None # error output self.error = dict(code=None, text=None) @@ -325,6 +345,7 @@ def __init__(self, module): self.response = None self.status = None self.url = None + self.httpapi_logs = list() # aci_rest output self.imdata = None @@ -333,6 +354,9 @@ def __init__(self, module): # Ensure protocol is set self.define_protocol() + # Set Connection plugin + self.set_connection() + if self.module._debug: self.module.warn("Enable debug output because ANSIBLE_DEBUG was set.") self.params["output_level"] = "debug" @@ -343,11 +367,12 @@ def __init__(self, module): self.module.fail_json(msg="Cannot use signature-based authentication because cryptography (preferred) or pyopenssl are not available") elif self.params.get("password") is not None: self.module.warn("When doing ACI signatured-based authentication, providing parameter 'password' is not required") - elif self.params.get("password"): - # Perform password-based authentication, log on using password - self.login() - else: - self.module.fail_json(msg="Either parameter 'password' or 'private_key' is required for authentication") + elif self.connection is None: + if self.params.get("password"): + # Perform password-based authentication, log on using password + self.login() + else: + self.module.fail_json(msg="Either parameter 'password' or 'private_key' is required for authentication") def boolean(self, value, true="yes", false="no"): """Return an acceptable value back""" @@ -382,37 +407,27 @@ def define_protocol(self): # Set protocol for further use self.params["protocol"] = "https" if self.params.get("use_ssl", True) else "http" - def define_method(self): - """Set method based on state parameter""" - - # Set method for further use - state_map = dict(absent="delete", present="post", query="get") - self.params["method"] = state_map.get(self.params.get("state")) + def set_connection(self): + if self.connection is None and self.module._socket_path: + self.connection = Connection(self.module._socket_path) def login(self): """Log in to APIC""" # Perform login request if self.params.get("port") is not None: - url = "%(protocol)s://%(host)s:%(port)s/api/aaaLogin.json" % self.params + url = "{protocol}://{host}:{port}/api/aaaLogin.json".format_map(self.params) else: - url = "%(protocol)s://%(host)s/api/aaaLogin.json" % self.params + url = "{protocol}://{host}/api/aaaLogin.json".format_map(self.params) payload = { "aaaUser": { "attributes": { - "name": self.params.get("username"), + "name": self.params.get("username", "admin"), "pwd": self.params.get("password"), } } } - resp, auth = fetch_url( - self.module, - url, - data=json.dumps(payload), - method="POST", - timeout=self.params.get("timeout"), - use_proxy=self.params.get("use_proxy"), - ) + resp, auth = self.api_call("POST", url, data=json.dumps(payload), return_response=True) # Handle APIC response if auth.get("status") != 200: @@ -421,10 +436,10 @@ def login(self): try: # APIC error self.response_json(auth["body"]) - self.fail_json(msg="Authentication failed: %(code)s %(text)s" % self.error) + self.fail_json(msg="Authentication failed: {code} {text}".format_map(self.error)) except KeyError: # Connection error - self.fail_json(msg="Connection failed for %(url)s. %(msg)s" % auth) + self.fail_json(msg="Connection failed for {url}. {msg}".format_map(auth)) # Retain cookie for later use self.headers["Cookie"] = resp.headers.get("Set-Cookie") @@ -463,7 +478,7 @@ def cert_auth(self, path=None, payload="", method=None): with open(self.params.get("private_key"), permission) as fh: private_key_content = fh.read() except Exception: - self.module.fail_json(msg="Cannot open private key file '%(private_key)s'." % self.params) + self.module.fail_json(msg="Cannot open private key file '{private_key}'.".format_map(self.params)) try: if HAS_CRYPTOGRAPHY: sig_key = serialization.load_pem_private_key( @@ -474,26 +489,28 @@ def cert_auth(self, path=None, payload="", method=None): else: sig_key = load_privatekey(FILETYPE_PEM, private_key_content) except Exception: - self.module.fail_json(msg="Cannot load private key file '%(private_key)s'." % self.params) + self.module.fail_json(msg="Cannot load private key file '{private_key}'.".format_map(self.params)) if self.params.get("certificate_name") is None: self.params["certificate_name"] = os.path.basename(os.path.splitext(self.params.get("private_key"))[0]) else: - self.module.fail_json(msg="Provided private key '%(private_key)s' does not appear to be a private key." % self.params) + self.module.fail_json( + msg="Provided private key {private_key} does not appear to be a private key or provided file does not exist.".format_map(self.params) + ) if self.params.get("certificate_name") is None: - self.params["certificate_name"] = self.params.get("username") + self.params["certificate_name"] = self.params.get("username", "admin") # NOTE: ACI documentation incorrectly adds a space between method and path sig_request = method + path + payload if HAS_CRYPTOGRAPHY: sig_signature = sig_key.sign(sig_request.encode(), padding.PKCS1v15(), hashes.SHA256()) else: sig_signature = sign(sig_key, sig_request, "sha256") - sig_dn = "uni/userext/user-%(username)s/usercert-%(certificate_name)s" % self.params + sig_dn = "uni/userext/user-{username}/usercert-{certificate_name}".format_map(self.params) self.headers["Cookie"] = ( "APIC-Certificate-Algorithm=v1.0; " - + "APIC-Certificate-DN=%s; " % sig_dn + + "APIC-Certificate-DN={0}; ".format(sig_dn) + "APIC-Certificate-Fingerprint=fingerprint; " - + "APIC-Request-Signature=%s" % to_native(base64.b64encode(sig_signature)) + + "APIC-Request-Signature={0}".format(to_native(base64.b64encode(sig_signature))) ) def response_json(self, rawoutput): @@ -502,7 +519,7 @@ def response_json(self, rawoutput): jsondata = json.loads(rawoutput) except Exception as e: # Expose RAW output for troubleshooting - self.error = dict(code=-1, text="Unable to parse output as JSON, see 'raw' output. %s" % e) + self.error = dict(code=-1, text="Unable to parse output as JSON, see 'raw' output. {0}".format(e)) self.result["raw"] = rawoutput return @@ -524,7 +541,7 @@ def response_xml(self, rawoutput): xmldata = cobra.data(xml) except Exception as e: # Expose RAW output for troubleshooting - self.error = dict(code=-1, text="Unable to parse output as XML, see 'raw' output. %s" % e) + self.error = dict(code=-1, text="Unable to parse output as XML, see 'raw' output. {0}".format(e)) self.result["raw"] = rawoutput return @@ -547,100 +564,6 @@ def response_error(self): except (AttributeError, IndexError, KeyError): pass - def request(self, path, payload=None): - """Perform a REST request""" - - # Ensure method is set (only do this once) - self.define_method() - self.path = path - - if self.params.get("port") is not None: - self.url = "%(protocol)s://%(host)s:%(port)s/" % self.params + path.lstrip("/") - else: - self.url = "%(protocol)s://%(host)s/" % self.params + path.lstrip("/") - - # Sign and encode request as to APIC's wishes - if self.params.get("private_key"): - self.cert_auth(path=path, payload=payload) - - # Perform request - resp, info = fetch_url( - self.module, - self.url, - data=payload, - headers=self.headers, - method=self.params.get("method").upper(), - timeout=self.params.get("timeout"), - use_proxy=self.params.get("use_proxy"), - ) - - self.response = info.get("msg") - self.status = info.get("status") - - # Handle APIC response - if info.get("status") != 200: - try: - # APIC error - self.response_json(info["body"]) - self.fail_json(msg="APIC Error %(code)s: %(text)s" % self.error) - except KeyError: - # Connection error - self.fail_json(msg="Connection failed for %(url)s. %(msg)s" % info) - - self.response_json(resp.read()) - - def query(self, path): - """Perform a query with no payload""" - - self.path = path - - if self.params.get("port") is not None: - self.url = "%(protocol)s://%(host)s:%(port)s/" % self.params + path.lstrip("/") - else: - self.url = "%(protocol)s://%(host)s/" % self.params + path.lstrip("/") - - # Sign and encode request as to APIC's wishes - if self.params.get("private_key"): - self.cert_auth(path=path, method="GET") - - # Perform request - resp, query = fetch_url( - self.module, - self.url, - data=None, - headers=self.headers, - method="GET", - timeout=self.params.get("timeout"), - use_proxy=self.params.get("use_proxy"), - ) - - # Handle APIC response - if query.get("status") != 200: - self.response = query.get("msg") - self.status = query.get("status") - try: - # APIC error - self.response_json(query["body"]) - self.fail_json(msg="APIC Error %(code)s: %(text)s" % self.error) - except KeyError: - # Connection error - self.fail_json(msg="Connection failed for %(url)s. %(msg)s" % query) - - query = json.loads(resp.read()) - - return json.dumps(query.get("imdata"), sort_keys=True, indent=2) + "\n" - - def request_diff(self, path, payload=None): - """Perform a request, including a proper diff output""" - self.result["diff"] = dict() - self.result["diff"]["before"] = self.query(path) - self.request(path, payload=payload) - # TODO: Check if we can use the request output for the 'after' diff - self.result["diff"]["after"] = self.query(path) - - if self.result.get("diff", {}).get("before") != self.result.get("diff", {}).get("after"): - self.result["changed"] = True - # TODO: This could be designed to update existing keys def update_qs(self, params): """Append key-value pairs to self.filter_string""" @@ -877,15 +800,7 @@ def construct_url( self.child_classes = set(child_classes) if subclass_5 is not None: - self._construct_url_6( - root_class, - subclass_1, - subclass_2, - subclass_3, - subclass_4, - subclass_5, - config_only, - ) + self._construct_url_6(root_class, subclass_1, subclass_2, subclass_3, subclass_4, subclass_5, config_only) elif subclass_4 is not None: self._construct_url_5(root_class, subclass_1, subclass_2, subclass_3, subclass_4, config_only) elif subclass_3 is not None: @@ -1232,37 +1147,9 @@ def delete_config(self): if not self.existing: return - elif not self.module.check_mode: # Sign and encode request as to APIC's wishes - if self.params["private_key"]: - self.cert_auth(method="DELETE") - - resp, info = fetch_url( - self.module, - self.url, - headers=self.headers, - method="DELETE", - timeout=self.params.get("timeout"), - use_proxy=self.params.get("use_proxy"), - ) - - self.response = info.get("msg") - self.status = info.get("status") - self.method = "DELETE" - - # Handle APIC response - if info.get("status") == 200: - self.result["changed"] = True - self.response_json(resp.read()) - else: - try: - # APIC error - self.response_json(info["body"]) - self.fail_json(msg="APIC Error %(code)s: %(text)s" % self.error) - except KeyError: - # Connection error - self.fail_json(msg="Connection failed for %(url)s. %(msg)s" % info) + self.api_call("DELETE", self.url, None, return_response=False) else: self.result["changed"] = True self.method = "DELETE" @@ -1272,7 +1159,6 @@ def get_diff(self, aci_class): This method is used to get the difference between the proposed and existing configurations. Each module should call the get_existing method before this method, and add the proposed config to the module results using the module's config parameters. The new config will added to the self.result dictionary. - :param aci_class: Type str. This is the root dictionary key for the MO's configuration body, or the ACI class of the MO. """ @@ -1309,7 +1195,6 @@ def get_diff_child(child_class, proposed_child, existing_child): """ This method is used to get the difference between a proposed and existing child configs. The get_nested_config() method should be used to return the proposed and existing config portions of child. - :param child_class: Type str. The root class (dict key) for the child dictionary. :param proposed_child: Type dict. @@ -1334,7 +1219,6 @@ def get_diff_children(self, aci_class, proposed_obj=None, existing_obj=None): """ This method is used to retrieve the updated child configs by comparing the proposed children configs against the objects existing children configs. - :param aci_class: Type str. This is the root dictionary key for the MO's configuration body, or the ACI class of the MO. :return: The list of updated child config dictionaries. None is returned if there are no changes to the child @@ -1388,40 +1272,13 @@ def get_existing(self): """ uri = self.url + self.filter_string - # Sign and encode request as to APIC's wishes - if self.params.get("private_key"): - self.cert_auth(path=self.path + self.filter_string, method="GET") - - resp, info = fetch_url( - self.module, - uri, - headers=self.headers, - method="GET", - timeout=self.params.get("timeout"), - use_proxy=self.params.get("use_proxy"), - ) - self.response = info.get("msg") - self.status = info.get("status") - self.method = "GET" - - # Handle APIC response - if info.get("status") == 200: - self.existing = json.loads(resp.read())["imdata"] - else: - try: - # APIC error - self.response_json(info["body"]) - self.fail_json(msg="APIC Error %(code)s: %(text)s" % self.error) - except KeyError: - # Connection error - self.fail_json(msg="Connection failed for %(url)s. %(msg)s" % info) + self.api_call("GET", uri, data=None, return_response=False) @staticmethod def get_nested_config(proposed_child, existing_children): """ This method is used for stiping off the outer layers of the child dictionaries so only the configuration key, value pairs are returned. - :param proposed_child: Type dict. The dictionary that represents the child config. :param existing_children: Type list. @@ -1451,7 +1308,6 @@ def get_nested_config(proposed_child, existing_children): def get_nested_children(proposed_child, existing_children): """ This method is used for stiping off the outer layers of the child dictionaries so only the children are returned. - :param proposed_child: Type dict. The dictionary that represents the child config. :param existing_children: Type list. @@ -1486,7 +1342,6 @@ def payload(self, aci_class, class_config, child_configs=None): This method is used to dynamically build the proposed configuration dictionary from the config related parameters passed into the module. All values that were not passed values from the playbook task will be removed so as to not inadvertently change configurations. - :param aci_class: Type str This is the root dictionary key for the MO's configuration body, or the ACI class of the MO. :param class_config: Type dict @@ -1541,35 +1396,7 @@ def post_config(self, parent_class=None): else: url = "{protocol}://{host}/{path}".format(path=self.parent_path, **self.module.params) self.config = {parent_class: {"attributes": {}, "children": [self.config]}} - if self.params.get("private_key"): - self.cert_auth(method="POST", payload=json.dumps(self.config)) - - resp, info = fetch_url( - self.module, - url, - data=json.dumps(self.config), - headers=self.headers, - method="POST", - timeout=self.params.get("timeout"), - use_proxy=self.params.get("use_proxy"), - ) - - self.response = info.get("msg") - self.status = info.get("status") - self.method = "POST" - - # Handle APIC response - if info.get("status") == 200: - self.result["changed"] = True - self.response_json(resp.read()) - else: - try: - # APIC error - self.response_json(info["body"]) - self.fail_json(msg="APIC Error %(code)s: %(text)s" % self.error) - except KeyError: - # Connection error - self.fail_json(msg="Connection failed for %(url)s. %(msg)s" % info) + self.api_call("POST", url, json.dumps(self.config), return_response=False) else: self.result["changed"] = True self.method = "POST" @@ -1596,6 +1423,8 @@ def exit_json(self, filter_existing=None, **kwargs): self.result["response"] = self.response self.result["status"] = self.status self.result["url"] = self.url + if self.httpapi_logs is not None: + self.result["httpapi_logs"] = self.httpapi_logs if self.stdout: self.result["stdout"] = self.stdout @@ -1624,12 +1453,13 @@ def fail_json(self, msg, **kwargs): if self.error.get("code") is not None and self.error.get("text") is not None: self.result["error"] = self.error + if self.stdout: + self.result["stdout"] = self.stdout + if "state" in self.params: if self.params.get("state") in ("absent", "present"): if self.params.get("output_level") in ("debug", "info"): self.result["previous"] = self.existing - if self.stdout: - self.result["stdout"] = self.stdout # Return the gory details when we need it if self.params.get("output_level") == "debug": @@ -1646,6 +1476,8 @@ def fail_json(self, msg, **kwargs): self.result["response"] = self.response self.result["status"] = self.status self.result["url"] = self.url + if self.httpapi_logs is not None: + self.result["httpapi_logs"] = self.httpapi_logs if "state" in self.params: if self.params.get("output_level") in ("debug", "info"): @@ -1680,30 +1512,61 @@ def dump_json(self): if self.result.get("changed") is True: json.dump([mo], output_file) - def delete_config_request(self, path): - self._config_request(path, "absent") - self.result["changed"] = True - - def get_config_request(self, path): - self._config_request(path, "query") - return self.imdata - - def _config_request(self, path, state): - reset_url = self.url - reset_state = self.params["state"] - self.params["state"] = state - self.request(path) - self.url = reset_url - self.params["state"] = reset_state + def parsed_url_path(self, url): + if not HAS_URLPARSE: + self.fail_json(msg="urlparse is not installed") + parse_result = urlparse(url) + if parse_result.query == "": + return parse_result.path + else: + return parse_result.path + "?" + parse_result.query + + def api_call(self, method, url, data=None, return_response=False): + resp = None + if self.connection is not None: + self.connection.set_params(self.params) + info = self.connection.send_request(method, self.parsed_url_path(url), data) + self.error = info.get("error") + self.httpapi_logs.extend(self.connection.pop_messages()) + else: + if self.params.get("private_key"): + self.cert_auth(path=self.parsed_url_path(url), payload=data, method=method) + resp, info = fetch_url( + self.module, + url, + data=data, + headers=self.headers, + method=method, + timeout=self.params.get("timeout", 30), + use_proxy=self.params.get("use_proxy", True), + ) + self.response = info.get("msg") + self.status = info.get("status") + self.method = method -def ospf_spec(): - return dict( - area_cost=dict(type="int"), - area_ctrl=dict(type="list", elements="str", choices=["redistribute", "summary", "suppress-fa", "unspecified"]), - area_id=dict(type="str"), - area_type=dict(type="str", choices=["nssa", "regular", "stub"]), - description=dict(type="str", aliases=["descr"]), - multipod_internal=dict(type="str", choices=["no", "yes"]), - name_alias=dict(type="str"), - ) + if return_response: + return resp, info + else: + # Handle APIC response + if info.get("status") == 200: + if method == "POST" or method == "DELETE": + self.result["changed"] = True + try: + if method == "GET": + self.existing = json.loads(resp.read())["imdata"] + else: + self.response_json(resp.read()) + except AttributeError: + if method == "GET": + self.existing = json.loads(info.get("body"))["imdata"] + else: + self.response_json(info.get("body")) + else: + try: + # APIC error + self.response_json(info["body"]) + self.fail_json(msg="APIC Error {code}: {text}".format_map(self.error)) + except KeyError: + # Connection error + self.fail_json(msg="Connection failed for {url}. {msg}".format_map(info)) diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 8788bd32a..77c3217e6 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -1,6 +1,6 @@ VALID_IP_PROTOCOLS = ["eigrp", "egp", "icmp", "icmpv6", "igmp", "igp", "l2tp", "ospfigp", "pim", "tcp", "udp", "unspecified"] -FILTER_PORT_MAPPING = {"443": "https", "25": "smtp", "80": "http", "53": "dns", "22": "ssh", "110": "pop3", "554": "rtsp", "20": "ftpData", "ftp": "ftpData"} +FILTER_PORT_MAPPING = {"443": "https", "25": "smtp", "80": "http", "53": "dns", "110": "pop3", "554": "rtsp", "20": "ftpData", "ftp": "ftpData"} VALID_ETHER_TYPES = ["arp", "fcoe", "ip", "ipv4", "ipv6", "mac_security", "mpls_ucast", "trill", "unspecified"] diff --git a/plugins/modules/aci_access_span_src_group.py b/plugins/modules/aci_access_span_src_group.py index 350b4f9ca..fb6f6a35b 100644 --- a/plugins/modules/aci_access_span_src_group.py +++ b/plugins/modules/aci_access_span_src_group.py @@ -293,7 +293,7 @@ def main(): # } # } # ) - aci.delete_config_request("/api/mo/uni/infra/srcgrp-{0}/rssrcGrpToFilterGrp.json".format(source_group)) + aci.api_call("DELETE", "/api/mo/uni/infra/srcgrp-{0}/rssrcGrpToFilterGrp.json".format(source_group)) elif child.get("spanSpanLbl") and child.get("spanSpanLbl").get("attributes").get("name") != destination_group: child_configs.append( { diff --git a/plugins/modules/aci_access_span_src_group_src.py b/plugins/modules/aci_access_span_src_group_src.py index 2419f8aff..f8ff4349e 100644 --- a/plugins/modules/aci_access_span_src_group_src.py +++ b/plugins/modules/aci_access_span_src_group_src.py @@ -388,7 +388,7 @@ def main(): # Commented validate code to avoid making additional API request which is handled by APIC error # Keeping for informational purposes # Validate drop_packets are set on parent correctly - # if aci.get_config_request("{0}/rssrcGrpToFilterGrp.json".format(source_group_path)) != [] and drop_packets: + # if aci.api_call("GET", "{0}/rssrcGrpToFilterGrp.json".format(source_group_path)) != [] and drop_packets: # module.fail_json(msg="It is not allowed to configure 'drop_packets: true' when a filter group is configured on the source group.") source_path = "/api/mo/uni/infra/srcgrp-{0}/src-{1}".format(source_group, source) @@ -408,13 +408,13 @@ def main(): # } # } # ) - aci.delete_config_request("{0}/rssrcToFilterGrp.json".format(source_path)) + aci.api_call("DELETE", "{0}/rssrcToFilterGrp.json".format(source_path)) elif child.get("spanRsSrcToEpg") and child.get("spanRsSrcToEpg").get("attributes").get("tDn") != epg_dn: # Appending to child_config list not possible because of APIC Error 103: child (Rn) of class spanRsSrcToEpg is already attached. - aci.delete_config_request("{0}/rssrcToEpg.json".format(source_path)) + aci.api_call("DELETE", "{0}/rssrcToEpg.json".format(source_path)) elif child.get("spanRsSrcToL3extOut") and child.get("spanRsSrcToL3extOut").get("attributes").get("tDn") != l3ext_out_dn: # Appending to child_config list not possible because of APIC Error 103: child (Rn) of class spanRsSrcToL3extOut is already attached. - aci.delete_config_request("{0}/rssrcToL3extOut.json".format(source_path)) + aci.api_call("DELETE", "{0}/rssrcToL3extOut.json".format(source_path)) aci.payload( aci_class="spanSrc", diff --git a/plugins/modules/aci_config_rollback.py b/plugins/modules/aci_config_rollback.py index ceccc3c98..fced5944c 100644 --- a/plugins/modules/aci_config_rollback.py +++ b/plugins/modules/aci_config_rollback.py @@ -197,7 +197,6 @@ from ansible_collections.cisco.aci.plugins.module_utils.aci import ACIModule, aci_argument_spec, aci_annotation_spec from ansible.module_utils.basic import AnsibleModule from ansible.module_utils._text import to_bytes -from ansible.module_utils.urls import fetch_url # Optional, only used for rollback preview try: @@ -284,11 +283,11 @@ def main(): aci.post_config() elif state == "preview": - aci.url = "%(protocol)s://%(host)s/mqapi2/snapshots.diff.xml" % module.params + aci.url = "{protocol}://{host}/mqapi2/snapshots.diff.xml".format_map(module.params) aci.filter_string = ( - "?s1dn=uni/backupst/snapshots-[uni/fabric/configexp-%(export_policy)s]/snapshot-%(snapshot)s&" - "s2dn=uni/backupst/snapshots-[uni/fabric/configexp-%(compare_export_policy)s]/snapshot-%(compare_snapshot)s" - ) % module.params + "?s1dn=uni/backupst/snapshots-[uni/fabric/configexp-{export_policy}]/snapshot-{snapshot}&" + "s2dn=uni/backupst/snapshots-[uni/fabric/configexp-{compare_export_policy}]/snapshot-{compare_snapshot}" + ).format_map(module.params) # Generate rollback comparison get_preview(aci) @@ -301,19 +300,21 @@ def get_preview(aci): This function is used to generate a preview between two snapshots and add the parsed results to the aci module return data. """ uri = aci.url + aci.filter_string - resp, info = fetch_url( - aci.module, uri, headers=aci.headers, method="GET", timeout=aci.module.params.get("timeout"), use_proxy=aci.module.params.get("use_proxy") - ) - aci.method = "GET" - aci.response = info.get("msg") - aci.status = info.get("status") + + resp, info = aci.api_call("GET", uri, data=None, return_response=True) # Handle APIC response if info.get("status") == 200: - xml_to_json(aci, resp.read()) + try: + xml_to_json(aci, resp.read()) + except AttributeError: + xml_to_json(aci, info.get("body")) else: - aci.result["raw"] = resp.read() - aci.fail_json(msg="Request failed: %(code)s %(text)s (see 'raw' output)" % aci.error) + try: + aci.result["raw"] = resp.read() + except AttributeError: + aci.result["raw"] = info.get("body") + aci.fail_json(msg="Request failed: {code} {text} (see 'raw' output)".format_map(aci.error)) def xml_to_json(aci, response_data): diff --git a/plugins/modules/aci_config_snapshot.py b/plugins/modules/aci_config_snapshot.py index e66b203fe..c141ab5f1 100644 --- a/plugins/modules/aci_config_snapshot.py +++ b/plugins/modules/aci_config_snapshot.py @@ -227,6 +227,8 @@ sample: https://10.11.12.13/api/mo/uni/tn-production.json """ +import json + from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.aci.plugins.module_utils.aci import ACIModule, aci_argument_spec, aci_annotation_spec @@ -302,13 +304,20 @@ def main(): aci.post_config() # Query for job information and add to results - # Change state to query else aci.request() will not execute a GET request but POST - aci.params["state"] = "query" - aci.request(path="/api/node/mo/uni/backupst/jobs-[uni/fabric/configexp-{0}].json".format(export_policy)) + path = "/api/node/mo/uni/backupst/jobs-[uni/fabric/configexp-{0}].json".format(export_policy) + if "port" in aci.params and aci.params.get("port") is not None: + port_url = "{protocol}://{host}:{port}/".format_map(aci.params) + path.lstrip("/") + else: + port_url = "{protocol}://{host}/".format_map(aci.params) + path.lstrip("/") + resp, info = aci.api_call("GET", port_url, data=None, return_response=True) + try: + aci.imdata = json.loads(resp.read())["imdata"] + except AttributeError: + aci.imdata = json.loads(info.get("body"))["imdata"] + aci.result["job_details"] = aci.imdata[0].get("configJobCont", {}) # Reset state and url to display correct in output and trigger get_existing() function with correct url aci.url = reset_url - aci.params["state"] = "present" else: # Prefix the proper url to export_policy diff --git a/plugins/modules/aci_fabric_span_src_group_src.py b/plugins/modules/aci_fabric_span_src_group_src.py index b4afca730..b4b722e41 100644 --- a/plugins/modules/aci_fabric_span_src_group_src.py +++ b/plugins/modules/aci_fabric_span_src_group_src.py @@ -385,10 +385,10 @@ def main(): for child in aci.existing[0].get("spanSrc", {}).get("children", {}): if child.get("spanRsSrcToCtx") and child.get("spanRsSrcToCtx").get("attributes").get("tDn") != vrf_dn: # Appending to child_config list not possible because of APIC Error 103: child (Rn) of class spanRsSrcToCtx is already attached. - aci.delete_config_request("{0}/rssrcToCtx.json".format(source_path)) + aci.api_call("DELETE", "{0}/rssrcToCtx.json".format(source_path)) elif child.get("spanRsSrcToBD") and child.get("spanRsSrcToBD").get("attributes").get("tDn") != bd_dn: # Appending to child_config list not possible because of APIC Error 103: child (Rn) of class spanRsSrcToBD is already attached. - aci.delete_config_request("{0}/rssrcToBD.json".format(source_path)) + aci.api_call("DELETE", "{0}/rssrcToBD.json".format(source_path)) aci.payload( aci_class="spanSrc", diff --git a/plugins/modules/aci_rest.py b/plugins/modules/aci_rest.py index 67428e4f9..d69051f8c 100644 --- a/plugins/modules/aci_rest.py +++ b/plugins/modules/aci_rest.py @@ -55,6 +55,11 @@ together with the C(template) lookup plugin, or use C(template). type: path aliases: [ config_file ] + rsp_subtree_preserve: + description: + - Preserve the response for the provided path. + type: bool + default: false extends_documentation_fragment: - cisco.aci.aci @@ -279,7 +284,6 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.aci.plugins.module_utils.aci import ACIModule, aci_argument_spec -from ansible.module_utils.urls import fetch_url from ansible.module_utils._text import to_text @@ -335,6 +339,7 @@ def main(): method=dict(type="str", default="get", choices=["delete", "get", "post"], aliases=["action"]), src=dict(type="path", aliases=["config_file"]), content=dict(type="raw"), + rsp_subtree_preserve=dict(type="bool", default=False), ) module = AnsibleModule( @@ -345,6 +350,7 @@ def main(): content = module.params.get("content") path = module.params.get("path") src = module.params.get("src") + rsp_subtree_preserve = module.params.get("rsp_subtree_preserve") # Report missing file file_exists = False @@ -352,7 +358,7 @@ def main(): if os.path.isfile(src): file_exists = True else: - module.fail_json(msg="Cannot find/access src '%s'" % src) + module.fail_json(msg="Cannot find/access src '{0}'".format(src)) # Find request type if path.find(".xml") != -1: @@ -386,7 +392,7 @@ def main(): # Validate YAML/JSON string payload = json.dumps(yaml.safe_load(payload)) except Exception as e: - module.fail_json(msg="Failed to parse provided JSON/YAML payload: %s" % to_text(e), exception=to_text(e), payload=payload) + module.fail_json(msg="Failed to parse provided JSON/YAML payload: {0}".format(to_text(e)), exception=to_text(e), payload=payload) elif rest_type == "xml" and HAS_LXML_ETREE: if content and isinstance(content, dict) and HAS_XMLJSON_COBRA: # Validate inline YAML/JSON @@ -396,42 +402,35 @@ def main(): # Validate XML string payload = etree.tostring(etree.fromstring(payload), encoding="unicode") except Exception as e: - module.fail_json(msg="Failed to parse provided XML payload: %s" % to_text(e), payload=payload) + module.fail_json(msg="Failed to parse provided XML payload: {0}".format(to_text(e)), payload=payload) # Perform actual request using auth cookie (Same as aci.request(), but also supports XML) if "port" in aci.params and aci.params.get("port") is not None: - aci.url = "%(protocol)s://%(host)s:%(port)s/" % aci.params + path.lstrip("/") + aci.url = "{protocol}://{host}:{port}/".format_map(aci.params) + path.lstrip("/") else: - aci.url = "%(protocol)s://%(host)s/" % aci.params + path.lstrip("/") - if aci.params.get("method") != "get": - path += "?rsp-subtree=modified" + aci.url = "{protocol}://{host}/".format_map(aci.params) + path.lstrip("/") + if aci.params.get("method") != "get" and not rsp_subtree_preserve: aci.url = update_qsl(aci.url, {"rsp-subtree": "modified"}) - # Sign and encode request as to APIC's wishes - if aci.params.get("private_key") is not None: - aci.cert_auth(path=path, payload=payload) - - aci.method = aci.params.get("method").upper() + method = aci.params.get("method").upper() # Perform request - resp, info = fetch_url( - module, aci.url, data=payload, headers=aci.headers, method=aci.method, timeout=aci.params.get("timeout"), use_proxy=aci.params.get("use_proxy") - ) - - aci.response = info.get("msg") - aci.status = info.get("status") + resp, info = aci.api_call(method, aci.url, data=payload, return_response=True) # Report failure if info.get("status") != 200: try: # APIC error - aci.response_type(info.get("body"), rest_type) - aci.fail_json(msg="APIC Error %(code)s: %(text)s" % aci.error) + aci.response_type(info["body"], rest_type) + aci.fail_json(msg="APIC Error {code}: {text}".format_map(aci.error)) except KeyError: # Connection error - aci.fail_json(msg="Connection failed for %(url)s. %(msg)s" % info) + aci.fail_json(msg="Connection failed for {url}. {msg}".format_map(info)) - aci.response_type(resp.read(), rest_type) + try: + aci.response_type(resp.read(), rest_type) + except AttributeError: + aci.response_type(info.get("body"), rest_type) aci.result["status"] = aci.status aci.result["imdata"] = aci.imdata diff --git a/tests/integration/inventory.networking b/tests/integration/inventory.networking index 16e3d1ee1..357f58758 100644 --- a/tests/integration/inventory.networking +++ b/tests/integration/inventory.networking @@ -3,11 +3,12 @@ cn-dmz-apic-m1-02-v42 ansible_host=173.36.219.68 aci_hostname=173.36.219.68 cn-dmz-apic-m1-03-v52 ansible_host=173.36.219.69 aci_hostname=173.36.219.69 cn-dmz-apic-m1-04-v60 ansible_host=173.36.219.70 aci_hostname=173.36.219.70 cn-dmz-apic-m1-07-v32 ansible_host=173.36.219.73 aci_hostname=173.36.219.73 -aws_cloud ansible_host=52.52.20.121 aci_hostname=52.52.20.121 aci_password="sJ94G92#8dq2hx*K4qh" cloud_type=aws region=us-east-1 region_2=us-west-1 availability_zone=us-west-1a -azure_cloud ansible_host=20.245.236.136 aci_hostname=20.245.236.136 aci_password="sJ94G92#8dq2hx*K4qh" cloud_type=azure region=westus region_2=westus2 vnet_gateway=true +aws_cloud ansible_host=52.52.20.121 aci_hostname=52.52.20.121 cloud_type=aws region=us-east-1 region_2=us-west-1 availability_zone=us-west-1a +azure_cloud ansible_host=20.245.236.136 aci_hostname=20.245.236.136 cloud_type=azure region=westus region_2=westus2 vnet_gateway=true [aci:vars] aci_username=ansible_github_ci aci_password="sJ94G92#8dq2hx*K4qh" +ansible_network_os=cisco.aci.aci ansible_connection=local ansible_python_interpreter=/usr/bin/python3.9 diff --git a/tests/integration/targets/aci_aaa_user_certificate/tasks/main.yml b/tests/integration/targets/aci_aaa_user_certificate/tasks/main.yml index 9d300e4d7..3140816e0 100644 --- a/tests/integration/targets/aci_aaa_user_certificate/tasks/main.yml +++ b/tests/integration/targets/aci_aaa_user_certificate/tasks/main.yml @@ -300,13 +300,13 @@ state: present - name: Remove user ansible_test - aci_aaa_user: - <<: *aci_info - aaa_user: ansible_test - state: absent + cisco.aci.aci_aaa_user: + <<: *aci_info + aaa_user: ansible_test + state: absent - name: Add user ansible_test - aci_aaa_user: + cisco.aci.aci_aaa_user: <<: *aci_info aaa_user: ansible_test aaa_password: ansible_5351 @@ -315,7 +315,7 @@ state: present - name: Add user certificate - aci_aaa_user_certificate: + cisco.aci.aci_aaa_user_certificate: <<: *aci_info aaa_user: ansible_test name: test @@ -323,10 +323,8 @@ state: present - name: Query test certificate - aci_aaa_user_certificate: - host: "{{ aci_hostname }}" - username: "{{ aci_username }}" - validate_certs: '{{ aci_validate_certs | default(false) }}' + cisco.aci.aci_aaa_user_certificate: + <<: *aci_info certificate_name: admin private_key: '{{ role_path }}/pki/admin.key' aaa_user: ansible_test @@ -334,13 +332,75 @@ state: query register: query_test +- name: Query test certificate with a private key file and no certificate name + cisco.aci.aci_aaa_user_certificate: + <<: *aci_info + private_key: '{{ role_path }}/pki/admin.key' + aaa_user: ansible_test + name: test + state: query + register: query_test_pk_file + +- name: Query test certificate with a non existent key + cisco.aci.aci_aaa_user_certificate: + <<: *aci_info + private_key: '{{ role_path }}/pki/non_existent.key' + aaa_user: ansible_test + name: test + state: query + register: query_test_non_existent_key + ignore_errors: true + +- name: Query test certificate with private key content and no certificate name present + cisco.aci.aci_aaa_user_certificate: + <<: *aci_info + private_key: | + -----BEGIN PRIVATE KEY----- + MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKIRv+2sbbewm0mj + D+6/tpoUymzYIdFsN+gu02teIr/lZi8ipEB514pyhoaerstzboPteWvniLuwq4KQ + VTEHgoln7J8EaHCnECViGA61pVx8RkJ99cmCkepspROw3I96zBcm58oXs6+Q/BnD + /OWET5sBvR9oTv9GNRVJ1rvSMAEJAgMBAAECgYByu3QO0qF9h7X3JEu0Ld4cKBnB + giQ2uJC/et7KxIJ/LOvw9GopBthyt27KwG1ntBkJpkTuAaQHkyNns7vLkNB0S0IR + +owVFEcKYq9VCHTaiQU8TDp24gN+yPTrpRuH8YhDVq5SfVdVuTMgHVQdj4ya4VlF + Gj+a7+ipxtGiLsVGrQJBAM7p0Fm0xmzi+tBOASUAcVrPLcteFIaTBFwfq16dm/ON + 00Khla8Et5kMBttTbqbukl8mxFjBEEBlhQqb6EdQQ0sCQQDIhHx1a9diG7y/4DQA + 4KvR3FCYwP8PBORlSamegzCo+P1OzxiEo0amX7yQMA5UyiP/kUsZrme2JBZgna8S + p4R7AkEAr7rMhSOPUnMD6V4WgsJ5g1Jp5kqkzBaYoVUUSms5RASz4+cwJVCwTX91 + Y1jcpVIBZmaaY3a0wrx13ajEAa0dOQJBAIpjnb4wqpsEh7VpmJqOdSdGxb1XXfFQ + sA0T1OQYqQnFppWwqrxIL+d9pZdiA1ITnNqyvUFBNETqDSOrUHwwb2cCQGArE+vu + ffPUWQ0j+fiK+covFG8NL7H+26NSGB5+Xsn9uwOGLj7K/YT6CbBtr9hJiuWjM1Al + 0V4ltlTuu2mTMaw= + -----END PRIVATE KEY----- + aaa_user: ansible_test + name: test + state: query + register: query_test_pk_content + - name: Verify query_test assert: that: - query_test is not changed + - query_test_pk_file is not changed + - '"Provided private key ******** does not appear to be a private key or provided file does not exist." in query_test_non_existent_key.msg' + - query_test_pk_content is not changed + +# Cleanup environment +- name: Remove test certificate + cisco.aci.aci_aaa_user_certificate: + <<: *aci_info + aaa_user: ansible_test + name: test + state: absent - name: Remove user to clean environment for next test on ci - aci_aaa_user: + cisco.aci.aci_aaa_user: <<: *aci_info aaa_user: ansible_test state: absent + +- name: Remove test certificate + cisco.aci.aci_aaa_user_certificate: + <<: *aci_info + aaa_user: '{{ aci_username }}' + name: admin + state: absent diff --git a/tests/integration/targets/aci_bd_subnet/tasks/main.yml b/tests/integration/targets/aci_bd_subnet/tasks/main.yml index f9833cf75..8e7a32b81 100644 --- a/tests/integration/targets/aci_bd_subnet/tasks/main.yml +++ b/tests/integration/targets/aci_bd_subnet/tasks/main.yml @@ -8,6 +8,24 @@ msg: 'Please define the following variables: aci_hostname, aci_username and aci_password.' when: aci_hostname is not defined or aci_username is not defined or aci_password is not defined +- name: Set vars + set_fact: + aci_info: &aci_info + host: "{{ aci_hostname }}" + username: "{{ aci_username }}" + password: "{{ aci_password }}" + validate_certs: '{{ aci_validate_certs | default(false) }}' + use_ssl: '{{ aci_use_ssl | default(true) }}' + use_proxy: '{{ aci_use_proxy | default(true) }}' + output_level: debug + +- name: Query system information + cisco.aci.aci_system: + <<: *aci_info + id: 1 + state: query + register: version + - name: Verify Cloud and Non-Cloud Sites in use. include_tasks: ../../../../../../integration/targets/aci_cloud_provider/tasks/main.yml @@ -60,7 +78,7 @@ <<: *aci_subnet_present register: create_subnet - - name: create new subnet - creation works + - name: create new subnet - creation works (with no ip_data_plane_learning - APIC version < 5.0) cisco.aci.aci_bd_subnet: &aci_subnet2_present <<: *aci_subnet2_absent state: present @@ -68,8 +86,20 @@ scope: [private, shared] route_profile: default route_profile_l3_out: default - ip_data_plane_learning: disabled register: create_subnet2 + when: version.current.0.topSystem.attributes.version is version('5', '<') + + - name: create new subnet - creation works (with ip_data_plane_learning - APIC version >= 5.0) + cisco.aci.aci_bd_subnet: + <<: *aci_subnet2_present + state: present + descr: Ansible Test + scope: [private, shared] + route_profile: default + route_profile_l3_out: default + ip_data_plane_learning: disabled + register: create_subnet2_5 + when: version.current.0.topSystem.attributes.version is version('5', '>=') - name: create subnet again - idempotency works cisco.aci.aci_bd_subnet: @@ -97,7 +127,7 @@ register: create_incomplete_data ignore_errors: true - - name: asserts for subnet creation tasks + - name: assert for subnet creation tasks assert: that: - create_check_mode is changed @@ -106,14 +136,7 @@ - create_check_mode.sent.fvSubnet.attributes.name == create_subnet.sent.fvSubnet.attributes.name == 'anstest' - create_subnet is changed - create_subnet.current.0.fvSubnet.attributes.annotation == 'orchestrator:ansible' - - create_subnet.current.0.fvSubnet.attributes.ipDPLearning == 'enabled' - create_subnet.previous == [] - - create_subnet2 is changed - - create_subnet2.current.0.fvSubnet.attributes.ipDPLearning == 'disabled' - - create_subnet2.sent == create_subnet2.proposed - - create_subnet2.sent.fvSubnet.attributes.scope == "private,shared" - - create_subnet2.sent.fvSubnet.children.0.fvRsBDSubnetToProfile.attributes.tnL3extOutName == 'default' - - create_subnet2.sent.fvSubnet.children.0.fvRsBDSubnetToProfile.attributes.tnRtctrlProfileName == 'default' - create_idempotency is not changed - create_idempotency.previous != [] - modify_subnet is changed @@ -126,6 +149,28 @@ - create_incomplete_data is failed - 'create_incomplete_data.msg == "state is present but all of the following are missing: bd"' + - name: assert for subnet for task with version < 5 + assert: + that: + - create_subnet2 is changed + - create_subnet2.sent == create_subnet2.proposed + - create_subnet2.sent.fvSubnet.attributes.scope == "private,shared" + - create_subnet2.sent.fvSubnet.children.0.fvRsBDSubnetToProfile.attributes.tnL3extOutName == 'default' + - create_subnet2.sent.fvSubnet.children.0.fvRsBDSubnetToProfile.attributes.tnRtctrlProfileName == 'default' + when: version.current.0.topSystem.attributes.version is version('5', '<') + + - name: assert for subnet ip_data_learning for task with version >=5 + assert: + that: + - create_subnet.current.0.fvSubnet.attributes.ipDPLearning == 'enabled' + - create_subnet2_5 is changed + - create_subnet2_5.current.0.fvSubnet.attributes.ipDPLearning == 'disabled' + - create_subnet2_5.sent == create_subnet2_5.proposed + - create_subnet2_5.sent.fvSubnet.attributes.scope == "private,shared" + - create_subnet2_5.sent.fvSubnet.children.0.fvRsBDSubnetToProfile.attributes.tnL3extOutName == 'default' + - create_subnet2_5.sent.fvSubnet.children.0.fvRsBDSubnetToProfile.attributes.tnRtctrlProfileName == 'default' + when: version.current.0.topSystem.attributes.version is version('5', '>=') + - name: get all subnets cisco.aci.aci_bd_subnet: &aci_query <<: *aci_tenant_present diff --git a/tests/integration/targets/aci_dns_domain/tasks/main.yml b/tests/integration/targets/aci_dns_domain/tasks/main.yml index 2d3de4dd0..af9c1026b 100644 --- a/tests/integration/targets/aci_dns_domain/tasks/main.yml +++ b/tests/integration/targets/aci_dns_domain/tasks/main.yml @@ -26,7 +26,6 @@ <<: *aci_info profile_name: ansible_dns_profile state: absent - delegate_to: localhost # ADD DNS PROFILE - name: Add DNS profile @@ -34,7 +33,6 @@ <<: *aci_info profile_name: ansible_dns_profile state: present - delegate_to: localhost # ADD DNS DOMAIN - name: Add a new DNS domain @@ -44,7 +42,6 @@ domain: example.com default: false state: present - delegate_to: localhost register: add_dns_domain - name: Verify DNS domain creation @@ -64,7 +61,6 @@ domain: example.com default: false state: present - delegate_to: localhost register: add_dns_domain_again - name: Verify DNS domain creation idempotence @@ -83,7 +79,6 @@ domain: example.com default: true state: present - delegate_to: localhost register: update_dns_domain - name: Verify DNS domain update @@ -101,7 +96,6 @@ dns_profile: ansible_dns_profile domain: example.com state: query - delegate_to: localhost register: query_dns_domain - name: Verify DNS domain attributes @@ -118,7 +112,6 @@ <<: *aci_info dns_profile: ansible_dns_profile state: query - delegate_to: localhost register: query_dns_domain_all - name: Verify DNS domain query idempotence @@ -133,7 +126,6 @@ dns_profile: ansible_dns_profile domain: example.com state: absent - delegate_to: localhost register: delete_dns_domain - name: Verify DNS domain deletion @@ -152,7 +144,6 @@ dns_profile: ansible_dns_profile domain: example.com state: absent - delegate_to: localhost register: delete_dns_domain_again - name: Verify DNS domain deletion idempotence @@ -166,4 +157,3 @@ <<: *aci_info profile_name: ansible_dns_profile state: absent - delegate_to: localhost diff --git a/tests/integration/targets/aci_dns_provider/tasks/main.yml b/tests/integration/targets/aci_dns_provider/tasks/main.yml index 8deca651c..3e31d2b52 100644 --- a/tests/integration/targets/aci_dns_provider/tasks/main.yml +++ b/tests/integration/targets/aci_dns_provider/tasks/main.yml @@ -26,7 +26,6 @@ <<: *aci_info profile_name: ansible_dns_profile state: absent - delegate_to: localhost # ADD DNS PROFILE - name: Add DNS profile @@ -34,7 +33,6 @@ <<: *aci_info profile_name: ansible_dns_profile state: present - delegate_to: localhost # ADD DNS PROVIDER - name: Add a new DNS provider @@ -44,7 +42,6 @@ addr: 10.20.30.40 preferred: false state: present - delegate_to: localhost register: add_dns_provider - name: Verify DNS provider creation @@ -64,7 +61,6 @@ addr: 10.20.30.40 preferred: false state: present - delegate_to: localhost register: add_dns_provider_again - name: Verify DNS provider creation idempotence @@ -83,7 +79,6 @@ addr: 10.20.30.40 preferred: true state: present - delegate_to: localhost register: update_dns_provider - name: Verify DNS provider update @@ -101,7 +96,6 @@ dns_profile: ansible_dns_profile addr: 10.20.30.40 state: query - delegate_to: localhost register: query_dns_provider - name: Verify DNS provider attributes @@ -118,7 +112,6 @@ <<: *aci_info dns_profile: ansible_dns_profile state: query - delegate_to: localhost register: query_dns_provider_all - name: Verify DNS provider query idempotence @@ -133,7 +126,6 @@ dns_profile: ansible_dns_profile addr: 10.20.30.40 state: absent - delegate_to: localhost register: delete_dns_provider - name: Verify DNS provider deletion @@ -152,7 +144,6 @@ dns_profile: ansible_dns_profile addr: 10.20.30.40 state: absent - delegate_to: localhost register: delete_dns_provider_again - name: Verify DNS provider deletion idempotence @@ -166,4 +157,3 @@ <<: *aci_info profile_name: ansible_dns_profile state: absent - delegate_to: localhost diff --git a/tests/integration/targets/aci_filter_entry/tasks/main.yml b/tests/integration/targets/aci_filter_entry/tasks/main.yml index 5461617c8..2f1bcdc53 100644 --- a/tests/integration/targets/aci_filter_entry/tasks/main.yml +++ b/tests/integration/targets/aci_filter_entry/tasks/main.yml @@ -78,8 +78,8 @@ ether_type: ip ip_protocol: tcp source_port: 20 - source_port_start: 22 - source_port_end: 22 + source_port_start: 80 + source_port_end: 80 register: nt_source_port ignore_errors: true @@ -119,7 +119,7 @@ ether_type: ip ip_protocol: tcp source_port_start: 20 - source_port_end: 22 + source_port_end: 80 tcp_flags: - acknowledgment - finish @@ -194,7 +194,7 @@ - source_port_values is changed - source_port_values.current.0.vzEntry.attributes.name == source_port_values.sent.vzEntry.attributes.name == "source_port_values" - source_port_values.current.0.vzEntry.attributes.sFromPort == source_port_values.sent.vzEntry.attributes.sFromPort == "ftpData" - - source_port_values.current.0.vzEntry.attributes.sToPort == source_port_values.sent.vzEntry.attributes.sToPort == "ssh" + - source_port_values.current.0.vzEntry.attributes.sToPort == source_port_values.sent.vzEntry.attributes.sToPort == "http" - source_port_values.current.0.vzEntry.attributes.tcpRules == source_port_values.sent.vzEntry.attributes.tcpRules == "ack,fin" - source_port_values.current.0.vzEntry.attributes.applyToFrag == "no" - source_port_values.current.0.vzEntry.attributes.arpOpc == "unspecified" diff --git a/tests/integration/targets/aci_interface_blacklist/tasks/main.yml b/tests/integration/targets/aci_interface_blacklist/tasks/main.yml index d7b125267..17e0bb67c 100644 --- a/tests/integration/targets/aci_interface_blacklist/tasks/main.yml +++ b/tests/integration/targets/aci_interface_blacklist/tasks/main.yml @@ -45,6 +45,40 @@ - name: Execute tasks only for non-cloud sites when: query_cloud.current == [] # This condition will execute only non-cloud sites block: # block specifies execution of tasks within, based on conditions + + - name: Query blacklisted interfaces + cisco.aci.aci_interface_blacklist: + <<: *aci_info + state: query + register: enable_and_clear + + - name: set regex + set_fact: + regexp: '(topology/pod-)(\d)(/paths-)(\d*)(/pathep-\[eth)(.*)(])' + + - name: Save Target DNs + set_fact: + tdn: "{{ item.fabricRsOosPath.attributes.tDn }}" + loop: "{{ enable_and_clear.current }}" + register: enabled_tdn + + - name: Enable interfaces that were blacklisted + cisco.aci.aci_interface_blacklist: + <<: *aci_info + pod_id: "{{ item.ansible_facts.tdn | regex_search(regexp, '\\2') | first }}" + node_id: "{{ item.ansible_facts.tdn | regex_search(regexp, '\\4') | first }}" + interface: "{{ item.ansible_facts.tdn | regex_search(regexp, '\\6') | first }}" + state: absent + loop: "{{ enabled_tdn.results }}" + + - name: Ensure Interfaces are absent - Clean up test environment + cisco.aci.aci_interface_config: + <<: *aci_info + node: "{{ item.ansible_facts.tdn | regex_search(regexp, '\\4') | first }}" + interface: "{{ item.ansible_facts.tdn | regex_search(regexp, '\\6') | first }}" + state: absent + loop: "{{ enabled_tdn.results }}" + - name: Spine - Clean test environment with enabled interface cisco.aci.aci_interface_blacklist: <<: *aci_info diff --git a/tests/integration/targets/aci_interface_config/tasks/main.yml b/tests/integration/targets/aci_interface_config/tasks/main.yml index 75253bd40..4a3915148 100644 --- a/tests/integration/targets/aci_interface_config/tasks/main.yml +++ b/tests/integration/targets/aci_interface_config/tasks/main.yml @@ -527,7 +527,7 @@ assert: that: - query_all_access_interfaces is not changed - - query_all_access_interfaces.current|length >= 6 + - query_all_access_interfaces.current|length >= 5 - name: Query all fabric interfaces cisco.aci.aci_interface_config: @@ -678,3 +678,19 @@ that: - query_interface_509 is not changed - query_interface_509.current == [] + + - name: Ensure Interfaces 50* are absent - Clean up test environment + cisco.aci.aci_interface_config: + <<: *aci_info + node: "{{ item.node }}" + interface: "{{ item.interface }}" + state: absent + with_items: + - { node: "501", interface: "1/1/1" } + - { node: "502", interface: "2/2/2" } + - { node: "503", interface: "1/1/1" } + - { node: "505", interface: "5/5/5" } + - { node: "506", interface: "6/6/6" } + - { node: "507", interface: "7/7/7" } + - { node: "508", interface: "8/8/8" } + - { node: "509", interface: "9/9/9" } diff --git a/tests/integration/targets/aci_rest/tasks/error_handling.yml b/tests/integration/targets/aci_rest/tasks/error_handling.yml index 1c503c5f2..9ecd7751f 100644 --- a/tests/integration/targets/aci_rest/tasks/error_handling.yml +++ b/tests/integration/targets/aci_rest/tasks/error_handling.yml @@ -27,7 +27,7 @@ assert: that: - error_on_name_resolution is failed - - error_on_name_resolution.msg.startswith("Connection failed for https://foo.bar.cisco.com/api/aaaLogin.json. Request failed:") + - error_on_name_resolution.msg.startswith("Connection failed for https://foo.bar.cisco.com/api/aaaLogin.json.") - "'current' not in error_on_name_resolution" - "'previous' not in error_on_name_resolution" - "'sent' not in error_on_name_resolution" diff --git a/tests/integration/targets/aci_rest/tasks/json_inline.yml b/tests/integration/targets/aci_rest/tasks/json_inline.yml index dd27525f9..731c749c3 100644 --- a/tests/integration/targets/aci_rest/tasks/json_inline.yml +++ b/tests/integration/targets/aci_rest/tasks/json_inline.yml @@ -37,12 +37,10 @@ } } } - delegate_to: localhost register: nm_add_tenant - name: Add tenant again (normal mode) cisco.aci.aci_rest: *tenant_present - delegate_to: localhost register: nm_add_tenant_again - name: Verify add_tenant @@ -72,12 +70,10 @@ } } } - delegate_to: localhost register: nm_add_tenant_descr - name: Change description of tenant again (normal mode) cisco.aci.aci_rest: *tenant_changed - delegate_to: localhost register: nm_add_tenant_descr_again - name: Verify add_tenant_descr @@ -89,7 +85,6 @@ # ADD TENANT AGAIN - name: Add tenant again with no description (normal mode) cisco.aci.aci_rest: *tenant_present - delegate_to: localhost register: nm_add_tenant_again_no_descr - name: Verify add_tenant_again_no_descr @@ -109,7 +104,6 @@ output_level: '{{ aci_output_level | default("info") }}' path: /api/mo/uni/tn-[ansible_test].json method: get - delegate_to: localhost register: nm_query_all_tenants - name: Verify query_all_tenants @@ -129,7 +123,6 @@ output_level: '{{ aci_output_level | default("info") }}' path: /api/mo/uni/tn-[ansible_test].json method: get - delegate_to: localhost register: nm_query_tenant - name: Verify query_tenant @@ -140,12 +133,10 @@ # REMOVE TENANT - name: Remove tenant (normal mode) cisco.aci.aci_rest: *tenant_absent - delegate_to: localhost register: nm_remove_tenant - name: Remove tenant again (normal mode) cisco.aci.aci_rest: *tenant_absent - delegate_to: localhost register: nm_remove_tenant_again - name: Verify remove_tenant @@ -157,7 +148,6 @@ # QUERY NON-EXISTING TENANT - name: Query non-existing tenant (normal mode) cisco.aci.aci_rest: *tenant_query - delegate_to: localhost register: nm_query_non_tenant - name: Verify query_non_tenant diff --git a/tests/integration/targets/aci_static_binding_to_epg/tasks/main.yml b/tests/integration/targets/aci_static_binding_to_epg/tasks/main.yml index 180848e28..0e9096b85 100644 --- a/tests/integration/targets/aci_static_binding_to_epg/tasks/main.yml +++ b/tests/integration/targets/aci_static_binding_to_epg/tasks/main.yml @@ -306,7 +306,7 @@ - 104 - 105 ignore_errors: true - register: fex_vpc_three extpaths + register: fex_vpc_three_extpaths - name: Bind static-binding to epg - fex_port_channel with multiple extpaths (check_mode) cisco.aci.aci_static_binding_to_epg: diff --git a/tests/integration/targets/aci_tenant/filter_plugins/generate_ips.py b/tests/integration/targets/aci_tenant/filter_plugins/generate_ips.py new file mode 100644 index 000000000..130fbb786 --- /dev/null +++ b/tests/integration/targets/aci_tenant/filter_plugins/generate_ips.py @@ -0,0 +1,28 @@ +# Copyright (c) 2023 Cisco and/or its affiliates. +# Copyright: (c) 2023, Shreyas Srish (@shrsr) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ipaddress import ip_network +import random + +RANGE_IPV4 = list(ip_network("192.0.2.0/24").hosts()) + list(ip_network("198.51.100.0/24").hosts()) + list(ip_network("203.0.113.0/24").hosts()) + + +class FilterModule(object): + def filters(self): + return { + "generate_random_ips": self.generate_random_ips, + } + + def generate_random_ips(self, given_ip, insert_given_ip_at, number_of_ips): + ips = "" + for i in range(number_of_ips): + if i == insert_given_ip_at - 1: + ips += given_ip + else: + ips += str((random.choice(RANGE_IPV4))) + ips += "," + return ips.rstrip(",") diff --git a/tests/integration/targets/aci_tenant/pki/admin.crt b/tests/integration/targets/aci_tenant/pki/admin.crt new file mode 100644 index 000000000..cfac5531e --- /dev/null +++ b/tests/integration/targets/aci_tenant/pki/admin.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICODCCAaGgAwIBAgIJAIt8XMntue0VMA0GCSqGSIb3DQEBCwUAMDQxDjAMBgNV +BAMMBUFkbWluMRUwEwYDVQQKDAxZb3VyIENvbXBhbnkxCzAJBgNVBAYTAlVTMCAX +DTE4MDEwOTAwNTk0NFoYDzIxMTcxMjE2MDA1OTQ0WjA0MQ4wDAYDVQQDDAVBZG1p +bjEVMBMGA1UECgwMWW91ciBDb21wYW55MQswCQYDVQQGEwJVUzCBnzANBgkqhkiG +9w0BAQEFAAOBjQAwgYkCgYEAohG/7axtt7CbSaMP7r+2mhTKbNgh0Ww36C7Ta14i +v+VmLyKkQHnXinKGhp6uy3Nug+15a+eIu7CrgpBVMQeCiWfsnwRocKcQJWIYDrWl +XHxGQn31yYKR6mylE7Dcj3rMFybnyhezr5D8GcP85YRPmwG9H2hO/0Y1FUnWu9Iw +AQkCAwEAAaNQME4wHQYDVR0OBBYEFD0jLXfpkrU/ChzRvfruRs/fy1VXMB8GA1Ud +IwQYMBaAFD0jLXfpkrU/ChzRvfruRs/fy1VXMAwGA1UdEwQFMAMBAf8wDQYJKoZI +hvcNAQELBQADgYEAOmvre+5tgZ0+F3DgsfxNQqLTrGiBgGCIymPkP/cBXXkNuJyl +3ac7tArHQc7WEA4U2R2rZbEq8FC3UJJm4nUVtCPvEh3G9OhN2xwYev79yt6pIn/l +KU0Td2OpVyo0eLqjoX5u2G90IBWzhyjFbo+CcKMrSVKj1YOdG0E3OuiJf00= +-----END CERTIFICATE----- diff --git a/tests/integration/targets/aci_tenant/pki/admin.key b/tests/integration/targets/aci_tenant/pki/admin.key new file mode 100644 index 000000000..63bb00cc0 --- /dev/null +++ b/tests/integration/targets/aci_tenant/pki/admin.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKIRv+2sbbewm0mj +D+6/tpoUymzYIdFsN+gu02teIr/lZi8ipEB514pyhoaerstzboPteWvniLuwq4KQ +VTEHgoln7J8EaHCnECViGA61pVx8RkJ99cmCkepspROw3I96zBcm58oXs6+Q/BnD +/OWET5sBvR9oTv9GNRVJ1rvSMAEJAgMBAAECgYByu3QO0qF9h7X3JEu0Ld4cKBnB +giQ2uJC/et7KxIJ/LOvw9GopBthyt27KwG1ntBkJpkTuAaQHkyNns7vLkNB0S0IR ++owVFEcKYq9VCHTaiQU8TDp24gN+yPTrpRuH8YhDVq5SfVdVuTMgHVQdj4ya4VlF +Gj+a7+ipxtGiLsVGrQJBAM7p0Fm0xmzi+tBOASUAcVrPLcteFIaTBFwfq16dm/ON +00Khla8Et5kMBttTbqbukl8mxFjBEEBlhQqb6EdQQ0sCQQDIhHx1a9diG7y/4DQA +4KvR3FCYwP8PBORlSamegzCo+P1OzxiEo0amX7yQMA5UyiP/kUsZrme2JBZgna8S +p4R7AkEAr7rMhSOPUnMD6V4WgsJ5g1Jp5kqkzBaYoVUUSms5RASz4+cwJVCwTX91 +Y1jcpVIBZmaaY3a0wrx13ajEAa0dOQJBAIpjnb4wqpsEh7VpmJqOdSdGxb1XXfFQ +sA0T1OQYqQnFppWwqrxIL+d9pZdiA1ITnNqyvUFBNETqDSOrUHwwb2cCQGArE+vu +ffPUWQ0j+fiK+covFG8NL7H+26NSGB5+Xsn9uwOGLj7K/YT6CbBtr9hJiuWjM1Al +0V4ltlTuu2mTMaw= +-----END PRIVATE KEY----- diff --git a/tests/integration/targets/aci_tenant/pki/admin_invalid.key b/tests/integration/targets/aci_tenant/pki/admin_invalid.key new file mode 100644 index 000000000..22f5fae45 --- /dev/null +++ b/tests/integration/targets/aci_tenant/pki/admin_invalid.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +This is an invalid private key +-----END PRIVATE KEY----- diff --git a/tests/integration/targets/aci_tenant/pki/openssh_rsa.key b/tests/integration/targets/aci_tenant/pki/openssh_rsa.key new file mode 100644 index 000000000..0c18da5c5 --- /dev/null +++ b/tests/integration/targets/aci_tenant/pki/openssh_rsa.key @@ -0,0 +1,16 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAIEA3VnrdPOQbr3DPF5GbC31W7ScloEpU9BSDqPmpyYPUdsWl21UXBB8 +exip3GVOl+7GbB1WkDKYr7uMuBjsfDzMzZkDAFVEpud+IUzZB7aSfSd+L9bdeFG2sGI+Fv +y1QmiMBT5gcvXaM16vRKe4FywM07/Fmd3REm/+wtmFG/C4sYUAAAIQLuIWNS7iFjUAAAAH +c3NoLXJzYQAAAIEA3VnrdPOQbr3DPF5GbC31W7ScloEpU9BSDqPmpyYPUdsWl21UXBB8ex +ip3GVOl+7GbB1WkDKYr7uMuBjsfDzMzZkDAFVEpud+IUzZB7aSfSd+L9bdeFG2sGI+Fvy1 +QmiMBT5gcvXaM16vRKe4FywM07/Fmd3REm/+wtmFG/C4sYUAAAADAQABAAAAgHj5rhALFg +MQP2X8+GwjahemzHYNPXMLRe2ucl8kE/de0CgOnq56bC4yupMz4xJyc4ufNTI2FPDmhfAP +3x+/cwZeYFsipyGdL1IYbfk0QYSP65Btr2yq8+QyN7zWdFXQ8csT0ImZgNiQKehc69ctLH +XcyelsdwNiUCRZYa7kCpf5AAAAQQCo7OSWQUa16xP9KrKm0F3fnaAKewhQNDIwok5PRgoN +03k/IpGOCAjvNuOb7DkXmVvxjO8Rj4L16vL+RTzHg8n7AAAAQQD7tej6gJy3MLcmrQ4aHb +FeLzQ/ZXS2IgdIRC8rcNB1h9Rso7+fySVFwnmwy2Um7wwsjNnr2xyhigwfQCSyRubfAAAA +QQDhH5EX7+hdm/fPLM6Goz9N3ERbIgBq2Mel5CCi/Ns7vDfBQiEla1atdKTV0S2EYfxIw2 +ehkMGbmXl2/9JHxKgbAAAAFGNpemhhb0BDSVpIQU8tTS05MjhRAQIDBAUG +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/integration/targets/aci_tenant/pki/rsa_ansible.key b/tests/integration/targets/aci_tenant/pki/rsa_ansible.key new file mode 100644 index 000000000..ac63a0055 --- /dev/null +++ b/tests/integration/targets/aci_tenant/pki/rsa_ansible.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQDVyLS8/ix6QOH7R83B4WuhsliL6nffBvrkHXXsqViit3OZd+/K +fSrNlZysUvHS4hxfRtJrFQfpkogwXEEupBPF3p0xy7wZzvjjWWJk0NQ8PoVlOhUY +emZTfMX+FFNr9pAjjjaVHb9jCuxko7upAyj8POhhETY2zYoJoa8TR6fLZwIDAQAB +AoGBALo5GzeGMThNTIyW/6Tjt94ifP9kPwcIDYSoJRECczNKmmgVEcxRO/fZW6DA +n+YTEKPuDV059KqB+iAmPKFkS9N41kaq+NUAknlFJPV6Vs3gpvJGqWgu++73dhR5 +cKsHTlK2KBsRtsXnOJ9odKWFjiTnZ1Eyvmhw7ct+Fojb/7ABAkEA9+Wwm+HGlYqw +ghuFaBtNuqC/S2vO6SEfdQvTDQKKO5ROei5m+ryjWj6flbCcG+5dLs8l4Zh3sQUL +kc0RQfHSWQJBANzFkdO6wXXPOw7RhAEP2sA2W2VacMbGynjsoDJXmypeJ7Z+odLb +5gNXET9RA77RY/saIBdwR4JNnku2WnoxU78CQQDhYirVP0vu8H6UfHMpeRGNqdLi +vq0LlrrkDxEe1f1aN/e17HRiaZnXVfKABWeZmXmNMndNifLgtiaTtC+JllRZAkEA +ydAdV0SANvaCETC7j9DzcgP+lm8PatYsHlCIvJxS7m71tKCbw0pbQDBmRtADMXrt +/4vJTEPKSrYzfxiqKstOtwJAXkWXaqVhJeKjbMj1buo6s/1qGIfSrZR/AjozvJ03 +JehevfULS3668jOYJZW6BoNhysx6+Hqf5Id8fB4iDWPQhA== +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/targets/aci_tenant/pki/rsa_user.crt b/tests/integration/targets/aci_tenant/pki/rsa_user.crt new file mode 100644 index 000000000..de2223500 --- /dev/null +++ b/tests/integration/targets/aci_tenant/pki/rsa_user.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICODCCAaGgAwIBAgIUNOqiIBh811X/tPWSUgr9rajJ7t4wDQYJKoZIhvcNAQEL +BQAwLTEOMAwGA1UEAwwFQWRtaW4xDjAMBgNVBAoMBWNpc2NvMQswCQYDVQQGEwJV +UzAgFw0yMDEwMjkyMjQ0NTNaGA8yMTIwMTAwNTIyNDQ1M1owLTEOMAwGA1UEAwwF +QWRtaW4xDjAMBgNVBAoMBWNpc2NvMQswCQYDVQQGEwJVUzCBnzANBgkqhkiG9w0B +AQEFAAOBjQAwgYkCgYEAwjb3/W3x/bPX+bylh2PjXbcFPwpdTPJwqTxCdUinJRKv +HXW7rwRiV9TdoNZZ946RvVM6l2LzUJyaK4wZZHf6WKJ2veL6LIrORA32vN+ofmpn +XcTAUQ1JVyHbriy0GaT+1wYClqImWj8HxiskgpD+pKc+kzgl33xwwwqyuF1N7ikC +AwEAAaNTMFEwHQYDVR0OBBYEFAK18YAZAaPQW7bHvqRwselDeGskMB8GA1UdIwQY +MBaAFAK18YAZAaPQW7bHvqRwselDeGskMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADgYEAgIvzyP0t4CjsmUmgG7QP977c3+Uvbt2wlCwe+rrXlqvuSeaW +l4DaTyv8kYyiqIxgrTVI/G+HbpHgTO2yH57njTIAdRjsJgMU9z0oCazOtVD8KpXj +SKFUtJVbY27BQAnbuDOawX96a0UDY44Ia9NaPuq0/mEcdCKSpQP4ZuvvKVc= +-----END CERTIFICATE----- diff --git a/tests/integration/targets/aci_tenant/pki/rsa_user.key b/tests/integration/targets/aci_tenant/pki/rsa_user.key new file mode 100644 index 000000000..354dbbdbb --- /dev/null +++ b/tests/integration/targets/aci_tenant/pki/rsa_user.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAMI29/1t8f2z1/m8 +pYdj4123BT8KXUzycKk8QnVIpyUSrx11u68EYlfU3aDWWfeOkb1TOpdi81CcmiuM +GWR3+liidr3i+iyKzkQN9rzfqH5qZ13EwFENSVch264stBmk/tcGApaiJlo/B8Yr +JIKQ/qSnPpM4Jd98cMMKsrhdTe4pAgMBAAECgYAX8c8BX9zF+rZWA/wkhRwzIa1z +6EM4iWt6cgN/kkWJPJR6fVl2aoP1cDki60qMSveM8AX5RCnbdnNLiypWSLSEogdd +bRWyFeF4ZXvivd+Lds2u5Ni3PiCrIpHfNvid2ERCaKhblQRdhi/dTH9Z+3kGspwc +jpKzWMmGjBpqWjWOQQJBAOB3cS/AxbwJ6Fzvbi6sLiK6Ry8eSIMlce3Yyw89oU+M +DGkIbggICCYKxXYIWtBbyxthdQudKFZYbLpCkLSMBXsCQQDdf5ICNN2R0ptYLhSX +kQ4tiGigi1hq93+25Ov1rI8eIFSYlKNcyA/cvwv5ptlXmy1UAyoAdGCbS47pgCwT +Nz+rAkEAtzHkR5PuDXSMluS2KRNPJ/qdxB/UEGzMGdEYkNy8vX5QVpyRqK5dcCbU +V2ukKm7wSe11KEBgPnA2dKGFFkU85wJAD895Vpr7bdtAp2yyn5cFEg74mO0ZZJlC +DoYMqb6lgJsCLtn9RzQonbMtYaadQPmcpLCNIPctpiggjV5OxxhcfQJBAM1ETm8p +/9beBPTS8cJdWHvCRE149H/ZCUxqjFZriJzFYvi0xor85eK8/3V7xaWtTkK25i3+ +xWk+sA3DYYDPGM8= +-----END PRIVATE KEY----- diff --git a/tests/integration/targets/aci_tenant/tasks/httpapi_connection.yml b/tests/integration/targets/aci_tenant/tasks/httpapi_connection.yml new file mode 100644 index 000000000..214f55fe2 --- /dev/null +++ b/tests/integration/targets/aci_tenant/tasks/httpapi_connection.yml @@ -0,0 +1,653 @@ +## Tests HTTTP Connection when a list of host are provided + +- name: Set vars + set_fact: + aci_info: &aci_info + host: "{{ aci_hostname }}" + username: "{{ aci_username }}" + password: "{{ aci_password }}" + validate_certs: "{{ aci_validate_certs | default(false) }}" + use_ssl: "{{ aci_use_ssl | default(true) }}" + use_proxy: "{{ aci_use_proxy | default(true) }}" + timeout: 5 + output_level: debug + +- name: Set vars with the randomly generated list of hosts for the task level operations + ansible.builtin.set_fact: + last_host_active: "{{ aci_hostname | generate_random_ips(5,5) }}" + second_host_active: "{{aci_hostname|generate_random_ips(2,5)}}" + no_host_active: "{{aci_hostname|generate_random_ips(0,5)}}" + +- name: Set the actual connection type specified in the inventory (this var is used at the end of this test) + ansible.builtin.set_fact: + old_connection: "{{ ansible_connection }}" + +- name: Set the connection to httpapi and set session key used in the inventory + ansible.builtin.set_fact: + ansible_user: "{{ aci_username }}" + ansible_password: "{{ aci_password }}" + ansible_connection: ansible.netcommon.httpapi + ansible_httpapi_session_key: {'admin': "{{ lookup('file', 'pki/admin.key') }}"} + ansible_httpapi_validate_certs: "{{ aci_validate_certs | default(false) }}" + ansible_httpapi_use_ssl: "{{ aci_use_ssl | default(true) }}" + ansible_httpapi_use_proxy: "{{ aci_use_proxy | default(true) }}" + ansible_httpapi_port: 443 + ansible_command_timeout: 5 + +- name: Run aci_aaa_user_certificate through the plugin + include_tasks: ../../../../../../integration/targets/aci_aaa_user_certificate/tasks/main.yml + +- name: Add user certificate to be used later in the test + cisco.aci.aci_aaa_user_certificate: + <<: *aci_info + aaa_user: "{{ aci_username }}" + name: admin + certificate: "{{ lookup('file', 'pki/admin.crt') }}" + state: present + +# XML operation, APIC HTTP Error and Connection reset upon changed parameter tests +- name: Add a tenant using an XML string (Check xml operations through plugin) + cisco.aci.aci_rest: + <<: *aci_info + path: api/mo/uni.xml + method: post + content: '' + register: tenant_xml_plugin + +- name: Add an AP (with non existent tenant) + cisco.aci.aci_ap: + <<: *aci_info + tenant: ansible_test_non_existent + ap: ap + description: default ap + state: present + ignore_errors: true + register: ap_non_existent_tenant + +- name: Delete Tenant with the wrong username and password (Check that connection resets) + cisco.aci.aci_tenant: + <<: *aci_info + username: wrong_username + password: wrong_password + tenant: ansible_test + state: absent + ignore_errors: true + register: wrong_credentials + +- name: Set the username to null + ansible.builtin.set_fact: + ansible_user: + +- name: Delete Tenant with no username in the task or inventory (Check that username is set to the default value) + cisco.aci.aci_tenant: + host: "{{ aci_hostname }}" + password: "{{ aci_password }}" + validate_certs: "{{ aci_validate_certs | default(false) }}" + use_ssl: "{{ aci_use_ssl | default(true) }}" + use_proxy: "{{ aci_use_proxy | default(true) }}" + timeout: 5 + output_level: debug + tenant: ansible_test + state: absent + ignore_errors: true + register: no_username + +- name: Revert the username to its original value + ansible.builtin.set_fact: + ansible_user: "{{ aci_username }}" + +- name: Flatten the registered instances + ansible.builtin.set_fact: + ap_non_existent_tenant_flattened: "{{ ap_non_existent_tenant.httpapi_logs | flatten }}" + wrong_credentials_flattened: "{{ wrong_credentials.httpapi_logs | flatten }}" + no_username_flattened: "{{ no_username.httpapi_logs | flatten }}" + +- name: Verify XML operation and HTTP error returned by APIC + assert: + that: + - tenant_xml_plugin.status == 200 + - '"Received response from {{ aci_hostname }} for POST operation with HTTP: 400" in ap_non_existent_tenant_flattened' + - '"Re-setting connection due to change in the username" in wrong_credentials_flattened' + - '"Re-setting connection due to change in the password" in wrong_credentials_flattened' + - '"Connection to {{ aci_hostname }} has failed: HTTP Error 401: Unauthorized" in wrong_credentials_flattened' + - '"Establishing login for admin to {{ aci_hostname }}" in no_username_flattened' + +# Simulate HTTP 403 error test +- name: Delete Tenant with only password in the task (Check for 403) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ last_host_active }}" + tenant: ansible_test + state: absent + register: op17_task_pwd_delete_tenant + +- name: Add Tenant with only password in the task (Check for 403) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ last_host_active }}" + tenant: ansible_test + state: present + register: op17_task_pwd_add_tenant + +- name: logout to render the token invalid + cisco.aci.aci_rest: + <<: *aci_info + host: "{{ last_host_active }}" + path: /api/aaaLogout.json + method: post + rsp_subtree_preserve: true + content: | + { + "aaaUser": { + "attributes": { + "name": "{{ aci_username }}" + } + } + } + +- name: Add an AP with only password in the task (Ensure re-login on the same host) + cisco.aci.aci_ap: + <<: *aci_info + host: "{{ last_host_active }}" + tenant: ansible_test + ap: ap + description: default ap + state: present + register: op18_task_pwd_add_ap + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op18_flattened_task_pwd_add_ap: "{{ op18_task_pwd_add_ap.httpapi_logs | flatten }}" + +- name: Verify forbidden error 403 + assert: + that: + - op17_task_pwd_add_tenant is changed + - op18_task_pwd_add_ap is changed + - '"Failed to receive response from {{ aci_hostname }} with HTTP Error 403: Forbidden" in op18_flattened_task_pwd_add_ap' + - '"Establishing login for {{ aci_username }} to {{ aci_hostname }}" in op18_flattened_task_pwd_add_ap' + - '"Connection to {{ aci_hostname }} was successful" in op18_flattened_task_pwd_add_ap' + +- name: reset connection to test other scenarios + meta: reset_connection + +# Precedence test +- name: Delete Tenant with password and private key in the task (private_key takes precedence) + cisco.aci.aci_tenant: + <<: *aci_info + private_key: "{{ lookup('file', 'pki/admin.key') }}" + tenant: ansible_test + state: absent + register: op1_task_private_key_delete_tenant + +- name: Add Tenant with password and private key in the task (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + <<: *aci_info + private_key: "{{ lookup('file', 'pki/admin.key') }}" + tenant: ansible_test + state: present + register: op1_task_private_key_add_tenant + +- name: Delete Tenant with only password in the task (password in the task takes precedence) + cisco.aci.aci_tenant: + <<: *aci_info + tenant: ansible_test + state: absent + register: op2_task_pwd_delete_tenant + +- name: Add Tenant with only password in the task (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + <<: *aci_info + tenant: ansible_test + state: present + register: op2_task_pwd_add_tenant + +- name: Delete Tenant with password and session key in the inventory (session_key takes precedence) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: absent + register: op3_inventory_session_key_delete_tenant + +- name: Add Tenant with password and session key in the inventory (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: present + register: op3_inventory_session_key_add_tenant + +- name: Remove session key used in the inventory + ansible.builtin.set_fact: + ansible_httpapi_session_key: + +- name: Delete Tenant with with only password in the inventory (check for authentication with the password in the inventory) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: absent + register: op4_inventory_pwd_delete_tenant + +- name: Add Tenant with with only password in the inventory (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: present + register: op4_inventory_pwd_add_tenant + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op1_flattened_task_private_key_delete_tenant: "{{ op1_task_private_key_delete_tenant.httpapi_logs | flatten }}" + op1_flattened_task_private_key_add_tenant: "{{ op1_task_private_key_add_tenant.httpapi_logs | flatten }}" + op2_flattened_task_pwd_delete_tenant: "{{ op2_task_pwd_delete_tenant.httpapi_logs | flatten }}" + op2_flattened_task_pwd_add_tenant: "{{ op2_task_pwd_add_tenant.httpapi_logs | flatten }}" + op3_flattened_inventory_session_key_delete_tenant: "{{ op3_inventory_session_key_delete_tenant.httpapi_logs | flatten }}" + op3_flattened_inventory_session_key_add_tenant: "{{ op3_inventory_session_key_add_tenant.httpapi_logs | flatten }}" + op4_flattened_inventory_pwd_delete_tenant: "{{ op4_inventory_pwd_delete_tenant.httpapi_logs | flatten }}" + op4_flattened_inventory_pwd_add_tenant: "{{ op4_inventory_pwd_add_tenant.httpapi_logs | flatten }}" + +- name: Verify Precedence + assert: + that: + - '"Provided Hosts: [''{{ aci_hostname }}'']" in op1_flattened_task_private_key_delete_tenant' + - '"Initializing operation on {{ aci_hostname }}" in op1_flattened_task_private_key_delete_tenant' + - op1_task_private_key_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op1_flattened_task_private_key_add_tenant' + - op2_task_pwd_delete_tenant is changed + - '"Re-setting connection due to change from private/session key authentication to password authentication" in op2_flattened_task_pwd_delete_tenant' + - '"Establishing login for {{ aci_username }} to {{ aci_hostname }}" in op2_flattened_task_pwd_delete_tenant' + - '"Connection to {{ aci_hostname }} was successful" in op2_flattened_task_pwd_delete_tenant' + - op2_task_pwd_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op2_flattened_task_pwd_add_tenant' + - op3_inventory_session_key_delete_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op3_flattened_inventory_session_key_delete_tenant' + - op3_inventory_session_key_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op3_flattened_inventory_session_key_add_tenant' + - op4_inventory_pwd_delete_tenant is changed + - '"Re-setting connection due to change from private/session key authentication to password authentication" in op4_flattened_inventory_pwd_delete_tenant' + - '"Establishing login for {{ aci_username }} to {{ aci_hostname }}" in op4_flattened_inventory_pwd_delete_tenant' + - '"Connection to {{ aci_hostname }} was successful" in op4_flattened_inventory_pwd_delete_tenant' + - op4_inventory_pwd_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op4_flattened_inventory_pwd_add_tenant' + +- name: reset connection to test other scenarios + meta: reset_connection + +# Switching of hosts test with the password in the task +- name: Delete Tenant with only password in the task (Check for successful operation on the last host) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ last_host_active }}" + tenant: ansible_test + state: absent + register: op5_task_pwd_delete_tenant + +- name: Add Tenant with only password in the task (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ last_host_active }}" + tenant: ansible_test + state: present + register: op5_task_pwd_add_tenant + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op5_flattened_task_pwd_delete_tenant: "{{ op5_task_pwd_delete_tenant.httpapi_logs | flatten }}" + op5_flattened_task_pwd_add_tenant: "{{ op5_task_pwd_add_tenant.httpapi_logs | flatten }}" + +- name: Verify switching of hosts with the password in the task + assert: + that: + - op5_task_pwd_delete_tenant is changed + - op5_flattened_task_pwd_delete_tenant | regex_search('Switching host from [0-9]+(?:\.[0-9]+){3} to {{ aci_hostname }}') is not none + - '"Establishing login for {{ aci_username }} to {{ aci_hostname }}" in op5_flattened_task_pwd_delete_tenant' + - '"Connection to {{ aci_hostname }} was successful" in op5_flattened_task_pwd_delete_tenant' + - op5_task_pwd_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op5_flattened_task_pwd_add_tenant' + +# Continuing on the connected host test with the password in the task +- name: Delete Tenant with only password in the task + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ second_host_active }}" + tenant: ansible_test + state: absent + register: op6_task_pwd_delete_tenant + +- name: Add Tenant with only password in the task (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ second_host_active }}" + tenant: ansible_test + state: present + register: op6_task_pwd_add_tenant + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op6_flattened_task_pwd_delete_tenant: "{{ op6_task_pwd_delete_tenant.httpapi_logs | flatten }}" + op6_flattened_task_pwd_add_tenant: "{{ op6_task_pwd_add_tenant.httpapi_logs | flatten }}" + +- name: Verify continuation of the operations on the connected host with the password in the task + assert: + that: + - op6_task_pwd_delete_tenant is changed + - '"Connected host {{ aci_hostname }} found in the provided hosts. Continuing with it." in op6_flattened_task_pwd_delete_tenant' + - op6_task_pwd_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op6_flattened_task_pwd_add_tenant' + +# Change of hosts and no hosts active test with the password in the task +- name: Delete Tenant with only password in the task (Check for failed operation) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ no_host_active }}" + tenant: ansible_test + state: absent + register: op7_task_pwd_delete_tenant + ignore_errors: True + +- name: Add Tenant with only password in the task (Check the reset of the provided list of hosts) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ second_host_active }}" + tenant: ansible_test + state: present + register: op7_task_pwd_add_tenant + +- name: Verify failure when no hosts are active + assert: + that: + - op7_task_pwd_delete_tenant.error.text | regex_search('No hosts left in the cluster to continue operation! Error on final host [0-9]+(?:\.[0-9]+){3}') is not none + - op7_task_pwd_add_tenant is not changed + +# Switching of hosts test with the the inventory password +- name: Set list of hosts in the inventory with the last host as active + ansible.builtin.set_fact: + ansible_host: "{{aci_hostname|generate_random_ips(5,5)}}" + +- name: Delete Tenant with only password in the inventory (Check for successful operation on the last host) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: absent + register: op8_inventory_pwd_delete_tenant + +- name: Add Tenant with only password in the inventory (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: present + register: op8_inventory_pwd_add_tenant + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op8_flattened_inventory_pwd_delete_tenant: "{{ op8_inventory_pwd_delete_tenant.httpapi_logs | flatten }}" + op8_flattened_inventory_pwd_add_tenant: "{{ op8_inventory_pwd_add_tenant.httpapi_logs | flatten }}" + +- name: Verify switching of hosts with the password in the inventory + assert: + that: + - op8_inventory_pwd_delete_tenant is changed + - op8_flattened_inventory_pwd_delete_tenant | regex_search('Switching host from [0-9]+(?:\.[0-9]+){3} to {{ aci_hostname }}') is not none + - '"Establishing login for {{ aci_username }} to {{ aci_hostname }}" in op8_flattened_inventory_pwd_delete_tenant' + - '"Connection to {{ aci_hostname }} was successful" in op8_flattened_inventory_pwd_delete_tenant' + - op8_inventory_pwd_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op8_flattened_inventory_pwd_add_tenant' + +# Continuing on the connected host test with the inventory password +- name: Set list of hosts in the inventory with the second host as active + ansible.builtin.set_fact: + ansible_host: "{{aci_hostname|generate_random_ips(2,5)}}" + +- name: Delete Tenant with only password in the inventory (Check for execution on the previously connected host) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: absent + register: op9_inventory_pwd_delete_tenant + +- name: Add Tenant with only password in the inventory (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: present + register: op9_inventory_pwd_add_tenant + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op9_flattened_inventory_pwd_delete_tenant: "{{ op9_inventory_pwd_delete_tenant.httpapi_logs | flatten }}" + op9_flattened_inventory_pwd_add_tenant: "{{ op9_inventory_pwd_add_tenant.httpapi_logs | flatten }}" + +- name: Verify switching of hosts with the password in the inventory + assert: + that: + - op9_inventory_pwd_delete_tenant is changed + - op9_flattened_inventory_pwd_delete_tenant | regex_search('Switching host from [0-9]+(?:\.[0-9]+){3} to {{ aci_hostname }}') is not none + - '"Establishing login for {{ aci_username }} to {{ aci_hostname }}" in op9_flattened_inventory_pwd_delete_tenant' + - '"Connection to {{ aci_hostname }} was successful" in op9_flattened_inventory_pwd_delete_tenant' + - op9_inventory_pwd_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op9_flattened_inventory_pwd_add_tenant' + +# Change of hosts and no hosts active test with the inventory password +- name: Set list of hosts in the inventory with no active hosts + ansible.builtin.set_fact: + ansible_host: "{{aci_hostname|generate_random_ips(0,5)}}" + +- name: Delete Tenant with only password in the inventory (Check for failed operation) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: absent + register: op10_inventory_pwd_delete_tenant + ignore_errors: True + +- name: Verify failure when no hosts are active in the inventory + assert: + that: + - op10_inventory_pwd_delete_tenant.error.text | regex_search('No hosts left in the cluster to continue operation! Error on final host [0-9]+(?:\.[0-9]+){3}') is not none + +# Switching of hosts test with the private key in the task +- name: Delete Tenant with only private key in the task (Check for successful operation on the last host) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ last_host_active }}" + private_key: "{{ lookup('file', 'pki/admin.key') }}" + tenant: ansible_test + state: absent + register: op11_task_private_key_delete_tenant + +- name: Add Tenant with only private key in the task (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ last_host_active }}" + private_key: "{{ lookup('file', 'pki/admin.key') }}" + tenant: ansible_test + state: present + register: op11_task_private_key_add_tenant + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op11_flattened_task_private_key_delete_tenant: "{{ op11_task_private_key_delete_tenant.httpapi_logs | flatten }}" + op11_flattened_task_private_key_add_tenant: "{{ op11_task_private_key_add_tenant.httpapi_logs | flatten }}" + +- name: Verify switching of hosts with the private key in the task + assert: + that: + - op11_task_private_key_delete_tenant is changed + - op11_flattened_task_private_key_delete_tenant | regex_search('Switching host from [0-9]+(?:\.[0-9]+){3} to {{ aci_hostname }}') is not none + - '"Establishing login for {{ aci_username }} to {{ aci_hostname }}" not in op11_flattened_task_private_key_delete_tenant' + - '"Connection to {{ aci_hostname }} was successful" not in op11_flattened_task_private_key_delete_tenant' + - op11_task_private_key_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op11_flattened_task_private_key_add_tenant' + +# Continuing on the connected host test with the private key in the task +- name: Delete Tenant with only private key in the task (Check for execution on the previously connected host) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ second_host_active }}" + private_key: "{{ lookup('file', 'pki/admin.key') }}" + tenant: ansible_test + state: absent + register: op12_task_private_key_delete_tenant + +- name: Add Tenant with only private key in the task (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ second_host_active }}" + private_key: "{{ lookup('file', 'pki/admin.key') }}" + tenant: ansible_test + state: present + register: op12_task_private_key_add_tenant + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op12_flattened_task_private_key_delete_tenant: "{{ op12_task_private_key_delete_tenant.httpapi_logs | flatten }}" + op12_flattened_task_private_key_add_tenant: "{{ op12_task_private_key_add_tenant.httpapi_logs | flatten }}" + +- name: Verify continuation of the operations on the connected host with the password in the task + assert: + that: + - op12_task_private_key_delete_tenant is changed + - '"Connected host {{ aci_hostname }} found in the provided hosts. Continuing with it." in op12_flattened_task_private_key_delete_tenant' + - op12_task_private_key_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op12_flattened_task_private_key_add_tenant' + +# Change of hosts and no hosts active test with the private key in the task +- name: Delete Tenant with only private key in the task (Check for failed operation) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ no_host_active }}" + private_key: "{{ lookup('file', 'pki/admin.key') }}" + tenant: ansible_test + state: absent + register: op13_task_private_key_delete_tenant + ignore_errors: True + +- name: Add Tenant with only private key in the task (Check the reset of the provided list of hosts) + cisco.aci.aci_tenant: + <<: *aci_info + host: "{{ second_host_active }}" + private_key: "{{ lookup('file', 'pki/admin.key') }}" + tenant: ansible_test + state: present + register: op13_task_private_key_add_tenant + +- name: Verify failure when no hosts are active in the task + assert: + that: + - op13_task_private_key_delete_tenant.error.text | regex_search('No hosts left in the cluster to continue operation! Error on final host [0-9]+(?:\.[0-9]+){3}') is not none + - op13_task_private_key_add_tenant is not changed + +# Switching of hosts test with the the inventory session key +- name: Set list of hosts in the inventory with the last host as active + ansible.builtin.set_fact: + ansible_host: "{{aci_hostname|generate_random_ips(5,5)}}" + ansible_httpapi_session_key: {'admin': "{{ lookup('file', 'pki/admin.key') }}"} + +- name: Delete Tenant with session key in the inventory (Check for successful operation on the last host) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: absent + register: op14_inventory_session_key_delete_tenant + +- name: Add Tenant with session key in the inventory (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: present + register: op14_inventory_session_key_add_tenant + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op14_flattened_inventory_session_key_delete_tenant: "{{ op14_inventory_session_key_delete_tenant.httpapi_logs | flatten }}" + op14_flattened_inventory_session_key_add_tenant: "{{ op14_inventory_session_key_add_tenant.httpapi_logs | flatten }}" + +- name: Verify switching of hosts with the session key in the inventory + assert: + that: + - op14_inventory_session_key_delete_tenant is changed + - op14_flattened_inventory_session_key_delete_tenant | regex_search('Switching host from [0-9]+(?:\.[0-9]+){3} to {{ aci_hostname }}') is not none + - '"Establishing login for {{ aci_username }} to {{ aci_hostname }}" not in op14_flattened_inventory_session_key_delete_tenant' + - '"Connection to {{ aci_hostname }} was successful" not in op14_flattened_inventory_session_key_delete_tenant' + - op14_inventory_session_key_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op14_flattened_inventory_session_key_add_tenant' + +# Continuing on the connected host test with the inventory session key +- name: Set list of hosts in the inventory with the second host as active + ansible.builtin.set_fact: + ansible_host: "{{aci_hostname|generate_random_ips(2,5)}}" + +- name: Delete Tenant with session key in the inventory (Check for execution on the previously connected host) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: absent + register: op15_inventory_session_key_delete_tenant + +- name: Add Tenant with session key in the inventory (Check for execution on the provided aci_hostname with no attempts at re-connection) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: present + register: op15_inventory_session_key_add_tenant + +- name: Flatten the registered instances + ansible.builtin.set_fact: + op15_flattened_inventory_session_key_delete_tenant: "{{ op15_inventory_session_key_delete_tenant.httpapi_logs | flatten }}" + op15_flattened_inventory_session_key_add_tenant: "{{ op15_inventory_session_key_add_tenant.httpapi_logs | flatten }}" + +- name: Verify switching of hosts with the session key in the inventory + assert: + that: + - op15_inventory_session_key_delete_tenant is changed + - op15_flattened_inventory_session_key_delete_tenant | regex_search('Switching host from [0-9]+(?:\.[0-9]+){3} to {{ aci_hostname }}') is not none + - '"Establishing login for {{ aci_username }} to {{ aci_hostname }}" not in op15_flattened_inventory_session_key_delete_tenant' + - '"Connection to {{ aci_hostname }} was successful" not in op15_flattened_inventory_session_key_delete_tenant' + - op15_inventory_session_key_add_tenant is changed + - '"Connection to {{ aci_hostname }} was successful" not in op15_flattened_inventory_session_key_add_tenant' + +# Change of hosts and no hosts active test with the inventory session key +- name: Set list of hosts in the inventory with no active hosts + ansible.builtin.set_fact: + ansible_host: "{{aci_hostname|generate_random_ips(0,5)}}" + +- name: Delete Tenant with session key in the inventory (Check for failed operation) + cisco.aci.aci_tenant: + output_level: debug + tenant: ansible_test + state: absent + register: op16_inventory_session_key_delete_tenant + ignore_errors: True + +- name: Verify failure when no hosts are active in the inventory + assert: + that: + - op16_inventory_session_key_delete_tenant.error.text | regex_search('No hosts left in the cluster to continue operation! Error on final host [0-9]+(?:\.[0-9]+){3}') is not none + +# Clean up Environment +- name: Delete a tenant using an XML string + cisco.aci.aci_rest: + <<: *aci_info + path: api/mo/uni/tn-[Sales].xml + method: delete + content: '' + +- name: Remove user certificate + cisco.aci.aci_aaa_user_certificate: + <<: *aci_info + aaa_user: "{{ aci_username }}" + name: admin + certificate: "{{ lookup('file', 'pki/admin.crt') }}" + state: absent + +- name: Delete Tenant + cisco.aci.aci_tenant: + <<: *aci_info + port: 443 + tenant: ansible_test + state: absent + +- name: Cleanup facts to continue operation using the inventory file + ansible.builtin.set_fact: + ansible_host: "{{ aci_hostname }}" + ansible_connection: "{{ old_connection }}" + ansible_httpapi_session_key: diff --git a/tests/integration/targets/aci_tenant/tasks/main.yml b/tests/integration/targets/aci_tenant/tasks/main.yml index f3eba556e..b8a7a310f 100644 --- a/tests/integration/targets/aci_tenant/tasks/main.yml +++ b/tests/integration/targets/aci_tenant/tasks/main.yml @@ -9,23 +9,26 @@ msg: 'Please define the following variables: aci_hostname, aci_username and aci_password.' when: aci_hostname is not defined or aci_username is not defined or aci_password is not defined +- include_tasks: httpapi_connection.yml + tags: httpapi_connection + - name: Delete old log files to clean test directory file: path: "{{ item }}" state: absent with_items: - - "{{ ansible_host }}_cm_add_tenant.json" - - "{{ ansible_host }}_nm_add_tenant.json" - - "{{ ansible_host }}_cm_add_tenant_again.json" - - "{{ ansible_host }}_nm_add_tenant_again.json" - - "{{ ansible_host }}_cm_add_tenant_descr.json" - - "{{ ansible_host }}_nm_add_tenant_descr.json" - - "{{ ansible_host }}_cm_add_tenant_descr_again.json" - - "{{ ansible_host }}_nm_add_tenant_descr_again.json" - - "{{ ansible_host }}_cm_remove_tenant.json" - - "{{ ansible_host }}_nm_remove_tenant.json" - - "{{ ansible_host }}_cm_remove_tenant_again.json" - - "{{ ansible_host }}_nm_remove_tenant_again.json" + - "{{ aci_hostname }}_cm_add_tenant.json" + - "{{ aci_hostname }}_nm_add_tenant.json" + - "{{ aci_hostname }}_cm_add_tenant_again.json" + - "{{ aci_hostname }}_nm_add_tenant_again.json" + - "{{ aci_hostname }}_cm_add_tenant_descr.json" + - "{{ aci_hostname }}_nm_add_tenant_descr.json" + - "{{ aci_hostname }}_cm_add_tenant_descr_again.json" + - "{{ aci_hostname }}_nm_add_tenant_descr_again.json" + - "{{ aci_hostname }}_cm_remove_tenant.json" + - "{{ aci_hostname }}_nm_remove_tenant.json" + - "{{ aci_hostname }}_cm_remove_tenant_again.json" + - "{{ aci_hostname }}_nm_remove_tenant_again.json" # CLEAN ENVIRONMENT - name: Remove tenant @@ -40,7 +43,6 @@ tenant: ansible_test state: absent - # ADD TENANT - name: Add tenant (check_mode) cisco.aci.aci_tenant: &tenant_present @@ -56,44 +58,44 @@ annotation: ansible_test owner_key: ansible_key owner_tag: ansible_tag - output_path: "{{ ansible_host }}_cm_add_tenant.json" + output_path: "{{ aci_hostname }}_cm_add_tenant.json" check_mode: true register: cm_add_tenant - name: Dump content of files debug: - msg: "{{ lookup('file', ansible_host +'_cm_add_tenant.json')}}" + msg: "{{ lookup('file', aci_hostname +'_cm_add_tenant.json')}}" - name: Add tenant (normal mode) cisco.aci.aci_tenant: <<: *tenant_present - output_path: "{{ ansible_host }}_nm_add_tenant.json" + output_path: "{{ aci_hostname }}_nm_add_tenant.json" register: nm_add_tenant - name: Add tenant again (check_mode) cisco.aci.aci_tenant: <<: *tenant_present - output_path: "{{ ansible_host }}_cm_add_tenant_again.json" + output_path: "{{ aci_hostname }}_cm_add_tenant_again.json" check_mode: true register: cm_add_tenant_again - name: Add tenant again (normal mode) cisco.aci.aci_tenant: <<: *tenant_present - output_path: "{{ ansible_host }}_nm_add_tenant_again.json" + output_path: "{{ aci_hostname }}_nm_add_tenant_again.json" register: nm_add_tenant_again - name: Dump content of files debug: - msg: "{{ lookup('file', ansible_host + '_cm_add_tenant.json')}}" + msg: "{{ lookup('file', aci_hostname + '_cm_add_tenant.json')}}" - name: Store file content on variables for create object set_fact: - fc_cm_add_tenant: "{{ lookup('file', ansible_host + '_cm_add_tenant.json') | from_json }}" - fc_nm_add_tenant: "{{ lookup('file', ansible_host + '_nm_add_tenant.json') | from_json }}" - fc_cm_add_tenant_again: "{{ lookup('file', ansible_host + '_cm_add_tenant_again.json') }}" - fc_nm_add_tenant_again: "{{ lookup('file', ansible_host + '_nm_add_tenant_again.json') }}" + fc_cm_add_tenant: "{{ lookup('file', aci_hostname + '_cm_add_tenant.json') | from_json }}" + fc_nm_add_tenant: "{{ lookup('file', aci_hostname + '_nm_add_tenant.json') | from_json }}" + fc_cm_add_tenant_again: "{{ lookup('file', aci_hostname + '_cm_add_tenant_again.json') }}" + fc_nm_add_tenant_again: "{{ lookup('file', aci_hostname + '_nm_add_tenant_again.json') }}" - name: Log file content verification for create object assert: @@ -119,7 +121,6 @@ - nm_add_tenant.current[0].fvTenant.attributes.ownerTag == 'ansible_tag' - cm_add_tenant.proposed.fvTenant.attributes.ownerTag == 'ansible_tag' - # CHANGE TENANT - name: Change description and annotation/owner_tag/owner_key of tenant (check_mode) cisco.aci.aci_tenant: @@ -128,7 +129,7 @@ annotation: ansible_test_changed owner_key: ansible_key_changed owner_tag: ansible_tag_changed - output_path: "{{ ansible_host }}_cm_add_tenant_descr.json" + output_path: "{{ aci_hostname }}_cm_add_tenant_descr.json" check_mode: true register: cm_add_tenant_descr @@ -139,7 +140,7 @@ annotation: ansible_test_changed owner_key: ansible_key_changed owner_tag: ansible_tag_changed - output_path: "{{ ansible_host }}_nm_add_tenant_descr.json" + output_path: "{{ aci_hostname }}_nm_add_tenant_descr.json" register: nm_add_tenant_descr - name: Change description and annotation/owner_tag/owner_key of tenant again (check_mode) @@ -149,7 +150,7 @@ annotation: ansible_test_changed owner_key: ansible_key_changed owner_tag: ansible_tag_changed - output_path: "{{ ansible_host }}_cm_add_tenant_descr_again.json" + output_path: "{{ aci_hostname }}_cm_add_tenant_descr_again.json" check_mode: true register: cm_add_tenant_descr_again @@ -160,15 +161,15 @@ annotation: ansible_test_changed owner_key: ansible_key_changed owner_tag: ansible_tag_changed - output_path: "{{ ansible_host }}_nm_add_tenant_descr_again.json" + output_path: "{{ aci_hostname }}_nm_add_tenant_descr_again.json" register: nm_add_tenant_descr_again - name: Store file content on variables for update object set_fact: - fc_cm_add_tenant_descr: "{{ lookup('file', ansible_host + '_cm_add_tenant_descr.json') | from_json }}" - fc_nm_add_tenant_descr: "{{ lookup('file', ansible_host + '_nm_add_tenant_descr.json') | from_json }}" - fc_cm_add_tenant_descr_again: "{{ lookup('file', ansible_host + '_cm_add_tenant_descr_again.json') }}" - fc_nm_add_tenant_descr_again: "{{ lookup('file', ansible_host + '_nm_add_tenant_descr_again.json') }}" + fc_cm_add_tenant_descr: "{{ lookup('file', aci_hostname + '_cm_add_tenant_descr.json') | from_json }}" + fc_nm_add_tenant_descr: "{{ lookup('file', aci_hostname + '_nm_add_tenant_descr.json') | from_json }}" + fc_cm_add_tenant_descr_again: "{{ lookup('file', aci_hostname + '_cm_add_tenant_descr_again.json') }}" + fc_nm_add_tenant_descr_again: "{{ lookup('file', aci_hostname + '_nm_add_tenant_descr_again.json') }}" - name: Log file content verification for update object assert: @@ -295,35 +296,35 @@ - name: Remove tenant (check_mode) cisco.aci.aci_tenant: <<: *tenant_absent - output_path: "{{ ansible_host }}_cm_remove_tenant.json" + output_path: "{{ aci_hostname }}_cm_remove_tenant.json" check_mode: true register: cm_remove_tenant - name: Remove tenant (normal mode) cisco.aci.aci_tenant: <<: *tenant_absent - output_path: "{{ ansible_host }}_nm_remove_tenant.json" + output_path: "{{ aci_hostname }}_nm_remove_tenant.json" register: nm_remove_tenant - name: Remove tenant again (check_mode) cisco.aci.aci_tenant: <<: *tenant_absent - output_path: "{{ ansible_host }}_cm_remove_tenant_again.json" + output_path: "{{ aci_hostname }}_cm_remove_tenant_again.json" check_mode: true register: cm_remove_tenant_again - name: Remove tenant again (normal mode) cisco.aci.aci_tenant: <<: *tenant_absent - output_path: "{{ ansible_host }}_nm_remove_tenant_again.json" + output_path: "{{ aci_hostname }}_nm_remove_tenant_again.json" register: nm_remove_tenant_again - name: Store file content on variables for delete object set_fact: - fc_cm_remove_tenant: "{{ lookup('file', ansible_host + '_cm_remove_tenant.json') | from_json }}" - fc_nm_remove_tenant: "{{ lookup('file', ansible_host + '_nm_remove_tenant.json') | from_json }}" - fc_cm_remove_tenant_again: "{{ lookup('file', ansible_host + '_cm_remove_tenant_again.json') }}" - fc_nm_remove_tenant_again: "{{ lookup('file', ansible_host + '_nm_remove_tenant_again.json') }}" + fc_cm_remove_tenant: "{{ lookup('file', aci_hostname + '_cm_remove_tenant.json') | from_json }}" + fc_nm_remove_tenant: "{{ lookup('file', aci_hostname + '_nm_remove_tenant.json') | from_json }}" + fc_cm_remove_tenant_again: "{{ lookup('file', aci_hostname + '_cm_remove_tenant_again.json') }}" + fc_nm_remove_tenant_again: "{{ lookup('file', aci_hostname + '_nm_remove_tenant_again.json') }}" - name: Log file content verification for delete object assert: diff --git a/tests/integration/targets/aci_tenant_span_dst_group/tasks/main.yml b/tests/integration/targets/aci_tenant_span_dst_group/tasks/main.yml index d7d807ad8..543a25cd5 100644 --- a/tests/integration/targets/aci_tenant_span_dst_group/tasks/main.yml +++ b/tests/integration/targets/aci_tenant_span_dst_group/tasks/main.yml @@ -26,216 +26,230 @@ tenant: ansible_tenant state: absent -- name: Add a new tenant - aci_tenant: - <<: *aci_info - tenant: ansible_tenant - description: Ansible tenant - state: present - -- name: Add span ansible_group - aci_tenant_span_dst_group: - <<: *aci_info - destination_group: ansible_group - description: Test span - destination_ip: 10.0.0.1 - source_ip: 10.0.2.1 - tenant: ansible_tenant - destination_epg: - tenant: Test1 - ap: ap1 - epg: ep1 - version_enforced: false - span_version: version_1 - ttl: 2 - mtu: 1500 - flow_id: 1 - dscp: "CS1" - state: present - register: add_span1 - -- name: Verify add span - assert: - that: - - add_span1 is changed - - add_span1.current.0.spanDestGrp.attributes.name == "ansible_group" - - add_span1.current.0.spanDestGrp.attributes.descr == "Test span" - - add_span1.current.0.spanDestGrp.attributes.dn == "uni/tn-ansible_tenant/destgrp-ansible_group" - - add_span1.current.0.spanDestGrp.children.0.spanDest.attributes.name == "ansible_group" - - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.dscp == "CS1" - - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.srcIpPrefix == "10.0.2.1" - - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ip == "10.0.0.1" - - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ver == "ver1" - - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.mtu == "1500" - - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.flowId == "1" - - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.verEnforced == "no" - - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ttl == "2" - - add_span1.current.0.spanDestGrp.attributes.annotation == 'orchestrator:ansible' - -- name: Add span ansible_group again - aci_tenant_span_dst_group: - <<: *aci_info - destination_group: ansible_group - description: Test span - destination_ip: 10.0.0.1 - source_ip: 10.0.2.1 - tenant: ansible_tenant - destination_epg: - tenant: Test1 - ap: ap1 - epg: ep1 - version_enforced: false - span_version: version_1 - ttl: 2 - mtu: 1500 - flow_id: 1 - dscp: "CS1" - state: present - register: add_span1_again - -- name: Verify add span again - assert: - that: - - add_span1_again is not changed - -- name: Change span ansible_group's src ip - aci_tenant_span_dst_group: - <<: *aci_info - destination_group: ansible_group2 - description: Test span - destination_ip: 10.0.0.2 - source_ip: 10.0.2.1 - tenant: ansible_tenant - destination_epg: - tenant: Test1 - ap: ap1 - epg: ep1 - version_enforced: false - span_version: version_1 - ttl: 2 - mtu: 1500 - flow_id: 1 - dscp: CS1 - state: present - register: change_span1_ip - -- name: Change span ansible_group's dscp - aci_tenant_span_dst_group: - <<: *aci_info - destination_group: ansible_group2 - description: Test span - destination_ip: 10.0.0.2 - source_ip: 10.0.2.1 - tenant: ansible_tenant - destination_epg: - tenant: Test1 - ap: ap1 - epg: ep1 - version_enforced: false - span_version: version_1 - ttl: 2 - mtu: 1500 - flow_id: 1 - dscp: VA - state: present - register: change_span1_dscp - -- name: Verify changes in span - assert: - that: - - change_span1_ip.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ip == "10.0.0.2" - - change_span1_dscp.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.dscp == "VA" - -- name: Add span ansible_group2 - aci_tenant_span_dst_group: - <<: *aci_info - destination_group: ansible_group2 - description: Test span - destination_ip: 10.0.0.1 - source_ip: 10.0.2.1 - tenant: ansible_tenant - destination_epg: - tenant: Test1 - ap: ap1 - epg: ep1 - version_enforced: true - span_version: version_2 - ttl: 2 - mtu: 1500 - flow_id: 1 - dscp: CS1 - state: present - register: add_span2 - -- name: Verify addition of second span - assert: - that: - - add_span2 is changed - - add_span2.current.0.spanDestGrp.attributes.name == "ansible_group2" - - add_span2.current.0.spanDestGrp.attributes.descr == "Test span" - - add_span2.current.0.spanDestGrp.attributes.dn == "uni/tn-ansible_tenant/destgrp-ansible_group2" - - add_span2.current.0.spanDestGrp.children.0.spanDest.attributes.name == "ansible_group2" - - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.dscp == "CS1" - - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.srcIpPrefix == "10.0.2.1" - - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ip == "10.0.0.1" - - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ver == "ver2" - - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.mtu == "1500" - - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.flowId == "1" - - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.verEnforced == "yes" - - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ttl == "2" - -- name: Query span ansible_group - aci_tenant_span_dst_group: - <<: *aci_info - tenant: ansible_tenant - destination_group: ansible_group - state: query - register: query_span_ansible_group - -- name: Query all span dest groups - aci_tenant_span_dst_group: - <<: *aci_info - state: query - register: query_all_span - -- name: Verify Query of span - assert: - that: - - query_span_ansible_group is not changed - - query_span_ansible_group.current.0.spanDestGrp.attributes.name == "ansible_group" - - query_span_ansible_group.current.0.spanDestGrp.attributes.descr == "Test span" - - query_span_ansible_group.current.0.spanDestGrp.attributes.dn == "uni/tn-ansible_tenant/destgrp-ansible_group" - - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.attributes.name == "ansible_group" - - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.dscp == "CS1" - - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.srcIpPrefix == "10.0.2.1" - - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ip == "10.0.0.1" - - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.mtu == "1500" - - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.flowId == "1" - - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.verEnforced == "no" - - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ttl == "2" - - query_all_span is not changed - - query_all_span | length >= 2 - -- name: Remove span ansible_group - aci_tenant_span_dst_group: - <<: *aci_info - tenant: ansible_tenant - destination_group: ansible_group - state: absent - register: remove_span1 - -- name: Remove span ansible_group2 - aci_tenant_span_dst_group: - <<: *aci_info - tenant: ansible_tenant - destination_group: ansible_group2 - state: absent - register: remove_span2 - -- name: Verify Remove of span - assert: - that: - - remove_span1 is changed - - remove_span1.current == [] - - remove_span2 is changed - - remove_span2.current == [] +- name: Verify Cloud and Non-Cloud Sites in use. + include_tasks: ../../../../../../integration/targets/aci_cloud_provider/tasks/main.yml + +- name: Execute tasks only for non-cloud sites + when: query_cloud.current == [] # This condition will execute only non-cloud sites + block: # block specifies execution of tasks within, based on conditions + + - name: Add a new tenant + aci_tenant: + <<: *aci_info + tenant: ansible_tenant + description: Ansible tenant + state: present + + - name: Add span ansible_group + aci_tenant_span_dst_group: + <<: *aci_info + destination_group: ansible_group + description: Test span + destination_ip: 10.0.0.1 + source_ip: 10.0.2.1 + tenant: ansible_tenant + destination_epg: + tenant: Test1 + ap: ap1 + epg: ep1 + version_enforced: false + span_version: version_1 + ttl: 2 + mtu: 1500 + flow_id: 1 + dscp: "CS1" + state: present + register: add_span1 + + - name: Verify add span + assert: + that: + - add_span1 is changed + - add_span1.current.0.spanDestGrp.attributes.name == "ansible_group" + - add_span1.current.0.spanDestGrp.attributes.descr == "Test span" + - add_span1.current.0.spanDestGrp.attributes.dn == "uni/tn-ansible_tenant/destgrp-ansible_group" + - add_span1.current.0.spanDestGrp.children.0.spanDest.attributes.name == "ansible_group" + - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.dscp == "CS1" + - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.srcIpPrefix == "10.0.2.1" + - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ip == "10.0.0.1" + - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ver == "ver1" + - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.mtu == "1500" + - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.flowId == "1" + - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.verEnforced == "no" + - add_span1.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ttl == "2" + - add_span1.current.0.spanDestGrp.attributes.annotation == 'orchestrator:ansible' + + - name: Add span ansible_group again + aci_tenant_span_dst_group: + <<: *aci_info + destination_group: ansible_group + description: Test span + destination_ip: 10.0.0.1 + source_ip: 10.0.2.1 + tenant: ansible_tenant + destination_epg: + tenant: Test1 + ap: ap1 + epg: ep1 + version_enforced: false + span_version: version_1 + ttl: 2 + mtu: 1500 + flow_id: 1 + dscp: "CS1" + state: present + register: add_span1_again + + - name: Verify add span again + assert: + that: + - add_span1_again is not changed + + - name: Change span ansible_group's src ip + aci_tenant_span_dst_group: + <<: *aci_info + destination_group: ansible_group2 + description: Test span + destination_ip: 10.0.0.2 + source_ip: 10.0.2.1 + tenant: ansible_tenant + destination_epg: + tenant: Test1 + ap: ap1 + epg: ep1 + version_enforced: false + span_version: version_1 + ttl: 2 + mtu: 1500 + flow_id: 1 + dscp: CS1 + state: present + register: change_span1_ip + + - name: Change span ansible_group's dscp + aci_tenant_span_dst_group: + <<: *aci_info + destination_group: ansible_group2 + description: Test span + destination_ip: 10.0.0.2 + source_ip: 10.0.2.1 + tenant: ansible_tenant + destination_epg: + tenant: Test1 + ap: ap1 + epg: ep1 + version_enforced: false + span_version: version_1 + ttl: 2 + mtu: 1500 + flow_id: 1 + dscp: VA + state: present + register: change_span1_dscp + + - name: Verify changes in span + assert: + that: + - change_span1_ip.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ip == "10.0.0.2" + - change_span1_dscp.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.dscp == "VA" + + - name: Add span ansible_group2 + aci_tenant_span_dst_group: + <<: *aci_info + destination_group: ansible_group2 + description: Test span + destination_ip: 10.0.0.1 + source_ip: 10.0.2.1 + tenant: ansible_tenant + destination_epg: + tenant: Test1 + ap: ap1 + epg: ep1 + version_enforced: true + span_version: version_2 + ttl: 2 + mtu: 1500 + flow_id: 1 + dscp: CS1 + state: present + register: add_span2 + + - name: Verify addition of second span + assert: + that: + - add_span2 is changed + - add_span2.current.0.spanDestGrp.attributes.name == "ansible_group2" + - add_span2.current.0.spanDestGrp.attributes.descr == "Test span" + - add_span2.current.0.spanDestGrp.attributes.dn == "uni/tn-ansible_tenant/destgrp-ansible_group2" + - add_span2.current.0.spanDestGrp.children.0.spanDest.attributes.name == "ansible_group2" + - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.dscp == "CS1" + - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.srcIpPrefix == "10.0.2.1" + - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ip == "10.0.0.1" + - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ver == "ver2" + - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.mtu == "1500" + - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.flowId == "1" + - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.verEnforced == "yes" + - add_span2.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ttl == "2" + + - name: Query span ansible_group + aci_tenant_span_dst_group: + <<: *aci_info + tenant: ansible_tenant + destination_group: ansible_group + state: query + register: query_span_ansible_group + + - name: Query all span dest groups + aci_tenant_span_dst_group: + <<: *aci_info + state: query + register: query_all_span + + - name: Verify Query of span + assert: + that: + - query_span_ansible_group is not changed + - query_span_ansible_group.current.0.spanDestGrp.attributes.name == "ansible_group" + - query_span_ansible_group.current.0.spanDestGrp.attributes.descr == "Test span" + - query_span_ansible_group.current.0.spanDestGrp.attributes.dn == "uni/tn-ansible_tenant/destgrp-ansible_group" + - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.attributes.name == "ansible_group" + - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.dscp == "CS1" + - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.srcIpPrefix == "10.0.2.1" + - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ip == "10.0.0.1" + - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.mtu == "1500" + - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.flowId == "1" + - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.verEnforced == "no" + - query_span_ansible_group.current.0.spanDestGrp.children.0.spanDest.children.0.spanRsDestEpg.attributes.ttl == "2" + - query_all_span is not changed + - query_all_span | length >= 2 + + - name: Remove span ansible_group + aci_tenant_span_dst_group: + <<: *aci_info + tenant: ansible_tenant + destination_group: ansible_group + state: absent + register: remove_span1 + + - name: Remove span ansible_group2 + aci_tenant_span_dst_group: + <<: *aci_info + tenant: ansible_tenant + destination_group: ansible_group2 + state: absent + register: remove_span2 + + - name: Verify Remove of span + assert: + that: + - remove_span1 is changed + - remove_span1.current == [] + - remove_span2 is changed + - remove_span2.current == [] + + # CLEAN ENVIRONMENT + - name: Remove the ansible_tenant + aci_tenant: + <<: *aci_info + tenant: ansible_tenant + state: absent