├── requirements.txt ├── meta └── runtime.yml ├── yapf.ini ├── .gitignore ├── cml_group_tag.png ├── cml_pat_tags.png ├── plugins ├── doc_fragments │ ├── __pycache__ │ │ └── cml.cpython-39.pyc │ └── cml.py ├── README.md ├── module_utils │ └── cml_utils.py ├── modules │ ├── cml_lab_facts.py │ ├── cml_users.py │ ├── cml_node.py │ └── cml_lab.py └── inventory │ └── cml_inventory.py ├── CHANGELOG.md ├── playbooks ├── inventory.yml ├── clean.yml └── build.yml ├── .github └── workflows │ └── ci.yml ├── Makefile ├── galaxy.yml └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | virl2-client==2.4.0 2 | -------------------------------------------------------------------------------- /meta/runtime.yml: -------------------------------------------------------------------------------- 1 | --- 2 | requires_ansible: '>=2.10.0' 3 | -------------------------------------------------------------------------------- /yapf.ini: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style = pep8 3 | column_limit = 120 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ansible_collections/ 2 | venv/ 3 | *.pyc 4 | tests/ 5 | *.gz 6 | -------------------------------------------------------------------------------- /cml_group_tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/ansible-cml/HEAD/cml_group_tag.png -------------------------------------------------------------------------------- /cml_pat_tags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/ansible-cml/HEAD/cml_pat_tags.png -------------------------------------------------------------------------------- /plugins/doc_fragments/__pycache__/cml.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoDevNet/ansible-cml/HEAD/plugins/doc_fragments/__pycache__/cml.cpython-39.pyc -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## v1.2.1 (2023-12-13) 4 | 5 | ### BUG FIXING 6 | - added CHANGELOG.md 7 | 8 | ## v1.2.0 (2023-12-13) 9 | 10 | ### FEATURE ENHANCEMENT 11 | - added PATty functionality -------------------------------------------------------------------------------- /playbooks/inventory.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/ansible-playbook.json 2 | - hosts: cml_hosts 3 | connection: local 4 | gather_facts: no 5 | tasks: 6 | - debug: 7 | msg: "Node: {{ inventory_hostname }}({{ cml_facts.node_definition }}), State: {{ cml_facts.state }}, Address: {{ ansible_host }}:{{ ansible_port | default('22') }}" 8 | when: ansible_host is defined 9 | # - debug: 10 | # var: cml_facts 11 | # tags: 12 | # - never 13 | # - detail 14 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # Collections Plugins Directory 2 | 3 | This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that 4 | is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that 5 | would contain module utils and modules respectively. 6 | 7 | Here is an example directory of the majority of plugins currently supported by Ansible: 8 | 9 | ``` 10 | └── plugins 11 | ├── action 12 | ├── become 13 | ├── cache 14 | ├── callback 15 | ├── cliconf 16 | ├── connection 17 | ├── filter 18 | ├── httpapi 19 | ├── inventory 20 | ├── lookup 21 | ├── module_utils 22 | ├── modules 23 | ├── netconf 24 | ├── shell 25 | ├── strategy 26 | ├── terminal 27 | ├── test 28 | └── vars 29 | ``` 30 | 31 | A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible/2.10/plugins/plugins.html). 32 | -------------------------------------------------------------------------------- /playbooks/clean.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/ansible-playbook.json 2 | - hosts: localhost 3 | gather_facts: no 4 | connection: local 5 | tasks: 6 | - name: Stop the lab 7 | cisco.cml.cml_lab: 8 | host: "{{ cml_host }}" 9 | user: "{{ cml_username }}" 10 | password: "{{ cml_password }}" 11 | lab: "{{ cml_lab }}" 12 | state: stopped 13 | tags: 14 | - stop 15 | - wipe 16 | - erase 17 | - name: Wipe the lab 18 | cisco.cml.cml_lab: 19 | host: "{{ cml_host }}" 20 | user: "{{ cml_username }}" 21 | password: "{{ cml_password }}" 22 | lab: "{{ cml_lab }}" 23 | state: wiped 24 | tags: 25 | - wipe 26 | - erase 27 | - name: Erase the lab 28 | cisco.cml.cml_lab: 29 | host: "{{ cml_host }}" 30 | user: "{{ cml_username }}" 31 | password: "{{ cml_password }}" 32 | lab: "{{ cml_lab }}" 33 | state: absent 34 | tags: 35 | - erase 36 | -------------------------------------------------------------------------------- /plugins/doc_fragments/cml.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, print_function 2 | 3 | __metaclass__ = type 4 | 5 | 6 | class ModuleDocFragment(object): 7 | # Standard files for documentation fragment 8 | DOCUMENTATION = r''' 9 | notes: 10 | - This should be run with connection C(local) 11 | options: 12 | host: 13 | description: FQDN of the target host (CML_HOST) 14 | required: true 15 | type: str 16 | username: 17 | description: user credential for target system (CML_USERNAME) 18 | required: true 19 | type: str 20 | aliases: 21 | - user 22 | password: 23 | description: user pass for the target system (CML_PASSWORD) 24 | required: true 25 | type: str 26 | timeout: 27 | description: API Timeout 28 | required: false 29 | type: int 30 | default: 30 31 | validate_certs: 32 | description: certificate validation (CML_VALIDATE_CERTS) 33 | required: false 34 | type: bool 35 | default: false 36 | ''' 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | # Run CI against all pushes (direct commits, also merged PRs), Pull Requests 4 | push: 5 | pull_request: 6 | # Run CI once per day (at 06:00 UTC) 7 | # This ensures that even if there haven't been commits that we are still testing against latest version of ansible-test for each ansible-base version 8 | schedule: 9 | - cron: '0 6 * * *' 10 | env: 11 | NAMESPACE: cisco 12 | COLLECTION_NAME: cml 13 | 14 | jobs: 15 | ### 16 | # Sanity tests (REQUIRED) 17 | # 18 | # https://docs.ansible.com/ansible/latest/dev_guide/testing_sanity.html 19 | 20 | sanity: 21 | name: Sanity (Ⓐ${{ matrix.ansible }}) 22 | strategy: 23 | matrix: 24 | ansible: 25 | # It's important that Sanity is tested against all stable-X.Y branches 26 | # Testing against `devel` may fail as new tests are added. 27 | - stable-2.15 28 | - stable-2.16 29 | - devel 30 | runs-on: ubuntu-latest 31 | steps: 32 | 33 | # ansible-test requires the collection to be in a directory in the form 34 | # .../ansible_collections/${{env.NAMESPACE}}/${{env.COLLECTION_NAME}}/ 35 | 36 | - name: Check out code 37 | uses: actions/checkout@v2 38 | with: 39 | path: ansible_collections/${{env.NAMESPACE}}/${{env.COLLECTION_NAME}} 40 | 41 | - name: Set up Python 42 | uses: actions/setup-python@v2 43 | with: 44 | # it is just required to run that once as "ansible-test sanity" in the docker image 45 | # will run on all python versions it supports. 46 | python-version: '3.10' 47 | 48 | # Install the head of the given branch (devel, stable-2.10) 49 | - name: Install ansible-base (${{ matrix.ansible }}) 50 | run: pip install https://github.com/ansible/ansible/archive/${{ matrix.ansible }}.tar.gz --disable-pip-version-check 51 | 52 | # run ansible-test sanity inside of Docker. 53 | # The docker container has all the pinned dependencies that are required 54 | # and all python versions ansible supports. 55 | - name: Run sanity tests 56 | run: ansible-test sanity --docker -v --color 57 | working-directory: ./ansible_collections/${{env.NAMESPACE}}/${{env.COLLECTION_NAME}} 58 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile 2 | PYTHON_EXE = python3.10 3 | COLLECTION_NAME="cisco.cml" 4 | COLLECTION_VERSION := $(shell awk '/^version:/{print $$NF}' galaxy.yml) 5 | TARBALL_NAME=cisco-cml-${COLLECTION_VERSION}.tar.gz 6 | PYDIRS="plugins" 7 | VENV = venv 8 | VENV_BIN=$(VENV)/bin 9 | 10 | help: ## Display help 11 | @awk -F ':|##' \ 12 | '/^[^\t].+?:.*?##/ {\ 13 | printf "\033[36m%-30s\033[0m %s\n", $$1, $$NF \ 14 | }' $(MAKEFILE_LIST) 15 | 16 | all: clean build test publish ## Setup python-viptela env and run tests 17 | 18 | # venv: ## Creates the needed virtual environment. 19 | # test -d $(VENV) || $(PYTHON_EXE) -m venv $(VENV) $(ARGS) 20 | 21 | $(VENV): $(VENV_BIN)/activate ## Build virtual environment 22 | 23 | $(VENV_BIN)/activate: 24 | test -d $(VENV) || $(PYTHON_EXE) -m venv $(VENV) 25 | . $(VENV_BIN)/activate 26 | 27 | $(TARBALL_NAME): galaxy.yml 28 | @ansible-galaxy collection build 29 | 30 | build: $(TARBALL_NAME) ## Build Collection 31 | 32 | publish: $(TARBALL_NAME) ## Publish Collection 33 | ansible-galaxy collection publish $(TARBALL_NAME) 34 | 35 | format: ## Format Python code 36 | yapf --style=yapf.ini -i -r *.py $(PYDIRS) 37 | 38 | test: $(VENV) $(TARBALL_NAME) ## Run Sanity Tests 39 | $(RM) -r ./ansible_collections 40 | ansible-galaxy collection install --force $(TARBALL_NAME) -p ./ansible_collections 41 | cd ./ansible_collections/cisco/cml && git init . 42 | $(VENV_BIN)/pip uninstall -y ansible-base 43 | $(VENV_BIN)/pip install https://github.com/ansible/ansible/archive/stable-2.13.tar.gz --disable-pip-version-check 44 | cd ./ansible_collections/cisco/cml && ../../../$(VENV_BIN)/ansible-test sanity --docker -v --color 45 | $(VENV_BIN)/pip uninstall -y ansible-base 46 | $(VENV_BIN)/pip install https://github.com/ansible/ansible/archive/stable-2.14.tar.gz --disable-pip-version-check 47 | cd ./ansible_collections/cisco/cml && ../../../$(VENV_BIN)/ansible-test sanity --docker -v --color 48 | $(VENV_BIN)/pip uninstall -y ansible-base 49 | $(VENV_BIN)/pip install https://github.com/ansible/ansible/archive/devel.tar.gz --disable-pip-version-check 50 | cd ./ansible_collections/cisco/cml && ../../../$(VENV_BIN)/ansible-test sanity --docker -v --color 51 | $(RM) -r ./ansible_collections 52 | 53 | clean: ## Clean 54 | $(RM) $(TARBALL_NAME) 55 | $(RM) -r ./ansible_collections 56 | $(RM) -r ./venv 57 | 58 | .PHONY: all clean build test publish 59 | -------------------------------------------------------------------------------- /plugins/module_utils/cml_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import (absolute_import, division, print_function) 2 | 3 | __metaclass__ = type 4 | import traceback 5 | from ansible.module_utils.basic import env_fallback, missing_required_lib 6 | 7 | VIRL2CLIENT_IMPORT_ERROR = None 8 | try: 9 | from virl2_client import ClientLibrary 10 | except ImportError: 11 | HAS_VIRL2CLIENT = False 12 | VIRL2CLIENT_IMPORT_ERROR = traceback.format_exc() 13 | else: 14 | HAS_VIRL2CLIENT = True 15 | 16 | 17 | def cml_argument_spec(): 18 | return dict(host=dict(type='str', required=True, fallback=(env_fallback, ['CML_HOST'])), 19 | username=dict(type='str', required=True, aliases=['user'], fallback=(env_fallback, ['CML_USERNAME'])), 20 | password=dict(type='str', required=True, no_log=True, fallback=(env_fallback, ['CML_PASSWORD'])), 21 | validate_certs=dict(type='bool', required=False, default=False), 22 | timeout=dict(type='int', default=30)) 23 | 24 | 25 | class cmlModule(object): 26 | 27 | def __init__(self, module, function=None): 28 | self.module = module 29 | self.params = module.params 30 | self.result = dict(changed=False) 31 | self.headers = dict() 32 | self.function = function 33 | self.cookies = None 34 | self.json = None 35 | 36 | self.method = None 37 | self.path = None 38 | self.response = None 39 | self.status = None 40 | self.url = None 41 | self.params['force_basic_auth'] = True 42 | self.user = self.params['user'] 43 | self.password = self.params['password'] 44 | self.host = self.params['host'] 45 | self.timeout = self.params['timeout'] 46 | self.modifiable_methods = ['POST', 'PUT', 'DELETE'] 47 | 48 | self.client = None 49 | 50 | if not HAS_VIRL2CLIENT: 51 | module.fail_json(msg=missing_required_lib('virl2_client'), exception=VIRL2CLIENT_IMPORT_ERROR) 52 | 53 | self.login() 54 | 55 | def login(self): 56 | self.client = ClientLibrary('https://{0}'.format(self.host), self.user, self.password, ssl_verify=False) 57 | 58 | def get_lab_by_name(self, name): 59 | for lab in self.client.all_labs(): 60 | if lab.name == name: 61 | return lab 62 | return None 63 | 64 | def get_node_by_name(self, lab, name): 65 | for node in lab.nodes(): 66 | if node.label == name: 67 | return node 68 | return None 69 | 70 | def exit_json(self, **kwargs): 71 | 72 | self.result.update(**kwargs) 73 | self.module.exit_json(**self.result) 74 | 75 | def fail_json(self, msg, **kwargs): 76 | 77 | self.result.update(**kwargs) 78 | self.module.fail_json(msg=msg, **self.result) 79 | -------------------------------------------------------------------------------- /galaxy.yml: -------------------------------------------------------------------------------- 1 | ### REQUIRED 2 | # The namespace of the collection. This can be a company/brand/organization or product namespace under which all 3 | # content lives. May only contain alphanumeric lowercase characters and underscores. Namespaces cannot start with 4 | # underscores or numbers and cannot contain consecutive underscores 5 | namespace: cisco 6 | 7 | # The name of the collection. Has the same character restrictions as 'namespace' 8 | name: cml 9 | 10 | # The version of the collection. Must be compatible with semantic versioning 11 | version: 1.2.1 12 | 13 | # The path to the Markdown (.md) readme file. This path is relative to the root of the collection 14 | readme: README.md 15 | 16 | # A list of the collection's content authors. Can be just the name or in the format 'Full Name (url) 17 | # @nicks:irc/im.site#channel' 18 | authors: 19 | - Steven Carter 20 | 21 | ### OPTIONAL but strongly recommended 22 | # A short summary description of the collection 23 | description: Ansible Collection for Cisco Modelling Labs 24 | 25 | # Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only 26 | # accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file' 27 | license: 28 | - GPL-2.0-or-later 29 | 30 | # The path to the license file for the collection. This path is relative to the root of the collection. This key is 31 | # mutually exclusive with 'license' 32 | license_file: '' 33 | 34 | # A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character 35 | # requirements as 'namespace' and 'name' 36 | tags: 37 | - networking 38 | - wireless 39 | - firewall 40 | - switching 41 | - cisco 42 | 43 | # Collections that this collection requires to be installed for it to be usable. The key of the dict is the 44 | # collection label 'namespace.name'. The value is a version range 45 | # L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version 46 | # range specifiers can be set and are separated by ',' 47 | dependencies: {} 48 | 49 | # The URL of the originating SCM repository 50 | repository: https://github.com/CiscoDevNet/ansible-cml 51 | 52 | # The URL to any online docs 53 | documentation: https://github.com/CiscoDevNet/ansible-cml 54 | 55 | # The URL to the homepage of the collection/project 56 | homepage: https://github.com/CiscoDevNet/ansible-cml 57 | 58 | # The URL to the collection issue tracker 59 | issues: https://github.com/CiscoDevNet/ansible-cml/issues 60 | 61 | # A list of file glob-like patterns used to filter any files or directories that should not be included in the build 62 | # artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This 63 | # uses 'fnmatch' to match the files or directories. Some directories and files like 'galaxy.yml', '*.pyc', '*.retry', 64 | # and '.git' are always filtered 65 | build_ignore: 66 | - '*tar.gz' 67 | - '*.DS_Store' 68 | - '*.json' 69 | - 'venv*' 70 | - '.vscode' 71 | - '.gitignore' 72 | - '.env' 73 | - '.github' 74 | - 'tests/output/' 75 | - 'ansible_collections/' 76 | -------------------------------------------------------------------------------- /playbooks/build.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/ansible-playbook.json 2 | - name: Build the topology 3 | hosts: localhost 4 | gather_facts: no 5 | vars: 6 | startup: 'all' # Specify 'host' for per host startup 7 | wait: 'no' 8 | tasks: 9 | - name: Check for the lab file 10 | stat: 11 | path: "{{ cml_lab_file }}" 12 | register: stat_result 13 | delegate_to: localhost 14 | run_once: yes 15 | 16 | - assert: 17 | that: 18 | - stat_result.stat.exists 19 | - cml_host != "" 20 | - cml_username != "" 21 | - cml_password != "" 22 | - cml_lab != "" 23 | - cml_lab_file != "" 24 | msg: "CML host, credentials, and topology file are required. Verify the requirements in README are met." 25 | delegate_to: localhost 26 | run_once: yes 27 | 28 | - name: Create the lab 29 | cisco.cml.cml_lab: 30 | host: "{{ cml_host }}" 31 | user: "{{ cml_username }}" 32 | password: "{{ cml_password }}" 33 | lab: "{{ cml_lab }}" 34 | state: "{{ 'started' if startup == 'all' else 'present' }}" 35 | topology: "{{ lookup('template', cml_lab_file) }}" 36 | wait: "{{ wait if startup == 'all' else 'yes' }}" 37 | register: results 38 | 39 | - name: Check to see if the Lab is there 40 | cisco.cml.cml_lab_facts: 41 | host: "{{ cml_host }}" 42 | user: "{{ cml_username }}" 43 | password: "{{ cml_password }}" 44 | lab: "{{ cml_lab }}" 45 | register: cml_lab_facts 46 | when: wait | bool 47 | 48 | - name: Refresh Inventory 49 | meta: refresh_inventory 50 | 51 | - name: Start Individual Nodes 52 | hosts: cml_hosts 53 | connection: local 54 | gather_facts: no 55 | vars: 56 | startup: 'all' 57 | tasks: 58 | - block: 59 | - block: 60 | - name: Check for the cml_config_file 61 | stat: 62 | path: "{{ cml_config_file }}" 63 | register: stat_result 64 | delegate_to: localhost 65 | 66 | - name: Read in cml_config_file 67 | set_fact: 68 | cml_config_content: "{{ lookup('template', cml_config_file) | default('') }}" 69 | when: stat_result.stat.exists 70 | when: cml_config_file is defined and cml_config_file 71 | 72 | - name: Start Individual Nodes 73 | cisco.cml.cml_node: 74 | name: "{{ inventory_hostname }}" 75 | host: "{{ cml_host }}" 76 | user: "{{ cml_username }}" 77 | password: "{{ cml_password }}" 78 | config: "{{ cml_config_content | default(omit, true) }}" 79 | lab: "{{ cml_lab }}" 80 | state: started 81 | delegate_to: localhost 82 | when: startup == 'host' 83 | 84 | - name: Wait for Topology to BOOT 85 | hosts: localhost 86 | gather_facts: no 87 | tags: 88 | - wait 89 | vars: 90 | startup: 'all' # Specify 'host' for per host startup 91 | wait: 'no' 92 | retries: 40 93 | tasks: 94 | - name: Check to see if all hosts are BOOTED 95 | cisco.cml.cml_lab_facts: 96 | host: "{{ cml_host }}" 97 | user: "{{ cml_username }}" 98 | password: "{{ cml_password }}" 99 | lab: "{{ cml_lab }}" 100 | register: cml_lab_facts 101 | until: (states | length == 1) and (states[0] == 'BOOTED') 102 | retries: "{{ retries }}" 103 | delay: 15 104 | vars: 105 | states: "{{ cml_lab_facts.cml_facts.nodes | default({}) | dict2items | selectattr('value.state','defined') | map(attribute='value.state') | unique | list }}" 106 | when: wait | bool -------------------------------------------------------------------------------- /plugins/modules/cml_lab_facts.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2017 Cisco and/or its affiliates. 5 | # 6 | # This file is part of Ansible 7 | # 8 | # Ansible is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # Ansible is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with Ansible. If not, see . 20 | # 21 | 22 | from __future__ import (absolute_import, division, print_function) 23 | 24 | __metaclass__ = type 25 | 26 | ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} 27 | 28 | DOCUMENTATION = r""" 29 | --- 30 | module: cml_lab_facts 31 | short_description: Get facts about a CML Lab 32 | description: 33 | - Get facts about a CML Lab 34 | author: 35 | - Steven Carter (@stevenca) 36 | requirements: 37 | - virl2_client 38 | version_added: '0.1.0' 39 | options: 40 | lab: 41 | description: The name of the CML lab (CML_LAB) 42 | required: true 43 | type: str 44 | extends_documentation_fragment: cisco.cml.cml 45 | """ 46 | EXAMPLES = r""" 47 | - name: Check initial topology connectivity 48 | hosts: localhost 49 | connection: local 50 | gather_facts: no 51 | tasks: 52 | - name: Get facts about a lab in CML 53 | cisco.cml.cml_lab_facts: 54 | host: "{{ cml_host }}" 55 | user: "{{ cml_username }}" 56 | password: "{{ cml_password }}" 57 | lab: "{{ cml_lab }}" 58 | register: results 59 | 60 | - debug: 61 | var: results 62 | """ 63 | 64 | from ansible.module_utils.basic import AnsibleModule 65 | from ansible_collections.cisco.cml.plugins.module_utils.cml_utils import cmlModule, cml_argument_spec 66 | 67 | 68 | def run_module(): 69 | # define available arguments/parameters a user can pass to the module 70 | argument_spec = cml_argument_spec() 71 | argument_spec.update(lab=dict(type='str', required=True), ) 72 | 73 | # the AnsibleModule object will be our abstraction working with Ansible 74 | # this includes instantiation, a couple of common attr would be the 75 | # args/params passed to the execution, as well as if the module 76 | # supports check mode 77 | module = AnsibleModule( 78 | argument_spec=argument_spec, 79 | supports_check_mode=True, 80 | ) 81 | cml = cmlModule(module) 82 | cml_facts = {} 83 | labs = cml.client.find_labs_by_title(cml.params['lab']) 84 | if len(labs): 85 | # Just take the first lab until we figure out how we want 86 | # to handle duplicates 87 | lab = labs[0] 88 | lab.sync() 89 | cml_facts['details'] = lab.details() 90 | cml_facts['nodes'] = {} 91 | for node in lab.nodes(): 92 | cml_facts['nodes'][node.label] = { 93 | 'state': node.state, 94 | 'image_definition': node.image_definition, 95 | 'node_definition': node.node_definition, 96 | 'cpus': node.cpus, 97 | 'ram': node.ram, 98 | 'config': node.config, 99 | 'data_volume': node.data_volume, 100 | 'tags': node.tags(), 101 | 'interfaces': {} 102 | } 103 | ansible_host = None 104 | ansible_host_interface = None 105 | for interface in node.interfaces(): 106 | if node.state == 'BOOTED': 107 | # Fill out the oper data if the node is not fully booted 108 | interface_data = { 109 | 'state': interface.state, 110 | 'ipv4_addresses': interface.discovered_ipv4, 111 | 'ipv6_addresses': interface.discovered_ipv6, 112 | 'mac_address': interface.discovered_mac_address, 113 | 'is_physical': interface.is_physical, 114 | 'readbytes': interface.readbytes, 115 | 'readpackets': interface.readpackets, 116 | 'writebytes': interface.writebytes, 117 | 'writepackets': interface.writepackets 118 | } 119 | # See if we can use this for ansible_host 120 | if interface.discovered_ipv4 and not ansible_host: 121 | ansible_host = interface.discovered_ipv4[0] 122 | ansible_host_interface = interface.label 123 | else: 124 | # Otherwise, set oper data to empty 125 | interface_data = { 126 | 'state': interface.state, 127 | 'ipv4_addresses': [], 128 | 'ipv6_addresses': [], 129 | 'mac_address': None, 130 | 'is_physical': interface.is_physical, 131 | 'readbytes': interface.readbytes, 132 | 'readpackets': interface.readpackets, 133 | 'writebytes': interface.writebytes, 134 | 'writepackets': interface.writepackets 135 | } 136 | cml_facts['nodes'][node.label]['interfaces'][interface.label] = interface_data 137 | cml_facts['nodes'][node.label]['ansible_host'] = ansible_host 138 | cml_facts['nodes'][node.label]['ansible_host_interface'] = ansible_host_interface 139 | cml.result['cml_facts'] = cml_facts 140 | cml.exit_json(**cml.result) 141 | 142 | 143 | def main(): 144 | run_module() 145 | 146 | 147 | if __name__ == '__main__': 148 | main() 149 | -------------------------------------------------------------------------------- /plugins/modules/cml_users.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2017 Cisco and/or its affiliates. 5 | # 6 | # This file is part of Ansible 7 | # 8 | # Ansible is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # Ansible is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with Ansible. If not, see . 20 | # 21 | 22 | from __future__ import (absolute_import, division, print_function) 23 | 24 | __metaclass__ = type 25 | 26 | ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} 27 | 28 | DOCUMENTATION = r""" 29 | --- 30 | module: cml_users 31 | short_description: Manage CML Users 32 | description: 33 | - Manage CML Users 34 | author: 35 | - Yoshitaka Nagami (@exjobo) 36 | requirements: 37 | - virl2_client 38 | version_added: '0.1.0' 39 | extends_documentation_fragment: cisco.cml.cml 40 | options: 41 | name: 42 | description: 43 | - Name of the user to create, remove or modify. 44 | type: str 45 | required: true 46 | fullname: 47 | description: 48 | - Full Name of the user to create, remove or modify. 49 | type: str 50 | default: "" 51 | user_pass: 52 | description: 53 | - Desired password. 54 | type: str 55 | state: 56 | description: 57 | - Whether the account should exist or not, taking action if the state is different from what is stated. 58 | type: str 59 | choices: [ absent, present ] 60 | default: present 61 | admin: 62 | description: 63 | - Whether to create admin user. 64 | type: bool 65 | default: no 66 | groups: 67 | description: 68 | - List of groups user will be added to. 69 | type: list 70 | elements: str 71 | default: [] 72 | description: 73 | description: 74 | - Optionally sets the description of user account. 75 | type: str 76 | default: "" 77 | """ 78 | EXAMPLES = r""" 79 | - name: Manage users 80 | hosts: localhost 81 | connection: local 82 | gather_facts: no 83 | tasks: 84 | - name: Add users to the CML instance 85 | cisco.cml.cml_users: 86 | host: "{{ cml_host }}" 87 | user: "{{ cml_username }}" 88 | password: "{{ cml_password }}" 89 | name: "first_user" 90 | user_pass: "password" 91 | admin: yes 92 | state: "present" 93 | 94 | - name: Remove users from the CML instance 95 | cisco.cml.cml_users: 96 | host: "{{ cml_host }}" 97 | user: "{{ cml_username }}" 98 | password: "{{ cml_password }}" 99 | name: "old_user" 100 | state: "absent" 101 | """ 102 | 103 | import traceback 104 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib 105 | from ansible_collections.cisco.cml.plugins.module_utils.cml_utils import cmlModule, cml_argument_spec 106 | 107 | REQUESTS_IMPORT_ERROR = None 108 | try: 109 | import requests 110 | except ImportError: 111 | HAS_REQUESTS = False 112 | REQUESTS_IMPORT_ERROR = traceback.format_exc() 113 | else: 114 | HAS_REQUESTS = True 115 | 116 | 117 | def get_userid(cml): 118 | try: 119 | userid = cml.client.user_management.user_id(cml.params['name']) 120 | return userid 121 | except requests.exceptions.RequestException as e: 122 | if e.response.status_code == 404: 123 | return None 124 | else: 125 | cml.fail_json(name=cml.params['name'], msg=e, rc=-1) 126 | 127 | 128 | def run_module(): 129 | # define available arguments/parameters a user can pass to the module 130 | argument_spec = cml_argument_spec() 131 | argument_spec.update( 132 | name=dict(type='str', required=True), 133 | fullname=dict(type='str', default=""), 134 | user_pass=dict(type='str', no_log=True), 135 | state=dict(type='str', default='present', choices=['absent', 'present']), 136 | admin=dict(type='bool', default=False), 137 | groups=dict(type='list', elements='str', default=[]), 138 | description=dict(type='str', default=""), 139 | ) 140 | 141 | # the AnsibleModule object will be our abstraction working with Ansible 142 | # this includes instantiation, a couple of common attr would be the 143 | # args/params passed to the execution, as well as if the module 144 | # supports check mode 145 | module = AnsibleModule( 146 | argument_spec=argument_spec, 147 | supports_check_mode=True, 148 | ) 149 | cml = cmlModule(module) 150 | cml.result['changed'] = False 151 | cml.result['name'] = cml.params['name'] 152 | cml.result['state'] = cml.params['state'] 153 | userid = get_userid(cml) 154 | 155 | if not HAS_REQUESTS: 156 | # Needs: from ansible.module_utils.basic import missing_required_lib 157 | module.fail_json(msg=missing_required_lib('requests'), exception=REQUESTS_IMPORT_ERROR) 158 | 159 | if cml.params['state'] == 'present': 160 | if userid is None: 161 | if module.check_mode: 162 | module.exit_json(changed=True) 163 | module.debug('Create user %s' % cml.params['name']) 164 | try: 165 | cml.client.user_management.create_user( 166 | username=cml.params['name'], 167 | pwd=cml.params['user_pass'], 168 | fullname=cml.params['fullname'], 169 | description=cml.params['description'], 170 | admin=cml.params['admin'], 171 | groups=cml.params['groups'], 172 | ) 173 | cml.result['changed'] = True 174 | except requests.exceptions.RequestException as e: 175 | cml.fail_json(name=cml.params['name'], msg=e, rc=-1) 176 | elif cml.params['state'] == 'absent': 177 | if userid is not None: 178 | if module.check_mode: 179 | module.exit_json(changed=True) 180 | try: 181 | cml.client.user_management.delete_user(userid) 182 | cml.result['changed'] = True 183 | except requests.exceptions.RequestException as e: 184 | cml.fail_json(name=cml.params['name'], msg=e, rc=-1) 185 | 186 | cml.exit_json(**cml.result) 187 | 188 | 189 | def main(): 190 | run_module() 191 | 192 | 193 | if __name__ == '__main__': 194 | main() 195 | -------------------------------------------------------------------------------- /plugins/modules/cml_node.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2017 Cisco and/or its affiliates. 5 | # 6 | # This file is part of Ansible 7 | # 8 | # Ansible is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # Ansible is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with Ansible. If not, see . 20 | # 21 | 22 | from __future__ import (absolute_import, division, print_function) 23 | 24 | __metaclass__ = type 25 | 26 | ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} 27 | 28 | DOCUMENTATION = r""" 29 | --- 30 | module: cml_node 31 | short_description: Create, update or delete a node in a CML Lab 32 | description: 33 | - Create, update or delete a node in a CML Lab 34 | author: 35 | - Steven Carter (@stevenca) 36 | requirements: 37 | - virl2_client 38 | version_added: '0.1.0' 39 | options: 40 | state: 41 | description: The desired state of the node 42 | required: false 43 | type: str 44 | choices: ['absent', 'present', 'started', 'stopped', 'wiped'] 45 | default: present 46 | lab: 47 | description: The name of the CML lab (CML_LAB) 48 | required: true 49 | type: str 50 | name: 51 | description: The name of the node 52 | required: true 53 | type: str 54 | 55 | node_definition: 56 | description: The node definition of this node 57 | required: false 58 | type: str 59 | 60 | image_definition: 61 | description: The image definition of this node 62 | required: false 63 | type: str 64 | 65 | config: 66 | description: The day0 configuration of this node 67 | required: false 68 | type: str 69 | 70 | x: 71 | description: X coordinate on topology canvas 72 | required: false 73 | type: int 74 | 75 | y: 76 | description: Y coordinate on topology canvas 77 | required: false 78 | type: int 79 | 80 | tags: 81 | description: List of tags 82 | required: false 83 | type: list 84 | elements: str 85 | 86 | wait: 87 | description: Wait for lab virtual machines to boot before continuing 88 | required: false 89 | type: bool 90 | default: False 91 | extends_documentation_fragment: cisco.cml.cml 92 | """ 93 | 94 | EXAMPLES = r""" 95 | - name: Start the CML nodes 96 | hosts: cml_hosts 97 | connection: local 98 | gather_facts: no 99 | tasks: 100 | - name: Generating day0 config 101 | set_fact: 102 | day0_config: "{{ lookup('template', cml_config_template) }}" 103 | when: cml_config_template is defined 104 | 105 | - name: Start Node 106 | cisco.cml.cml_node: 107 | name: "{{ inventory_hostname }}" 108 | host: "{{ cml_host }}" 109 | user: "{{ cml_username }}" 110 | password: "{{ cml_password }}" 111 | lab: "{{ cml_lab }}" 112 | state: started 113 | image_definition: "{{ cml_image_definition | default(omit) }}" 114 | config: "{{ day0_config | default(omit) }}" 115 | """ 116 | 117 | from ansible.module_utils.basic import AnsibleModule, env_fallback 118 | from ansible_collections.cisco.cml.plugins.module_utils.cml_utils import cmlModule, cml_argument_spec 119 | 120 | 121 | def run_module(): 122 | # define available arguments/parameters a user can pass to the module 123 | argument_spec = cml_argument_spec() 124 | argument_spec.update( 125 | state=dict(type='str', choices=['absent', 'present', 'started', 'stopped', 'wiped'], default='present'), 126 | name=dict(type='str', required=True), 127 | lab=dict(type='str', required=True, fallback=(env_fallback, ['CML_LAB'])), 128 | node_definition=dict(type='str'), 129 | image_definition=dict(type='str'), 130 | config=dict(type='str'), 131 | tags=dict(type='list', elements='str'), 132 | x=dict(type='int'), 133 | y=dict(type='int'), 134 | wait=dict(type='bool', default=False), 135 | ) 136 | 137 | # the AnsibleModule object will be our abstraction working with Ansible 138 | # this includes instantiation, a couple of common attr would be the 139 | # args/params passed to the execution, as well as if the module 140 | # supports check mode 141 | module = AnsibleModule( 142 | argument_spec=argument_spec, 143 | supports_check_mode=True, 144 | ) 145 | cml = cmlModule(module) 146 | 147 | labs = cml.client.find_labs_by_title(cml.params['lab']) 148 | if len(labs) > 0: 149 | lab = labs[0] 150 | else: 151 | cml.fail_json("Cannot find lab {0}".format(cml.params['lab'])) 152 | 153 | node = cml.get_node_by_name(lab, cml.params['name']) 154 | if cml.params['state'] == 'present': 155 | if node is None: 156 | node = lab.create_node(label=cml.params['name'], node_definition=cml.params['node_definition']) 157 | cml.result['changed'] = True 158 | elif cml.params['state'] == 'started': 159 | if node is None: 160 | cml.fail_json("Node must be created before it is started") 161 | if node.state not in ['STARTED', 'BOOTED']: 162 | if node.state == 'DEFINED_ON_CORE' and cml.params['config']: 163 | node.config = cml.params['config'] 164 | if cml.params['image_definition']: 165 | node.image_definition = cml.params['image_definition'] 166 | if cml.params['wait'] is False: 167 | lab.wait_for_covergence = False 168 | node.start() 169 | cml.result['changed'] = True 170 | elif cml.params['state'] == 'stopped': 171 | if node is None: 172 | cml.fail_json("Node must be created before it is stopped") 173 | if node.state not in ['STOPPED', 'DEFINED_ON_CORE']: 174 | if cml.params['wait'] is False: 175 | lab.wait_for_covergence = False 176 | node.stop() 177 | cml.result['changed'] = True 178 | elif cml.params['state'] == 'wiped': 179 | if node is None: 180 | cml.fail_json("Node must be created before it is wiped") 181 | if node.state not in ['DEFINED_ON_CORE']: 182 | node.wipe(wait=cml.params['wait']) 183 | cml.result['changed'] = True 184 | cml.exit_json(**cml.result) 185 | 186 | 187 | def main(): 188 | run_module() 189 | 190 | 191 | if __name__ == '__main__': 192 | main() 193 | -------------------------------------------------------------------------------- /plugins/modules/cml_lab.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (c) 2017 Cisco and/or its affiliates. 5 | # 6 | # This file is part of Ansible 7 | # 8 | # Ansible is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # Ansible is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with Ansible. If not, see . 20 | # 21 | 22 | from __future__ import (absolute_import, division, print_function) 23 | 24 | __metaclass__ = type 25 | 26 | ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} 27 | 28 | DOCUMENTATION = r""" 29 | --- 30 | module: cml_lab 31 | short_description: Create, update or delete a CML Lab 32 | description: 33 | - Create, update or delete a CML Lab 34 | author: 35 | - Steven Carter (@stevenca) 36 | requirements: 37 | - virl2_client 38 | version_added: '0.1.0' 39 | options: 40 | lab: 41 | description: The name of the CML lab (CML_LAB) 42 | required: true 43 | type: str 44 | file: 45 | description: The name of the topology file to use. 46 | required: false 47 | type: str 48 | topology: 49 | description: The lab topology. 50 | required: false 51 | type: str 52 | state: 53 | description: The desired state of the lab 54 | required: false 55 | type: str 56 | choices: ['absent', 'present', 'started', 'stopped', 'wiped'] 57 | default: present 58 | wait: 59 | description: Wait for lab virtual machines to boot before continuing 60 | required: false 61 | type: bool 62 | default: True 63 | extends_documentation_fragment: cisco.cml.cml 64 | """ 65 | 66 | EXAMPLES = r""" 67 | - name: Build the topology 68 | hosts: localhost 69 | gather_facts: no 70 | tags: 71 | - virl 72 | - network 73 | tasks: 74 | - name: Check for the lab file 75 | stat: 76 | path: "{{ cml_lab_file }}" 77 | register: stat_result 78 | delegate_to: localhost 79 | run_once: yes 80 | 81 | - assert: 82 | that: 83 | - stat_result.stat.exists 84 | - cml_host != "" 85 | - cml_username != "" 86 | - cml_password != "" 87 | - cml_lab != "" 88 | msg: "CML host, credentials, and topology file are required. Verify the requirements in README are met." 89 | delegate_to: localhost 90 | run_once: yes 91 | 92 | - name: Create the lab 93 | cisco.cml.cml_lab: 94 | host: "{{ cml_host }}" 95 | user: "{{ cml_username }}" 96 | password: "{{ cml_password }}" 97 | lab: "{{ cml_lab }}" 98 | state: present 99 | file: "{{ cml_lab_file }}" 100 | register: results 101 | 102 | - name: Refresh Inventory 103 | meta: refresh_inventory 104 | """ 105 | 106 | from ansible_collections.cisco.cml.plugins.module_utils.cml_utils import cmlModule, cml_argument_spec 107 | from ansible.module_utils.basic import AnsibleModule, env_fallback 108 | import os 109 | 110 | 111 | def run_module(): 112 | # define available arguments/parameters a user can pass to the module 113 | argument_spec = cml_argument_spec() 114 | argument_spec.update(state=dict(type='str', 115 | choices=['absent', 'present', 'started', 'stopped', 'wiped'], 116 | default='present'), 117 | lab=dict(type='str', required=True, fallback=(env_fallback, ['CML_LAB'])), 118 | file=dict(type='str'), 119 | topology=dict(type='str'), 120 | wait=dict(type='bool', default=True)) 121 | 122 | # the AnsibleModule object will be our abstraction working with Ansible 123 | # this includes instantiation, a couple of common attr would be the 124 | # args/params passed to the execution, as well as if the module 125 | # supports check mode 126 | module = AnsibleModule( 127 | argument_spec=argument_spec, 128 | supports_check_mode=True, 129 | ) 130 | cml = cmlModule(module) 131 | cml.result['changed'] = False 132 | labs = cml.client.find_labs_by_title(cml.params['lab']) 133 | if len(labs) > 0: 134 | lab = labs[0] 135 | else: 136 | lab = None 137 | 138 | if cml.params['state'] == 'present': 139 | if lab is None: 140 | if cml.params['topology']: 141 | lab = cml.client.import_lab(cml.params['topology'], title=cml.params['lab']) 142 | elif cml.params['file']: 143 | if os.path.isabs(cml.params['file']): 144 | topology_file = cml.params['file'] 145 | else: 146 | topology_file = os.getcwd() + '/' + cml.params['file'] 147 | lab = cml.client.import_lab_from_path(topology_file, title=cml.params['lab']) 148 | else: 149 | lab = cml.client.create_lab(title=cml.params['lab']) 150 | lab.title = cml.params['lab'] 151 | cml.result['changed'] = True 152 | elif cml.params['state'] == 'started': 153 | if lab is None: 154 | if cml.params['topology']: 155 | lab = cml.client.import_lab(cml.params['topology'], title=cml.params['lab']) 156 | lab.start(wait=cml.params['wait']) 157 | elif cml.params['file']: 158 | lab = cml.client.import_lab_from_path(cml.params['file'], title=cml.params['lab']) 159 | lab.start(wait=cml.params['wait']) 160 | else: 161 | lab = cml.client.create_lab(title=cml.params['lab']) 162 | lab.start(wait=cml.params['wait']) 163 | lab.title = cml.params['lab'] 164 | cml.result['changed'] = True 165 | elif lab.state() == "STOPPED": 166 | lab.start(wait=cml.params['wait']) 167 | cml.result['changed'] = True 168 | elif cml.params['state'] == 'absent': 169 | if lab: 170 | cml.result['changed'] = True 171 | if lab.state() == "STARTED": 172 | lab.stop(wait=True) 173 | lab.wipe(wait=True) 174 | elif lab.state() == "STOPPED": 175 | lab.wipe(wait=True) 176 | lab.remove() 177 | elif cml.params['state'] == 'stopped': 178 | if lab: 179 | if lab.state() == "STARTED": 180 | cml.result['changed'] = True 181 | lab.stop(wait=True) 182 | elif cml.params['state'] == 'wiped': 183 | if lab: 184 | if lab.state() == "STOPPED": 185 | cml.result['changed'] = True 186 | lab.wipe(wait=True) 187 | 188 | cml.exit_json(**cml.result) 189 | 190 | 191 | def main(): 192 | run_module() 193 | 194 | 195 | if __name__ == '__main__': 196 | main() 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ansible-cml 2 | 3 | Ansible Modules for CML^2 4 | 5 | ## Requirements 6 | 7 | * Ansible v2.9 or newer is required for collection support 8 | 9 | ## What is Cisco Modelling Labs? 10 | 11 | Cisco Modeling Labs is an on-premise network simulation tool that runs on workstations and servers. With Cisco Modeling Labs, you can quickly and easily simulate Cisco and non-Cisco networks, using real Cisco images. This gives you highly reliable models for designing, testing, and troubleshooting. Compared to building out real-world labs, Cisco Modeling Labs returns results faster, more easily, and for a fraction of the cost. 12 | 13 | ## Installation 14 | ### Directly from Ansible Galaxy 15 | 16 | ``` 17 | ansible-galaxy collection install cisco.cml 18 | ``` 19 | 20 | ### via git repository 21 | 22 | ``` 23 | ansible-galaxy collection install 'git@github.com:CiscoDevNet/ansible-cml.git,branch' 24 | ``` 25 | 26 | ## Environmental Variables 27 | 28 | * `CML_USERNAME`: Username for the CML user (used when `host` not specified) 29 | * `CML_PASSWORD`: Password for the CML user (used when `password` not specified) 30 | * `CML_HOST`: The CML host (used when `host` not specified) 31 | * `CML_LAB`: The name of the lab 32 | 33 | ## Inventory 34 | 35 | The dynamic inventory script will then return information about the nodes in the 36 | lab: 37 | 38 | ``` 39 | ok: [hq-rtr1] => { 40 | "cml_facts": { 41 | "config": "hostname hq-rtr1\nvrf definition Mgmt-intf\n!\naddress-family ipv4\nexit-address-family\n!\naddress-family ipv6\nexit-address-family\n!\nusername admin privilege 15 secret 0 admin\ncdp run\nno aaa new-model\nip domain-name mdd.cisco.com\n!\ninterface GigabitEthernet1\nvrf forwarding Mgmt-intf\nip address dhcp\nnegotiation auto\nno cdp enable\nno shutdown\n!\ninterface GigabitEthernet2\ncdp enable\n!\ninterface GigabitEthernet3\ncdp enable\n!\ninterface GigabitEthernet4\ncdp enable\n!\nip http server\nip http secure-server\nip http max-connections 2\n!\nip ssh time-out 60\nip ssh version 2\nip ssh server algorithm encryption aes128-ctr aes192-ctr aes256-ctr\nip ssh client algorithm encryption aes128-ctr aes192-ctr aes256-ctr\n!\nline vty 0 4\nexec-timeout 30 0\nabsolute-timeout 60\nsession-limit 16\nlogin local\ntransport input ssh\n!\nend", 42 | "cpus": 1, 43 | "data_volume": null, 44 | "image_definition": null, 45 | "interfaces": [ 46 | { 47 | "ipv4_addresses": null, 48 | "ipv6_addresses": null, 49 | "mac_address": null, 50 | "name": "Loopback0", 51 | "state": "STARTED" 52 | }, 53 | { 54 | "ipv4_addresses": [ 55 | "192.168.255.199" 56 | ], 57 | "ipv6_addresses": [], 58 | "mac_address": "52:54:00:13:51:66", 59 | "name": "GigabitEthernet1", 60 | "state": "STARTED" 61 | } 62 | ], 63 | "node_definition": "csr1000v", 64 | "ram": 3072, 65 | "state": "BOOTED" 66 | } 67 | } 68 | ``` 69 | 70 | The first IPv4 address found (in order of the interfaces) is used as `ansible_host` to enable the playbook to connect to the device. 71 | 72 | To use the CML dynamic inventory plugin, the environmental variables must be set and a file (e.g. `cml.yml`) placed in the inventory specifying the plugin information: 73 | 74 | ``` 75 | plugin: cisco.cml.cml_inventory 76 | group_tags: network, ios, nxos, router 77 | ``` 78 | 79 | Options: 80 | 81 | `plugin:` Specfies the name of the inventory plugin 82 | `group_tags:` The group tags that, if one or more are found in a CML device tags, will create an Ansible group of the same name 83 | 84 | To create an Ansible group, specify a device tag in CML: 85 | 86 | ![CML Tag Example](cml_group_tag.png?raw=true "CML Tag Example") 87 | 88 | When the CML dynamic inventory plugin runs, it will create a router group with all of the devices that have that tag: 89 | ``` 90 | mdd % ansible-playbook cisco.cml.inventory --limit=router 91 | 92 | PLAY [cml_hosts] ****************************************************************************************************************************** 93 | 94 | TASK [debug] ********************************************************************************************************************************** 95 | ok: [hq-rtr1] => { 96 | "msg": "Node: hq-rtr1(csr1000v), State: BOOTED, Address: 192.168.255.199:22" 97 | } 98 | ok: [hq-rtr2] => { 99 | "msg": "Node: hq-rtr2(csr1000v), State: BOOTED, Address: 192.168.255.53:22" 100 | } 101 | ok: [site1-rtr1] => { 102 | "msg": "Node: site1-rtr1(csr1000v), State: BOOTED, Address: 192.168.255.63:22" 103 | } 104 | ok: [site2-rtr1] => { 105 | "msg": "Node: site2-rtr1(csr1000v), State: BOOTED, Address: 192.168.255.7:22" 106 | } 107 | 108 | PLAY RECAP ************************************************************************************************************************************ 109 | hq-rtr1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 110 | hq-rtr2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 111 | site1-rtr1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 112 | site2-rtr1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 113 | ``` 114 | 115 | In addition to group tags, the CML dynamic inventory plugin will also parse tags to pass information from PATty and to create 116 | generic inventory facts. 117 | 118 | ![PAT Tag Example](cml_pat_tags.png?raw=true "PAT Tag Example") 119 | 120 | If a CML tag is specified that matches `^pat:(?:tcp|udp)?:?(\d+):(\d+)`, the CML server address (as opposed to the first IPv4 address found) 121 | will be used for `ansible_host`. To change `ansible_port` to point to the translated SSH port, the tag `ansible:ansible_port=2020` can 122 | be set. These two tags tell the Ansible playbook to connect to port 2020 of the CML server to automate the specified host in the topology. 123 | The `ansible:` tag can also be used to specify other host facts. For example, the tag `ansible:nso_api_port=2021` can be used to tell the 124 | playbook the port to use to reach the Cisco NSO API. Any arbitrary fact can be set in this way. 125 | 126 | 127 | ## Collection Playbooks 128 | 129 | ### `cisco.cml.build` 130 | 131 | * Build a topology 132 | 133 | extra_vars: 134 | * `startup`: Either `all` to start up all devices at one or `host` to startup devices individually (default: `all`) 135 | * `wait`: Whether to wait for the task to complete before returning (default: `no`) 136 | 137 | notes: 138 | * `cml_lab_file` lab file must be defined and will be read in as a J2 template. 139 | * When `cml_config_file` is specified per host and `-e startup='host'` is specified, the file is read in as a J2 template and fed into the device at startup. 140 | 141 | ### `cisco.cml.clean` 142 | 143 | * Clean a topology 144 | 145 | tags: 146 | * `stop`: Just stop the topology 147 | * `wipe`: Stop and wipe the topology 148 | * `erase`: Stop, wipe, and erase the topology 149 | 150 | ### `cisco.cml.inventory` 151 | 152 | * Show topology Inventory 153 | 154 | ## Example Playbooks 155 | 156 | ### Create a Lab 157 | - name: Create the lab 158 | cisco.cml.cml_lab: 159 | host: "{{ cml_host }}" 160 | user: "{{ cml_username }}" 161 | password: "{{ cml_password }}" 162 | lab: "{{ cml_lab }}" 163 | state: present 164 | file: "{{ cml_lab_file }}" 165 | register: results 166 | 167 | ### Start a Node 168 | 169 | - name: Start Node 170 | cisco.cml.cml_node: 171 | name: "{{ inventory_hostname }}" 172 | host: "{{ cml_host }}" 173 | user: "{{ cml_username }}" 174 | password: "{{ cml_password }}" 175 | lab: "{{ cml_lab }}" 176 | state: started 177 | image_definition: "{{ cml_image_definition | default(omit) }}" 178 | config: "{{ day0_config | default(omit) }}" 179 | 180 | ### Collect facts about the Lab 181 | - name: Collect Facts 182 | cisco.cml.cml_lab_facts: 183 | host: "{{ cml_host }}" 184 | user: "{{ cml_username }}" 185 | password: "{{ cml_password }}" 186 | lab: "{{ cml_lab }}" 187 | register: result 188 | 189 | ## License 190 | 191 | GPLv3 192 | 193 | ## Development 194 | ### Running sanity tests locally 195 | Clean existing build: 196 | ``` 197 | make clean 198 | ``` 199 | 200 | Build the collection: 201 | ``` 202 | make build 203 | ``` 204 | 205 | Test the collection: 206 | ``` 207 | make test 208 | ``` 209 | -------------------------------------------------------------------------------- /plugins/inventory/cml_inventory.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2017 Cisco and/or its affiliates. 4 | # 5 | # This file is part of Ansible 6 | # 7 | # Ansible is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Ansible is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with Ansible. If not, see . 19 | # 20 | 21 | from __future__ import (absolute_import, division, print_function) 22 | 23 | __metaclass__ = type 24 | 25 | DOCUMENTATION = r''' 26 | name: cml_inventory 27 | short_description: Returns Inventory from the CML server 28 | description: 29 | - Retrieves inventory from the CML server 30 | options: 31 | plugin: 32 | description: Name of the plugin 33 | required: true 34 | choices: ['cisco.cml.cml_inventory'] 35 | host: 36 | description: FQDN of the target host 37 | required: false 38 | username: 39 | description: user credential for target system 40 | required: false 41 | password: 42 | description: user pass for the target system 43 | required: false 44 | lab: 45 | description: The name of the cml lab 46 | required: false 47 | group: 48 | description: The name of group in which to put nodes 49 | required: false 50 | group_tags: 51 | description: The list of tags for which to make and populate groups 52 | type: list 53 | elements: string 54 | required: false 55 | validate_certs: 56 | description: certificate validation 57 | required: false 58 | choices: ['yes', 'no'] 59 | ''' 60 | 61 | import os 62 | import traceback 63 | import re 64 | from ansible.plugins.inventory import BaseInventoryPlugin 65 | from ansible.errors import AnsibleError, AnsibleParserError 66 | from ansible.module_utils._text import to_text 67 | 68 | try: 69 | from virl2_client import ClientLibrary 70 | except ImportError: 71 | HAS_VIRL2CLIENT = False 72 | VIRL2CLIENT_IMPORT_ERROR = traceback.format_exc() 73 | else: 74 | HAS_VIRL2CLIENT = True 75 | 76 | 77 | class InventoryModule(BaseInventoryPlugin): 78 | 79 | NAME = 'cisco.cml.cml_inventory' 80 | 81 | def __init__(self): 82 | super(InventoryModule, self).__init__() 83 | 84 | # from config 85 | self.username = None 86 | self.password = None 87 | self.host = None 88 | self.lab = None 89 | self.group = None 90 | 91 | def verify_file(self, path): 92 | 93 | if super(InventoryModule, self).verify_file(path): 94 | endings = ('cml.yaml', 'cml.yml') 95 | if any((path.endswith(ending) for ending in endings)): 96 | return True 97 | self.display.debug("cml inventory filename must end with 'cml.yml' or 'cml.yaml'") 98 | return False 99 | 100 | def parse(self, inventory, loader, path, cache=True): 101 | 102 | # call base method to ensure properties are available for use with other helper methods 103 | super(InventoryModule, self).parse(inventory, loader, path, cache) 104 | 105 | # this method will parse 'common format' inventory sources and 106 | # update any options declared in DOCUMENTATION as needed 107 | # config = self._read_config_data(self, path) 108 | self._read_config_data(path) 109 | 110 | # if NOT using _read_config_data you should call set_options directly, 111 | # to process any defined configuration for this plugin, 112 | # if you dont define any options you can skip 113 | # self.set_options() 114 | 115 | if 'CML_HOST' in os.environ and len(os.environ['CML_HOST']): 116 | self.host = os.environ['CML_HOST'] 117 | else: 118 | self.host = self.get_option('host') 119 | 120 | self.display.vvv("cml.py - CML_HOST: {0}".format(self.host)) 121 | 122 | if 'CML_USERNAME' in os.environ and len(os.environ['CML_USERNAME']): 123 | self.username = os.environ['CML_USERNAME'] 124 | else: 125 | self.username = self.get_option('username') 126 | 127 | self.display.vvv("cml.py - CML_USERNAME: {0}".format(self.username)) 128 | 129 | if 'CML_PASSWORD' in os.environ and len(os.environ['CML_PASSWORD']): 130 | self.password = os.environ['CML_PASSWORD'] 131 | else: 132 | self.password = self.get_option('password') 133 | 134 | if 'CML_LAB' in os.environ and len(os.environ['CML_LAB']): 135 | self.lab = os.environ['CML_LAB'] 136 | else: 137 | self.lab = self.get_option('lab') 138 | 139 | self.display.vvv("cml.py - CML_LAB: {0}".format(self.lab)) 140 | 141 | if not self.lab: 142 | self.display.vvv("No lab defined. Nothing to do.") 143 | return 144 | 145 | self.group_tags = self.get_option('group_tags') 146 | if self.group_tags: 147 | self.display.vvv("cml.py - group_tags: {0}".format(','.join(self.group_tags))) 148 | self.group = self.get_option('group') 149 | if self.group is None: 150 | self.group = 'cml_hosts' 151 | 152 | self.display.vvv("cml.py - Group: {0}".format(self.group)) 153 | self.inventory.set_variable('all', 'cml_group', self.group) 154 | 155 | self.inventory.set_variable('all', 'cml_host', self.host) 156 | self.inventory.set_variable('all', 'cml_username', self.username) 157 | self.inventory.set_variable('all', 'cml_password', self.password) 158 | self.inventory.set_variable('all', 'cml_lab', self.lab) 159 | 160 | url = 'https://{0}'.format(self.host) 161 | client = ClientLibrary(url, username=self.username, password=self.password, ssl_verify=False) 162 | 163 | labs = (client.find_labs_by_title(self.lab)) 164 | if not labs: 165 | return 166 | 167 | group = "None" 168 | try: 169 | group = self.inventory.add_group(self.group) 170 | except AnsibleError as e: 171 | raise AnsibleParserError("Unable to add group %s: %s" % (group, to_text(e))) 172 | group_dict = {} 173 | 174 | lab = labs[0] 175 | lab.sync() 176 | for node in lab.nodes(): 177 | self.inventory.add_host(node.label, group=self.group) 178 | cml = { 179 | 'state': node.state, 180 | 'image_definition': node.image_definition, 181 | 'node_definition': node.node_definition, 182 | 'cpus': node.cpus, 183 | 'ram': node.ram, 184 | 'config': node.config, 185 | 'data_volume': node.data_volume, 186 | } 187 | interface_list = [] 188 | ansible_host = None 189 | ansible_port = None 190 | # pat_regex_list = [r"^pat:tcp:(\d+):22", r"^pat:(\d+):22"] 191 | for tag in node.tags(): 192 | fact_match = re.search(r"^ansible:([^=]+)=(\d+)$", tag) 193 | pat_match = re.search(r"^pat:(?:tcp|udp)?:?(\d+):(\d+)", tag) 194 | if fact_match: 195 | self.display.vvv("Add fact to node {0}: {1}={2}".format(node.label, fact_match.group(1), fact_match.group(2))) 196 | self.inventory.set_variable(node.label, fact_match.group(1), fact_match.group(2)) 197 | # for regex_pattern in pat_regex_list: 198 | # # Use re.search to find a match 199 | elif pat_match: 200 | self.display.vvv("Found PAT: outside_port={0}, inside_port={1}".format(pat_match.group(1), pat_match.group(2))) 201 | # Extract values from capture groups 202 | # outside_port = match.group(1) 203 | ansible_host = self.host 204 | # ansible_port = match.group(1) 205 | # break # Exit the inner loop once a match is found 206 | else: 207 | continue # Continue with the next string if no match was found 208 | for interface in node.interfaces(): 209 | if node.state == 'BOOTED': 210 | # Fill out the oper data if the node is not fully booted 211 | interface_dict = { 212 | 'name': interface.label, 213 | 'state': interface.state, 214 | 'ipv4_addresses': interface.discovered_ipv4, 215 | 'ipv6_addresses': interface.discovered_ipv6, 216 | 'mac_address': interface.discovered_mac_address 217 | } 218 | # See if we can use this for ansible_host 219 | if interface.discovered_ipv4 and not ansible_host: 220 | ansible_host = interface.discovered_ipv4[0] 221 | else: 222 | # Otherwise, set oper data to empty 223 | interface_dict = { 224 | 'name': interface.label, 225 | 'state': interface.state, 226 | 'ipv4_addresses': [], 227 | 'ipv6_addresses': [], 228 | 'mac_address': None 229 | } 230 | interface_list.append(interface_dict) 231 | cml.update({'interfaces': interface_list}) 232 | if ansible_host: 233 | self.inventory.set_variable(node.label, 'ansible_host', ansible_host) 234 | if ansible_port: 235 | self.inventory.set_variable(node.label, 'ansible_port', ansible_port) 236 | self.inventory.set_variable(node.label, 'cml_facts', cml) 237 | self.display.vvv("Adding {0}({1}) to group {2}, state: {3}, ansible_host: {4}".format( 238 | node.label, node.node_definition, self.group, node.state, ansible_host)) 239 | # Group by node_definition 240 | if node.node_definition not in group_dict: 241 | try: 242 | group_dict[node.node_definition] = self.inventory.add_group(node.node_definition) 243 | except AnsibleError as e: 244 | raise AnsibleParserError("Unable to add group %s: %s" % (group, to_text(e))) 245 | self.inventory.add_host(node.label, group=node.node_definition) 246 | # Find the group to create and add this host to 247 | if self.group_tags: 248 | for group in list(set(self.group_tags) & set(node.tags())): 249 | if group not in group_dict: 250 | try: 251 | group_dict[group] = self.inventory.add_group(group) 252 | except AnsibleError as e: 253 | raise AnsibleParserError("Unable to add group %s: %s" % (group, to_text(e))) 254 | self.inventory.add_host(node.label, group=group) 255 | self.display.vvv("Adding {0} to group {1}".format(node.label, group)) 256 | --------------------------------------------------------------------------------