diff --git a/.ansible-lint b/.ansible-lint index 3af154c..cdf866c 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -50,6 +50,8 @@ mock_modules: - cisco.catalystwan.config_group_deployment - cisco.catalystwan.feature_profile_builder - cisco.catalystwan.policy + - cisco.catalystwan.tenants + - cisco.catalystwan.tenants_info # - zuul_return # # note the foo.bar is invalid as being neither a module or a collection # - fake_namespace.fake_collection.fake_module diff --git a/plugins/module_utils/filters.py b/plugins/module_utils/filters.py index ca1f807..7238e44 100644 --- a/plugins/module_utils/filters.py +++ b/plugins/module_utils/filters.py @@ -7,6 +7,7 @@ from catalystwan.endpoints.monitoring.device_details import DeviceData from catalystwan.session import ManagerHTTPError from catalystwan.typed_list import DataSequence +from catalystwan.utils.session_type import SessionType from ..module_utils.vmanage_module import AnsibleCatalystwanModule @@ -41,7 +42,10 @@ def get_target_device( module.logger.info(f"Device Category: {device_category} \nAll devices response: {devices}") if module.params.get("device_ip"): - target_device = devices.filter(device_ip=module.params["device_ip"]).single_or_default() + if module.session.session_type == SessionType.PROVIDER: + target_device = devices.filter(local_system_ip=module.params["device_ip"]).single_or_default() + else: + target_device = devices.filter(device_ip=module.params["device_ip"]).single_or_default() if module.params.get("hostname"): target_device = devices.filter(host_name=module.params["hostname"]).single_or_default() if module.params.get("uuid"): diff --git a/plugins/module_utils/vmanage_module.py b/plugins/module_utils/vmanage_module.py index e4698f1..230297c 100644 --- a/plugins/module_utils/vmanage_module.py +++ b/plugins/module_utils/vmanage_module.py @@ -61,6 +61,7 @@ class AnsibleCatalystwanModule: username=dict(type="str", required=True, fallback=(env_fallback, ["VMANAGE_USERNAME"])), password=dict(type="str", required=True, fallback=(env_fallback, ["VMANAGE_PASSWORD"]), no_log=True), port=dict(type="str", required=False, fallback=(env_fallback, ["VMANAGE_PORT"])), + subdomain=dict(type="str", required=False, fallback=(env_fallback, ["VMANAGE_SUBDOMAIN"])), ), ), catalystwan_log_dir=dict(type="str", required=False, fallback=(env_fallback, ["CATALYSTWAN_LOG_DIR"])), @@ -136,6 +137,7 @@ def session(self) -> ManagerSession: username=self.module.params["manager_credentials"]["username"], password=self.module.params["manager_credentials"]["password"], port=self.module.params["manager_credentials"]["port"], + subdomain=self.module.params["manager_credentials"]["subdomain"], logger=self._vmanage_logger, ) break diff --git a/plugins/modules/cluster_management.py b/plugins/modules/cluster_management.py index 8766e76..47fe677 100644 --- a/plugins/modules/cluster_management.py +++ b/plugins/modules/cluster_management.py @@ -52,6 +52,26 @@ - A dict containing the services of cluster device, such as Cisco Software-Defined Application Visibility and Control. type: dict + tenancy: + description: + - Dictionary to configure tenancy settings. + type: dict + required: false + suboptions: + mode: + description: + - Tenancy mode for the cluster. + choices: ['single', 'multi'] + type: str + clusterid: + description: + - Unique identifier for the cluster in tenancy context. + type: str + domain: + description: + - Domain name associated with the tenancy. + type: str + author: - Przemyslaw Susko (sprzemys@cisco.com) extends_documentation_fragment: @@ -105,12 +125,18 @@ services: sd-avc: server: false + +- name: Create a cluster with tenancy configuration + cluster_management: + tenancy: + mode: multi + domain: "domain" """ import time from typing import List, Optional -from catalystwan.endpoints.cluster_management import ConnectedDevice, VManageSetup +from catalystwan.endpoints.cluster_management import ConnectedDevice, TenancyMode, VManageSetup from catalystwan.exceptions import ManagerRequestException from ..module_utils.result import ModuleResult @@ -160,12 +186,12 @@ def run_module(): module_args = dict( wait_until_configured_seconds=dict(type="int", default=0), vmanage_id=dict(type=str), - system_ip=dict(type=str, required=True), - cluster_ip=dict(type=str, required=True), - username=dict(type=str, required=True), - password=dict(type=str, no_log=True, required=True), + system_ip=dict(type=str), + cluster_ip=dict(type=str), + username=dict(type=str), + password=dict(type=str, no_log=True), gen_csr=dict(type=bool, aliases=["genCSR"]), - persona=dict(type=str, choices=["COMPUTE_AND_DATA", "COMPUTE", "DATA"], required=True), + persona=dict(type=str, choices=["COMPUTE_AND_DATA", "COMPUTE", "DATA"]), services=dict( type="dict", options=dict( @@ -178,56 +204,113 @@ def run_module(): ), ), ), + tenancy=dict( + type="dict", + options=dict( + mode=dict(type="str", choices=["single", "multi"]), + clusterid=dict(type="str"), + domain=dict(type="str"), + ), + required=False, + ), ) - module = AnsibleCatalystwanModule(argument_spec=module_args, session_reconnect_retries=180) + required_together = [("system_ip", "cluster_ip", "username", "password", "persona")] + + mutually_exclusive = [("tenancy", ("system_ip", "cluster_ip", "username", "password", "persona"))] + + required_one_of = mutually_exclusive + + module = AnsibleCatalystwanModule( + argument_spec=module_args, + session_reconnect_retries=180, + required_together=required_together, + mutually_exclusive=mutually_exclusive, + required_one_of=required_one_of, + ) module.session.request_timeout = 60 result = ModuleResult() - vmanage_id = module.params.get("vmanage_id") - system_ip = module.params.get("system_ip") - cluster_ip = module.params.get("cluster_ip") - - if is_device_connected_to_cluster(module, system_ip, cluster_ip): - result.changed = False - result.msg = f"Device {cluster_ip} already configured" - module.exit_json(**result.model_dump(mode="json")) - - payload = VManageSetup( - vmanage_id=vmanage_id, - device_ip=cluster_ip, - username=module.params.get("username"), - password=module.params.get("password"), - persona=module.params.get("persona"), - services=module.params.get("services"), - ) + if module.params.get("vmanage_id"): + vmanage_id = module.params.get("vmanage_id") + system_ip = module.params.get("system_ip") + cluster_ip = module.params.get("cluster_ip") - if vmanage_id: - module.send_request_safely( - result, - action_name="Cluster Management: Edit vManage", - send_func=module.session.endpoints.cluster_management.edit_vmanage, - payload=payload, - response_key="edit_vmanage", + if is_device_connected_to_cluster(module, system_ip, cluster_ip): + result.changed = False + result.msg = f"Device {cluster_ip} already configured" + module.exit_json(**result.model_dump(mode="json")) + + payload = VManageSetup( + vmanage_id=vmanage_id, + device_ip=cluster_ip, + username=module.params.get("username"), + password=module.params.get("password"), + persona=module.params.get("persona"), + services=module.params.get("services"), ) - else: - module.send_request_safely( - result, - action_name="Cluster Management: Add vManage", - send_func=module.session.endpoints.cluster_management.add_vmanage, - payload=payload, - response_key="add_vmanage", + + if vmanage_id: + module.send_request_safely( + result, + action_name="Cluster Management: Edit vManage", + send_func=module.session.endpoints.cluster_management.edit_vmanage, + payload=payload, + response_key="edit_vmanage", + ) + else: + module.send_request_safely( + result, + action_name="Cluster Management: Add vManage", + send_func=module.session.endpoints.cluster_management.add_vmanage, + payload=payload, + response_key="add_vmanage", + ) + + if result.changed: + wait_until_configured_seconds = module.params.get("wait_until_configured_seconds") + if wait_until_configured_seconds: + error_msg = wait_for_connected_device(module, system_ip, cluster_ip, wait_until_configured_seconds) + if error_msg: + module.fail_json(msg=f"Error during vManage configuration: {error_msg}") + result.msg = "Successfully updated requested vManage configuration." + else: + result.msg = "No changes to vManage configuration applied." + + if module.params.get("tenancy"): + tenancy_mode: TenancyMode = module.get_response_safely( + module.session.endpoints.cluster_management.get_tenancy_mode ) - if result.changed: - wait_until_configured_seconds = module.params.get("wait_until_configured_seconds") - if wait_until_configured_seconds: - error_msg = wait_for_connected_device(module, system_ip, cluster_ip, wait_until_configured_seconds) - if error_msg: - module.fail_json(msg=f"Error during vManage configuration: {error_msg}") - result.msg = "Successfully updated requested vManage configuration." - else: - result.msg = "No changes to vManage configuration applied." + if tenancy_mode.mode == "MultiTenant" and module.params.get("tenancy").get("mode") != "multi": + module.fail_json(msg="Switching from MultiTenancy to SingleTenancy is forbidden") + + elif tenancy_mode.mode == "SingleTenant" and module.params.get("tenancy").get("mode") != "single": + new_tennacy = TenancyMode( + mode="MultiTenant", + domain=module.params.get("tenancy").get("domain"), + clusterid=module.params.get("tenancy").get("clusterid"), + ) + + module.send_request_safely( + result, + action_name="Cluster Management: Tenancy Mode", + send_func=module.session.endpoints.cluster_management.set_tenancy_mode, + payload=new_tennacy, + response_key=tenancy_mode, + ) + + if result.changed: + wait_until_configured_seconds = module.params.get("wait_until_configured_seconds") + if wait_until_configured_seconds: + # wait for manager to restart + time.sleep(60) + module.session.wait_server_ready(timeout=(wait_until_configured_seconds)) + result.msg = "Successfully updated requested vManage configuration. vManage was restarted" + else: + result.msg = "Successfully updated requested vManage configuration. vManage will be restarted" + else: + result.msg = "No changes to vManage configuration applied." module.exit_json(**result.model_dump(mode="json")) diff --git a/plugins/modules/devices_certificates.py b/plugins/modules/devices_certificates.py index 7dc7cca..0ec581c 100644 --- a/plugins/modules/devices_certificates.py +++ b/plugins/modules/devices_certificates.py @@ -212,7 +212,7 @@ def run_module(): result.changed = False module.exit_json(**result.model_dump(mode="json")) - payload = TargetDevice(deviceIP=module.params.get("device_ip")) + payload = TargetDevice(deviceIP=target_device_details.device_ip) module.send_request_safely( result=result, action_name="Generate CSR for vManage", diff --git a/plugins/modules/tenants.py b/plugins/modules/tenants.py new file mode 100644 index 0000000..1fb3d7f --- /dev/null +++ b/plugins/modules/tenants.py @@ -0,0 +1,208 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2025 Cisco Systems, Inc. and its affiliates +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + + +DOCUMENTATION = r""" +--- +module: tenants +short_description: Manage Tenants in Cisco SDWAN +version_added: "0.2.0" +description: + - This module allows you to create or delete Tenants in Cisco SDWAN. +options: + state: + description: + - Whether the Tenant should be present or absent on the Cisco SDWAN. + required: false + type: str + choices: ["present", "absent"] + default: "present" + name: + description: + - The name of the Tenant. + required: true + type: str + description: + description: + - The description of the Tenant. + required: false + type: str + default: None + org_name: + description: + - The organization name of the Tenant. + required: false + type: str + subdomain: + description: + - The subdomain of the Tenant. + required: false + type: str + wan_edge_forecast: + description: + - Forecasted number of edges in Tenant. + required: false + type: int + wait_timeout_seconds: + description: + - The timeout in seconds for creating Tenant. Default is 7200. + type: int +author: + - Piotr Piwowarski (pipiwowa@cisco.com) +extends_documentation_fragment: + - cisco.catalystwan.manager_authentication +notes: + - Ensure that the provided credentials have sufficient permissions to manage tenants in vManage. +""" + +EXAMPLES = r""" +- name: Create tenant + cisco.catalystwan.tenants: + name: "MyTenant" + org_name: "my-tenant-org" + description: "My tenant description" + subdomain: "tenant.domain" + wan_edge_forecast: "3" + manager_authentication: ... + +- name: Remove a Tenant from vManage + cisco.catalystwan.tenants: + state: absent + name: "MyTenant" + manager_credentials: ... +""" + +RETURN = r""" +msg: + description: A message describing the result of the operation. + returned: always + type: str + sample: "Created tenant MyTenant" +changed: + description: A boolean flag indicating if any changes were made. + returned: always + type: bool + sample: true +""" + +from typing import Literal, Optional, get_args + +from catalystwan.api.task_status_api import Task +from catalystwan.models.tenant import Tenant +from catalystwan.session import ManagerRequestException +from catalystwan.typed_list import DataSequence +from catalystwan.vmanage_auth import UnauthorizedAccessError +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed # type: ignore + +from ..module_utils.result import ModuleResult +from ..module_utils.vmanage_module import AnsibleCatalystwanModule + +State = Literal["present", "absent"] + +INTERVAL_SECONDS = 30 +TIMEOUT_SECONDS = 7200 + + +@retry( + wait=wait_fixed(INTERVAL_SECONDS), + stop=stop_after_attempt(int(TIMEOUT_SECONDS / INTERVAL_SECONDS)), + retry=retry_if_exception_type((ManagerRequestException, UnauthorizedAccessError)), + reraise=True, +) +def wait_for_task_data(module: AnsibleCatalystwanModule, result: ModuleResult, task: Task): + task.session.login() + task_data = task.wait_for_completed(timeout_seconds=module.params.get("wait_timeout_seconds")) + if not task_data.result: + result.msg = [data.activity for data in task_data.sub_tasks_data] + result.response = task_data.json() + module.fail_json(**result.model_dump(mode="json")) + module.logger.info(f"Task data after task completed: {task_data.dict()}") + + +def run_module(): + module_args = dict( + state=dict( + type=str, + choices=list(get_args(State)), + default="present", + ), + name=dict(type="str", required=True), + description=dict(type="str", default=None), + org_name=dict(type="str", default=None), + subdomain=dict(type="str", default=None), + wan_edge_forecast=dict(type="int", default=None), + wait_timeout_seconds=dict( + type="int", default=7200 + ), # 3600 is because vManage reports: 'configuration-dbStatus it may take up to 40 mins or longer' + ) + result = ModuleResult() + + module = AnsibleCatalystwanModule( + argument_spec=module_args, + required_if=[ + ( + "state", + "present", + ( + "name", + "description", + "org_name", + "subdomain", + "wan_edge_forecast", + ), + ), + ], + ) + + tenant_name = module.params.get("name") + + all_tenants: DataSequence[Tenant] = module.get_response_safely(module.session.api.tenant_management.get) + filtered_tenants: Optional[DataSequence[Tenant]] = all_tenants.filter(name=tenant_name) + + if module.params.get("state") == "present": + # Code for checking if tenant exists already + if filtered_tenants: + module.logger.debug(f"Detected existing tenant:\n{filtered_tenants}\n") + result.msg = f"Tenant with name {tenant_name} already present on vManage, skipping create tenant operation." + else: + create_task = module.session.api.tenant_management.create( + tenants=[ + Tenant( + name=tenant_name, + desc=module.params.get("description"), + org_name=module.params.get("org_name"), + subdomain=module.params.get("subdomain"), + wan_edge_forecast=module.params.get("wan_edge_forecast"), + ) + ] + ) + + wait_for_task_data(module=module, result=result, task=create_task) + + result.changed = True + result.msg += f"Created tenant {tenant_name}." + + if module.params.get("state") == "absent": + if filtered_tenants: + delete_task = module.session.api.tenant_management.delete(tenant_id_list=[filtered_tenants[0].id]) + + wait_for_task_data(module=module, result=result, task=delete_task) + + result.changed = True + result.msg += f"Created tenant {tenant_name}." + else: + module.logger.debug(f"Tenant '{tenant_name}' not presend on vManage.") + result.msg = f"Tenant '{tenant_name}' not presend on vManage. skipping delete tenant operation." + + module.exit_json(**result.model_dump(mode="json")) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/tenants_info.py b/plugins/modules/tenants_info.py new file mode 100644 index 0000000..5c2e880 --- /dev/null +++ b/plugins/modules/tenants_info.py @@ -0,0 +1,135 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2025 Cisco Systems, Inc. and its affiliates +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + + +DOCUMENTATION = r""" +--- +module: tenants_info +short_description: Get information about tenants +version_added: "0.3.5" +description: + - This module allows you to get tenants Info from vManage. +options: + filters: + description: + - A dictionary of filters used to select tenants info. + type: dict + required: false + suboptions: + name: + description: + - The name of the tenant. + required: false + default: null + type: str + description: + description: + - Description of the tenant. + required: false + default: null + type: str + org_name: + description: + - Description of the tenant. + required: false + default: null + type: str + subdomain: + description: + - Description of the tenant. + required: false + default: null + type: str + id: + description: + - tenant ID. + required: false + default: null + type: str +extends_documentation_fragment: + - cisco.catalystwan.manager_authentication +notes: + - Ensure that the provided credentials have sufficient permissions to manage tenants in vManage. +""" + +EXAMPLES = r""" +- name: Get all vmanage tenants available + cisco.catalystwan.tenants: + filters: + device_type: vmanage + manager_credentials: ... +""" + +RETURN = r""" +msg: + description: A message describing the result of the operation. + returned: always + type: str + sample: "Succesfully got all requested tenants Info from vManage" +changed: + description: A boolean flag indicating if any changes were made. + returned: always + type: bool + sample: true +tenants_info: + description: List of tenants filtered by arguments provided to module. + returned: always + type: list +tenancy_mode: + description: Current tenancy mode of cluster. + returned: always + type: str +tenancy_domain: + description: Name of multitenancy domain. + returned: always + type: str +""" + +from catalystwan.endpoints.cluster_management import TenancyMode +from catalystwan.models.tenant import Tenant +from catalystwan.typed_list import DataSequence + +from ..module_utils.result import ModuleResult +from ..module_utils.vmanage_module import AnsibleCatalystwanModule + + +def run_module(): + module_args = dict( + filters=dict(type="dict", default=None, required=False), + ) + result = ModuleResult() + + module = AnsibleCatalystwanModule(argument_spec=module_args) + + filters = module.params.get("filters") + + all_tenants: DataSequence[Tenant] = module.get_response_safely(module.session.api.tenant_management.get) + + tenancy_mode: TenancyMode = module.get_response_safely(module.session.endpoints.cluster_management.get_tenancy_mode) + result.tenancy_mode = tenancy_mode.mode + result.tenancy_domain = tenancy_mode.domain + + if module.params.get("filters"): + result.tenants_info = [tenant for tenant in all_tenants.filter(**filters)] + else: + result.tenants_info = [tenant for tenant in all_tenants] + + if result.tenants_info: + module.logger.info(f"All tenants filtered with filters: {filters}:\n{result.tenants_info}") + result.msg = "Succesfully got all requested tenants Info from vManage" + else: + module.logger.warning(msg=f"tenants filtered with `{filters}` not present.") + result.msg = f"tenants filtered with `{filters}` not present on vManage." + + module.exit_json(**result.model_dump(mode="json")) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/roles/activate_edges/defaults/main.yml b/roles/activate_edges/defaults/main.yml new file mode 100644 index 0000000..9b4e69c --- /dev/null +++ b/roles/activate_edges/defaults/main.yml @@ -0,0 +1,4 @@ +--- + +provider_mode: false +tenants: [] diff --git a/roles/activate_edges/tasks/activate_edges.yml b/roles/activate_edges/tasks/activate_edges.yml new file mode 100644 index 0000000..545eccc --- /dev/null +++ b/roles/activate_edges/tasks/activate_edges.yml @@ -0,0 +1,91 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +--- + +- name: Verify required variables for selected role + ansible.builtin.include_tasks: variables_assertion.yml + +- name: Get list of Edge devices + cisco.catalystwan.devices_info: + device_category: vedges + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + subdomain: "{{ tenant_subdomain if provider_mode else omit }}" + register: edge_devices + retries: 12 + delay: 10 + +# We do PnP sync with validity set to valid, task obsolete, kept only as reference +# - name: Change Edge devices validity to Valid +# cisco.catalystwan.devices_certificates: +# change_vedge_list_validity: +# chasis_number: "{{ item.chasis_number }}" +# validity: "valid" +# manager_authentication: +# url: "{{ (vmanage_instances | first).mgmt_public_ip }}" +# username: "{{ (vmanage_instances | first).admin_username }}" +# password: "{{ (vmanage_instances | first).admin_password }}" +# loop: "{{ edge_devices.devices }}" +# loop_control: +# label: "Device chasis number: {{ item.chasis_number }}" + +- name: Send to controllers to sync the WAN Edge list on all controllers + cisco.catalystwan.devices_certificates: + send_to_controllers: true + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + subdomain: "{{ tenant_subdomain if provider_mode else omit }}" + retries: 12 + delay: 10 + + +- name: Wait until edge devices are reachable and OTP phase is over + cisco.catalystwan.devices_info: + device_category: vedges + filters: + chasis_number: "{{ device_item.chasis_number }}" + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + subdomain: "{{ tenant_subdomain if provider_mode else omit }}" + register: edge_device_details + loop: "{{ edge_devices.devices }}" + loop_control: + loop_var: device_item + label: "Device chasis number: {{ device_item.chasis_number }}" + retries: 20 + delay: 10 + until: + - edge_device_details.devices | default([]) + - edge_device_details.devices[0].device_state == "READY" + +- name: Wait until cert_install_status == "Installed" and reachability == "reachable" on all edge devices + cisco.catalystwan.devices_info: + device_category: vedges + filters: + chasis_number: "{{ device_item.chasis_number }}" + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + subdomain: "{{ tenant_subdomain if provider_mode else omit }}" + register: edge_device_details + loop: "{{ edge_devices.devices }}" + loop_control: + loop_var: device_item + label: "Device chasis number: {{ device_item.chasis_number }}" + retries: 20 + delay: 10 + until: + - edge_device_details.devices | default([]) + - edge_device_details.devices[0].cert_install_status == "Installed" + - edge_device_details.devices[0].reachability == "reachable" + when: > + wan_edges is not defined + or wan_edges | json_query('[?uuid==`'~device_item['uuid']~'`] | [?!contains(keys(@), `foreign`) || !foreign || contains(keys(@), `mgmt_public_ip`)]') diff --git a/roles/activate_edges/tasks/main.yml b/roles/activate_edges/tasks/main.yml index 58629ae..2b66d17 100644 --- a/roles/activate_edges/tasks/main.yml +++ b/roles/activate_edges/tasks/main.yml @@ -6,82 +6,18 @@ - name: Verify required variables for selected role ansible.builtin.include_tasks: variables_assertion.yml -- name: Get list of Edge devices - cisco.catalystwan.devices_info: - device_category: vedges - manager_authentication: - url: "{{ (vmanage_instances | first).mgmt_public_ip }}" - username: "{{ (vmanage_instances | first).admin_username }}" - password: "{{ (vmanage_instances | first).admin_password }}" - register: edge_devices - retries: 12 - delay: 10 - -# We do PnP sync with validity set to valid, task obsolete, kept only as reference -# - name: Change Edge devices validity to Valid -# cisco.catalystwan.devices_certificates: -# change_vedge_list_validity: -# chasis_number: "{{ item.chasis_number }}" -# validity: "valid" -# manager_authentication: -# url: "{{ (vmanage_instances | first).mgmt_public_ip }}" -# username: "{{ (vmanage_instances | first).admin_username }}" -# password: "{{ (vmanage_instances | first).admin_password }}" -# loop: "{{ edge_devices.devices }}" -# loop_control: -# label: "Device chasis number: {{ item.chasis_number }}" - -- name: Send to controllers to sync the WAN Edge list on all controllers - cisco.catalystwan.devices_certificates: - send_to_controllers: true - manager_authentication: - url: "{{ (vmanage_instances | first).mgmt_public_ip }}" - username: "{{ (vmanage_instances | first).admin_username }}" - password: "{{ (vmanage_instances | first).admin_password }}" - retries: 12 - delay: 10 - - -- name: Wait until edge devices are reachable and OTP phase is over - cisco.catalystwan.devices_info: - device_category: vedges - filters: - chasis_number: "{{ device_item.chasis_number }}" - manager_authentication: - url: "{{ (vmanage_instances | first).mgmt_public_ip }}" - username: "{{ (vmanage_instances | first).admin_username }}" - password: "{{ (vmanage_instances | first).admin_password }}" - register: edge_device_details - loop: "{{ edge_devices.devices }}" - loop_control: - loop_var: device_item - label: "Device chasis number: {{ device_item.chasis_number }}" - retries: 20 - delay: 10 - until: - - edge_device_details.devices | default([]) - - edge_device_details.devices[0].device_state == "READY" - -- name: Wait until cert_install_status == "Installed" and reachability == "reachable" on all edge devices - cisco.catalystwan.devices_info: - device_category: vedges - filters: - chasis_number: "{{ device_item.chasis_number }}" - manager_authentication: - url: "{{ (vmanage_instances | first).mgmt_public_ip }}" - username: "{{ (vmanage_instances | first).admin_username }}" - password: "{{ (vmanage_instances | first).admin_password }}" - register: edge_device_details - loop: "{{ edge_devices.devices }}" +- name: Activate edges in single tenant environment + ansible.builtin.include_tasks: activate_edges.yml + +- name: Activate edges in multitenant tenant environment + ansible.builtin.include_tasks: + file: activate_edges.yml + apply: + vars: + provider_mode: true + tenant_subdomain: "{{ tenant_item.subdomain }}" + loop: "{{ tenants }}" loop_control: - loop_var: device_item - label: "Device chasis number: {{ device_item.chasis_number }}" - retries: 20 - delay: 10 - until: - - edge_device_details.devices | default([]) - - edge_device_details.devices[0].cert_install_status == "Installed" - - edge_device_details.devices[0].reachability == "reachable" - when: > - wan_edges is not defined - or wan_edges | json_query('[?uuid==`'~device_item['uuid']~'`] | [?!contains(keys(@), `foreign`) || !foreign || contains(keys(@), `mgmt_public_ip`)]') + loop_var: tenant_item + label: "{{ tenant_item.name }}" + index_var: "tenant_id" diff --git a/roles/health_checks/defaults/main.yml b/roles/health_checks/defaults/main.yml new file mode 100644 index 0000000..9b4e69c --- /dev/null +++ b/roles/health_checks/defaults/main.yml @@ -0,0 +1,4 @@ +--- + +provider_mode: false +tenants: [] diff --git a/roles/health_checks/tasks/main.yml b/roles/health_checks/tasks/main.yml index 90dbd60..6e5de9e 100644 --- a/roles/health_checks/tasks/main.yml +++ b/roles/health_checks/tasks/main.yml @@ -79,6 +79,7 @@ url: "{{ (vmanage_instances | first).mgmt_public_ip }}" username: "{{ (vmanage_instances | first).admin_username }}" password: "{{ (vmanage_instances | first).admin_password }}" + when: not tenants - name: "Health check: OMP sessions - verifies if all have state up" cisco.catalystwan.health_checks: @@ -87,3 +88,4 @@ url: "{{ (vmanage_instances | first).mgmt_public_ip }}" username: "{{ (vmanage_instances | first).admin_username }}" password: "{{ (vmanage_instances | first).admin_password }}" + when: not tenants diff --git a/roles/multitenant_mode/README.md b/roles/multitenant_mode/README.md new file mode 100644 index 0000000..d2111dc --- /dev/null +++ b/roles/multitenant_mode/README.md @@ -0,0 +1,59 @@ +# Ansible Role: multitenant_mode + +An Ansible role to enable multi-tenant mode on Cisco Catalyst SD-WAN vManage. + +## Role Description + +The `multitenant_mode` role performs the following tasks: + +1. Verifies that the required variables for the role are present. +2. Sets the tenancy mode for and vManage instance. +3. Waits until vManage reboots after change + +## Requirements + +- `cisco.catalystwan` collection installed. +- Access details for the Cisco Manager instance must be provided. + +## Dependencies + +There are no external role dependencies. Only `cisco.catalystwan` collection is required. + +## Role Variables + +Variables expected by this role: + +- `vmanage_instances`: List of vManage instances containing management IP, admin username, and admin password. +- `vsmart_instances`: List of vSmart controller instances with hostnames. +- `vbond_instances`: List of vBond controller instances with hostnames. +- `multitenant_domain`: Multi-tenant domain name. + +## Example Playbook + +Including an example of how to use your role (with variables passed in as parameters): + +```yaml +- hosts: all + gather_facts: no + tasks: + - name: Set multi-tenant mode + import_role: + name: multitenant_mode + vars: + vmanage_instances: + - mgmt_public_ip: '192.0.2.1' + admin_username: 'admin' + admin_password: 'password' + multitenant_domain: "MyDomainName" +``` + +## Known Limitations + + +## License + +"GPL-3.0-only" + +## Author Information + +This role was created by Piotr Piwowarski diff --git a/roles/multitenant_mode/meta/main.yml b/roles/multitenant_mode/meta/main.yml new file mode 100644 index 0000000..c0c7a04 --- /dev/null +++ b/roles/multitenant_mode/meta/main.yml @@ -0,0 +1,15 @@ +--- + +galaxy_info: + author: Piotr Piwowarski + description: Set multi-tenancy mode + license: GPL-3.0-or-later + min_ansible_version: "2.16.6" + + galaxy_tags: + - cisco + - sdwan + - catalystwan + - networking + +dependencies: [] diff --git a/roles/multitenant_mode/tasks/main.yml b/roles/multitenant_mode/tasks/main.yml new file mode 100644 index 0000000..227be32 --- /dev/null +++ b/roles/multitenant_mode/tasks/main.yml @@ -0,0 +1,19 @@ +# Copyright 2025 Cisco Systems, Inc. and its affiliates +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +--- + +- name: Verify required variables for selected role + ansible.builtin.include_tasks: variables_assertion.yml + +- name: "Set multitenant mode for vManage {{ (vmanage_instances | first).hostname }}" + cisco.catalystwan.cluster_management: + wait_until_configured_seconds: 300 + tenancy: + mode: multi + domain: "{{ multitenant_domain }}" + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + register: updated_tenancy_mode diff --git a/roles/multitenant_mode/tasks/variables_assertion.yml b/roles/multitenant_mode/tasks/variables_assertion.yml new file mode 100644 index 0000000..fa732b7 --- /dev/null +++ b/roles/multitenant_mode/tasks/variables_assertion.yml @@ -0,0 +1,23 @@ +# Copyright 2025 Cisco Systems, Inc. and its affiliates +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +--- + +- name: Assert that required variables are provided + ansible.builtin.assert: + that: + - required_var + - required_var is defined + - required_var != None + - required_var != "None" + - required_var != "" + - required_var | length > 0 + fail_msg: "Your SD-WAN initial config file missing required variable: {{ required_var }}" + quiet: true + loop: + - "{{ vmanage_instances }}" + - "{{ vbond_instances }}" + - "{{ vsmart_instances }}" + - "{{ multitenant_domain }}" + loop_control: + loop_var: required_var diff --git a/roles/onboarding_controllers/tasks/main.yml b/roles/onboarding_controllers/tasks/main.yml index d9f3fdf..5d77b2d 100644 --- a/roles/onboarding_controllers/tasks/main.yml +++ b/roles/onboarding_controllers/tasks/main.yml @@ -146,7 +146,7 @@ cisco.catalystwan.devices_info: device_category: controllers filters: - device_ip: "{{ device_item.system_ip }}" + local_system_ip: "{{ device_item.system_ip }}" manager_authentication: url: "{{ (vmanage_instances | first).mgmt_public_ip }}" username: "{{ (vmanage_instances | first).admin_username }}" @@ -164,7 +164,7 @@ cisco.catalystwan.devices_info: device_category: controllers filters: - device_ip: "{{ device_item.system_ip }}" + local_system_ip: "{{ device_item.system_ip }}" manager_authentication: url: "{{ (vmanage_instances | first).mgmt_public_ip }}" username: "{{ (vmanage_instances | first).admin_username }}" diff --git a/roles/sync_pnp_edges/defaults/main.yml b/roles/sync_pnp_edges/defaults/main.yml new file mode 100644 index 0000000..dc5a850 --- /dev/null +++ b/roles/sync_pnp_edges/defaults/main.yml @@ -0,0 +1,5 @@ +--- + +provider_mode: false +tenant_id: 0 +tenants: [] diff --git a/roles/sync_pnp_edges/tasks/main.yml b/roles/sync_pnp_edges/tasks/main.yml index 9f6bc64..b22a857 100644 --- a/roles/sync_pnp_edges/tasks/main.yml +++ b/roles/sync_pnp_edges/tasks/main.yml @@ -15,80 +15,24 @@ Press any key to continue, press `Ctrl + C` and `Shift + A` to abort register: user_response - -# serial viptela file will work only for dev/engineering images -- name: Upload WAN Edge List - cisco.catalystwan.devices_wan_edges: - wan_edge_list: "{{ wan_edge_list_path }}" - state: present - manager_authentication: - url: "{{ (vmanage_instances | first).mgmt_public_ip }}" - username: "{{ (vmanage_instances | first).admin_username }}" - password: "{{ (vmanage_instances | first).admin_password }}" - when: wan_edge_list_path is defined - retries: 12 - delay: 5 - -- name: Sync devices with Smart Account only if user didn't provide WAN Edge list path - cisco.catalystwan.devices_wan_edges: - state: present - sync_devices_from_smart_account: true - username: "{{ pnp_username }}" - password: "{{ pnp_password }}" - manager_authentication: - url: "{{ (vmanage_instances | first).mgmt_public_ip }}" - username: "{{ (vmanage_instances | first).admin_username }}" - password: "{{ (vmanage_instances | first).admin_password }}" - when: wan_edge_list_path is not defined - retries: 12 - delay: 5 - -- name: Get list of Edge devices - cisco.catalystwan.devices_info: - device_category: vedges - manager_authentication: - url: "{{ (vmanage_instances | first).mgmt_public_ip }}" - username: "{{ (vmanage_instances | first).admin_username }}" - password: "{{ (vmanage_instances | first).admin_password }}" - register: all_edge_devices - retries: 12 - delay: 5 - -- name: Generate bootstrap configuration for all devices - cisco.catalystwan.devices_wan_edges: - generate_bootstrap_configuration: true - uuid: "{{ (all_edge_devices['devices'] | selectattr('cert_install_status', '!=', 'Installed')) | map(attribute='uuid') }}" - manager_authentication: - url: "{{ (vmanage_instances | first).mgmt_public_ip }}" - username: "{{ (vmanage_instances | first).admin_username }}" - password: "{{ (vmanage_instances | first).admin_password }}" - register: all_bootstrap_cfg - retries: 12 - delay: 5 - -- name: Initialize empty dictionary for generated_data_edge_instances - ansible.builtin.set_fact: - generated_data_edge_instances: - edge_instances: [] - -- name: Generate entries for generated_data_edge_instances.edge_instances - ansible.builtin.set_fact: - generated_data_edge_instances: - edge_instances: "{{ generated_data_edge_instances.edge_instances + [new_entry] }}" - vars: - generated_bootstrap: "{{ all_bootstrap_cfg.bootstrap_configuration | json_query('[?uuid==`'~ instance.uuid ~'`]') }}" - new_entry: - hostname: "{{ organization_name }}-cedge-{{ index + 1 }}" - otp: "{{ generated_bootstrap[0].otp if generated_bootstrap else omit }}" - uuid: "{{ instance.uuid }}" - vbond: "{{ generated_bootstrap[0].vbond if generated_bootstrap else instance.vbond }}" - system_ip: "192.168.10{{ index + 1 }}.1" - site_id: "{{ 1000 + index + 1 }}" - loop: "{{ all_edge_devices.devices }}" +- name: Sync edges in single tenant environment + ansible.builtin.include_tasks: sync_pnp_edges.yml + when: not tenants + +- name: Sync edges in multitenant tenant environment + ansible.builtin.include_tasks: + file: sync_pnp_edges.yml + apply: + vars: + provider_mode: true + tenant_subdomain: "{{ tenant_item.subdomain }}" + tenant_org: "{{ tenant_item.org_name }}" + wan_edge_list_path: "{{ tenant_item.wan_edge_list_path or omit }}" + loop: "{{ tenants }}" loop_control: - index_var: index - loop_var: instance - label: "{{ instance.uuid }}" + loop_var: tenant_item + label: "{{ tenant_item.name }}" + index_var: "tenant_id" - name: "Generate file to store edge deployment configuration, path: {{ deployment_edges_config }}" ansible.builtin.blockinfile: diff --git a/roles/sync_pnp_edges/tasks/sync_pnp_edges.yml b/roles/sync_pnp_edges/tasks/sync_pnp_edges.yml new file mode 100644 index 0000000..1f8790b --- /dev/null +++ b/roles/sync_pnp_edges/tasks/sync_pnp_edges.yml @@ -0,0 +1,79 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +--- + +# serial viptela file will work only for dev/engineering images +- name: Upload WAN Edge List + cisco.catalystwan.devices_wan_edges: + wan_edge_list: "{{ wan_edge_list_path }}" + state: present + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + subdomain: "{{ tenant_subdomain if provider_mode else omit }}" + when: wan_edge_list_path is defined + retries: 12 + delay: 5 + +- name: Sync devices with Smart Account only if user didn't provide WAN Edge list path + cisco.catalystwan.devices_wan_edges: + state: present + sync_devices_from_smart_account: true + username: "{{ pnp_username }}" + password: "{{ pnp_password }}" + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + subdomain: "{{ tenant_subdomain if provider_mode else omit }}" + when: wan_edge_list_path is not defined + retries: 12 + delay: 5 + +- name: Get list of Edge devices + cisco.catalystwan.devices_info: + device_category: vedges + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + subdomain: "{{ tenant_subdomain if provider_mode else omit }}" + register: all_edge_devices + retries: 12 + delay: 5 + +- name: Generate bootstrap configuration for all devices + cisco.catalystwan.devices_wan_edges: + generate_bootstrap_configuration: true + uuid: "{{ (all_edge_devices['devices'] | selectattr('cert_install_status', '!=', 'Installed')) | map(attribute='uuid') }}" + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + subdomain: "{{ tenant_subdomain if provider_mode else omit }}" + register: all_bootstrap_cfg + retries: 12 + delay: 5 + +- name: Generate entries for generated_data_edge_instances.edge_instances + ansible.builtin.set_fact: + generated_data_edge_instances: + edge_instances: "{{ generated_data_edge_instances.edge_instances | default([]) + [new_entry] }}" + vars: + generated_bootstrap: "{{ all_bootstrap_cfg.bootstrap_configuration | json_query('[?uuid==`'~ instance.uuid ~'`]') }}" + new_entry: + hostname: "{{ tenant_org if provider_mode else organization_name }}-cedge-{{ index + 1 }}" + otp: "{{ generated_bootstrap[0].otp if generated_bootstrap else omit }}" + uuid: "{{ instance.uuid }}" + vbond: "{{ generated_bootstrap[0].vbond if generated_bootstrap else instance.vbond }}" + system_ip: "192.168.{{ 100 + tenant_id * 20 + index + 1 }}.1" + site_id: "{{ 1000 + tenant_id * 20 + index + 1 }}" + tenant_org_name: "{{ tenant_org | default(organization_name) }}" + tenant_subdomain: "{{ tenant_subdomain | default('') }}" + loop: "{{ all_edge_devices.devices }}" + loop_control: + index_var: index + loop_var: instance + label: "{{ instance.uuid }}" diff --git a/roles/tenants/README.md b/roles/tenants/README.md new file mode 100644 index 0000000..d50d869 --- /dev/null +++ b/roles/tenants/README.md @@ -0,0 +1,62 @@ +# Ansible Role: tenants + +This Ansible role is designed to create tenants in Cisco SD-WAN environment. + +## Role Description + +The `tenants` role performs the following tasks: + +1. Verifies that the required variables for the role are present. +2. Gets list of currently created tenants. +3. Creates missing tenants. + +## Requirements + +- `cisco.catalystwan` collection installed. +- Access details for the Cisco Manager instance must be provided. + +## Dependencies + +There are no external role dependencies. Only `cisco.catalystwan` collection is required. + +## Role Variables + +Variables expected by this role: + +- `vmanage_instances`: List of vManage instances containing management IP, admin username, and admin password. +- `tenants`: List of tenants that will be present in system. + +## Example Playbook + +Including an example of how to use your role (with variables passed in as parameters): + +```yaml +- hosts: all + gather_facts: no + tasks: + - name: Create tenants + import_role: + name: tenants + vars: + vmanage_instances: + - mgmt_public_ip: '192.0.2.1' + admin_username: 'admin' + admin_password: 'password' + tenants: + - name: "MyTenant" + org_name: "my-tenant-org" + description: "My tenant description" + subdomain: "tenant.domain" + wan_edge_forecast: "3" +``` + +## Known Limitations + + +## License + +"GPL-3.0-only" + +## Author Information + +This role was created by Piotr Piwowarski diff --git a/roles/tenants/meta/main.yml b/roles/tenants/meta/main.yml new file mode 100644 index 0000000..aa0a664 --- /dev/null +++ b/roles/tenants/meta/main.yml @@ -0,0 +1,15 @@ +--- + +galaxy_info: + author: Piotr Piwowarski + description: Manage tenants + license: GPL-3.0-or-later + min_ansible_version: "2.16.6" + + galaxy_tags: + - cisco + - sdwan + - catalystwan + - networking + +dependencies: [] diff --git a/roles/tenants/tasks/main.yml b/roles/tenants/tasks/main.yml new file mode 100644 index 0000000..dafdcbe --- /dev/null +++ b/roles/tenants/tasks/main.yml @@ -0,0 +1,36 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +--- + +- name: Verify required variables for selected role + ansible.builtin.include_tasks: variables_assertion.yml + +- name: Get list of tenants + cisco.catalystwan.tenants_info: + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + retries: 20 + delay: 10 + register: result_tenants + +- name: Create tenants + cisco.catalystwan.tenants: + name: "{{ tenant_item.name }}" + org_name: "{{ tenant_item.org_name }}" + description: "{{ tenant_item.description }}" + subdomain: "{{ tenant_item.subdomain }}" + wan_edge_forecast: "{{ tenant_item.wan_edge_forecast }}" + manager_authentication: + url: "{{ (vmanage_instances | first).mgmt_public_ip }}" + username: "{{ (vmanage_instances | first).admin_username }}" + password: "{{ (vmanage_instances | first).admin_password }}" + loop: "{{ tenants }}" + loop_control: + loop_var: tenant_item + label: "{{ tenant_item.name }}" + when: tenant_item.name not in result_tenants.tenants_info | map(attribute='name') + retries: 6 + delay: 5 diff --git a/roles/tenants/tasks/variables_assertion.yml b/roles/tenants/tasks/variables_assertion.yml new file mode 100644 index 0000000..16c62f9 --- /dev/null +++ b/roles/tenants/tasks/variables_assertion.yml @@ -0,0 +1,20 @@ +# Copyright 2024 Cisco Systems, Inc. and its affiliates +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +--- + +- name: Assert that required variables are provided + ansible.builtin.assert: + that: + - required_var + - required_var is defined + - required_var != None + - required_var != "None" + - required_var != "" + - required_var | length > 0 + fail_msg: "Your SD-WAN initial config file missing required variable: {{ required_var }}" + quiet: true + loop: + - "{{ vmanage_instances }}" + loop_control: + loop_var: required_var diff --git a/roles/vmanage_mode/tasks/main.yml b/roles/vmanage_mode/tasks/main.yml index 460e302..29425bb 100644 --- a/roles/vmanage_mode/tasks/main.yml +++ b/roles/vmanage_mode/tasks/main.yml @@ -29,6 +29,7 @@ url: "{{ (vmanage_instances | first).mgmt_public_ip }}" username: "{{ (vmanage_instances | first).admin_username }}" password: "{{ (vmanage_instances | first).admin_password }}" + subdomain: "{{ instance_item.tenant_subdomain | default('') or omit }}" loop: "{{ edge_instances }}" loop_control: loop_var: instance_item