├── .ansible-lint ├── .gitignore ├── .yamllint ├── README.md ├── ansible.cfg ├── docker-compose.yml ├── docs └── interop2020-molecule-gns3.svg ├── inventory ├── group_vars │ └── ios.yml └── hosts ├── molecule ├── gns3 │ ├── create.yml │ ├── destroy.yml │ ├── inventory │ │ ├── gns3_vars.yml │ │ ├── group_vars │ │ │ └── all.yml │ │ └── host_vars │ │ │ └── router01.yml │ ├── library │ │ └── gns3_telnet_console.py │ ├── molecule.yml │ ├── prepare.yml │ ├── tasks │ │ ├── create_docker.yml │ │ ├── create_gns3.yml │ │ ├── destroy_docker.yml │ │ ├── destroy_gns3.yml │ │ └── vars_processing.yml │ ├── templates │ │ └── ios.j2 │ └── verify.yml ├── mock │ ├── create.yml │ ├── destroy.yml │ ├── molecule.yml │ └── prepare.yml └── static │ ├── Dockerfile.j2 │ ├── data │ └── interface-data.txt │ ├── elasticsearch │ ├── Dockerfile.j2 │ └── config │ │ └── elasticsearch.yml │ ├── molecule.yml │ └── prepare.yml ├── playbooks ├── collect_mocked_data.yml ├── collect_static_data.yml └── verify.yml ├── requirements.txt └── textfsm └── cisco_ios_show_ip_interface_brief.textfsm /.ansible-lint: -------------------------------------------------------------------------------- 1 | warn_list: 2 | - '301' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.com 4 | *.class 5 | *.dll 6 | *.exe 7 | *.o 8 | *.so 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # Packages # 14 | ############ 15 | # it's better to unpack these files and commit the raw source 16 | # git has its own built in compression methods 17 | *.7z 18 | *.dmg 19 | *.gz 20 | *.iso 21 | *.jar 22 | *.rar 23 | *.tar 24 | *.zip 25 | .Python 26 | build/ 27 | develop-eggs/ 28 | dist/ 29 | downloads/ 30 | eggs/ 31 | .eggs/ 32 | lib/ 33 | lib64/ 34 | parts/ 35 | sdist/ 36 | var/ 37 | wheels/ 38 | pip-wheel-metadata/ 39 | share/python-wheels/ 40 | *.egg-info/ 41 | .installed.cfg 42 | *.egg 43 | MANIFEST 44 | 45 | # Logs and databases # 46 | ###################### 47 | *.log 48 | *.sql 49 | *.sqlite 50 | 51 | # OS generated files # 52 | ###################### 53 | .DS_Store 54 | .DS_Store? 55 | ._* 56 | .Spotlight-V100 57 | .Trashes 58 | ehthumbs.db 59 | Thumbs.db 60 | *.retry 61 | .vscode 62 | .mypy_cache 63 | .pytest_cache 64 | __pycache__ 65 | prof 66 | .envFile 67 | .env 68 | .eapi.conf 69 | .idea 70 | 71 | # Unit test / coverage reports 72 | htmlcov/ 73 | .tox/ 74 | .nox/ 75 | .coverage 76 | .coverage.* 77 | .cache 78 | nosetests.xml 79 | coverage.xml 80 | *.cover 81 | .hypothesis/ 82 | .pytest_cache/ 83 | 84 | # Sphinx documentation 85 | docs/_build/ 86 | 87 | # MkDocs 88 | site/ 89 | 90 | # mypy 91 | .mypy_cache/ 92 | .dmypy.json 93 | dmypy.json 94 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | # Based on ansible-lint config 3 | extends: default 4 | 5 | rules: 6 | braces: 7 | max-spaces-inside: 1 8 | level: error 9 | brackets: 10 | max-spaces-inside: 1 11 | level: error 12 | colons: 13 | max-spaces-after: -1 14 | level: error 15 | commas: 16 | max-spaces-after: -1 17 | level: error 18 | comments: disable 19 | comments-indentation: disable 20 | document-start: disable 21 | empty-lines: 22 | max: 3 23 | level: error 24 | hyphens: 25 | level: error 26 | indentation: disable 27 | key-duplicates: enable 28 | line-length: disable 29 | new-line-at-end-of-file: disable 30 | new-lines: 31 | type: unix 32 | trailing-spaces: disable 33 | truthy: disable 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Molecule for Testing Network Automation Ansible Projects 2 | 3 | [Molecule](https://molecule.readthedocs.io/en/latest/index.html) is a project designed to aid in the development of Ansible projects. This demo aims to show its testing framework capabilities in a Network Automation context. 4 | 5 | ## Overview 6 | 7 | The Molecule project is designed to aid the development and testing of Ansible playbooks, roles and collections. And for this it provides support for testing with multiple instances, operating systems and distributions, virtualization providers, test frameworks and scenarios. 8 | 9 | In the network infrastructure/automation domain testing can be particularly hard, connectivity and data collection mechanism, network device virtualization, difference between NOSes, among other issues. And here is where the flexibility molecule gives, alongside the capabilities that ansible already provides, help building test scenarios for your ansible projects. 10 | 11 | To follow along with the example of the demos you need to clone the project: 12 | 13 | ```shell 14 | git clone https://github.com/davidban77/ansible-molecule-demo.git 15 | cd ansible-molecule-demo 16 | ``` 17 | 18 | When molecule is run, it is normally in charge of: 19 | 20 | - Creating the instances to test your ansible playbooks, roles and/or collections. 21 | - (Optionally) Preparing the instances, like installing pre-requisite packages or setting up the environment. 22 | - Converging the playbooks, roles or collections you want to test. 23 | - Performing idempotency check by running again your ansible project. 24 | - Verify the final state of the instances. 25 | - Destroy the instances. 26 | 27 | ## Requirements 28 | 29 | In a python virtual environment install the dependencies stated in `requirement.txt` 30 | 31 | ```shell 32 | pip install -r requirements.txt 33 | ``` 34 | 35 | ## How it works 36 | 37 | This repository has 2 playbooks which follow the following workflow: 38 | 39 | ```shell 40 | Collect Interface Raw Data > Parse data into structured format > Save to DB 41 | ``` 42 | 43 | There are 2 molecule scenarios built that test these playbooks. 44 | 45 | --- 46 | 47 | ## Test: Static Data Scenario 48 | 49 | This scenarios aims to show how molecule can be used with `docker` plugin. It instantiates a `centos:7` docker container where a interface data `txt` file has the information and the playbook is in charge of reading it, parsing the data and save it to an Elasticsearch DB container. 50 | 51 | To run all scneario's phases: 52 | 53 | ```shell 54 | molecule test -s static 55 | ``` 56 | 57 | --- 58 | 59 | ## Test: Mocked Data Scenario 60 | 61 | In this scenario I am leveraging the awesome project [cisshgo](https://github.com/tbotnz/cisshgo), which give the ability to mock an SSH connection to a network device and has some predefined data which you can use to parse and test. 62 | 63 | As a pre-requisite for this test scenario you need to build a docker image locally so it can be used: 64 | 65 | ```shell 66 | git clone https://github.com/tbotnz/cisshgo.git 67 | cd cisshgo 68 | docker build -t cisshgo:latest . 69 | docker tag cisshgo:latest molecule_local/cisshgo:latest 70 | ``` 71 | 72 | The tag is needed when molecule uses it. 73 | 74 | To run the test scenario: 75 | 76 | ```shell 77 | molecule test -s mock 78 | ``` 79 | 80 | --- 81 | 82 | ## Test: GNS3 Scenario 83 | 84 | In this interesting scenario you can leverage a GNS3 server as a resource provider for the molecule scenario, meaning that **molecule will create, prepare and destroy GNS3 labs and routers** for testing the ansible playbooks. 85 | 86 | ### Extra requirements: 87 | 88 | - **GNS3 server and valid router image installed**: In this case the GNS3 server is on the same network as the host running molecule and it has the IOSv image and template installed. This means as well that connectivty from the molecule machine to GNS3 server must exist to be able to connect to its API. 89 | - [gns3fy](https://github.com/davidban77/gns3fy) 90 | 91 | ```shell 92 | pip install gns3fy 93 | ``` 94 | 95 | - [Ansible collection for gns3](https://galaxy.ansible.com/davidban77/gns3) for the creation and destroy playbooks to be succesfully executed 96 | 97 | ```shell 98 | ansible-galaxy collection install davidban77.gns3 99 | ``` 100 | 101 | - [netaddr](https://github.com/netaddr/netaddr) python package to successfully run some jinja2 filters in ansible 102 | 103 | ```shell 104 | pip install netaddr 105 | ``` 106 | 107 | ### Molecule procedure in GNS3 scenario 108 | 109 | ![molecule-gns3-scenario](docs/interop2020-molecule-gns3.svg) 110 | 111 | The steps shown in the diagram are executed in the following molecule phases: 112 | 113 | - **Create and prepare**: Step 1 and 2. Uses the GNS3 ansible modules to connect to server, create lab, create router and push initial bootstrap SSH config over console. 114 | - **Converge**: Step 3 and 4. Where the playbook being tested is executed. It follows the same procedure as the steps above. Important note here is that the connection is now `network_cli`. 115 | - **Verify**: Step 5. Could have been the same `verify.yml` of the other scenarios but since the interfaces were different, I just copied and replaced the interfaces to be verified. 116 | - **Destroy**: Step 6. It deletes the GNS3 lab and router **AND** the docker networks and container (elasticsearch). So all resources being used are released. 117 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | inventory = inventory/hosts 3 | stdout_callback = debug 4 | remote_port = 10000 5 | remote_user = netpanda 6 | host_key_checking = False 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.2' 2 | services: 3 | elasticsearch: 4 | image: docker.elastic.co/elasticsearch/elasticsearch:7.9.1 5 | container_name: elasticsearch 6 | environment: 7 | - discovery.type=single-node 8 | ports: 9 | - 9200:9200 10 | 11 | router01: 12 | image: cisshgo:latest 13 | container_name: router01 14 | command: go run cissh.go -listners 1 15 | ports: 16 | - 10000:10000 17 | -------------------------------------------------------------------------------- /docs/interop2020-molecule-gns3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
GNS3 Server
GNS3 Server
Create GNS3 Lab and router
Create GNS3 Lab and router
Push config for SSH
Push config for SSH
Get interface information (network_cli)
Get interface information (network_cli)
Push to elasticsearch container
Push to elasticsearch container
IOSv
IOSv
Get interface data and verify
Get interface data and verify
Delete router and lab
Delete router and lab
1
1
2
2
3
3
4
4
5
5
6
6
molecule
molecule
Viewer does not support full SVG 1.1
-------------------------------------------------------------------------------- /inventory/group_vars/ios.yml: -------------------------------------------------------------------------------- 1 | ansible_network_os: ios 2 | ansible_connection: network_cli 3 | ansible_password: admin 4 | ansible_become: yes 5 | ansible_become_method: enable 6 | -------------------------------------------------------------------------------- /inventory/hosts: -------------------------------------------------------------------------------- 1 | [ios] 2 | router01 ansible_host=localhost 3 | -------------------------------------------------------------------------------- /molecule/gns3/create.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create 3 | hosts: localhost 4 | connection: local 5 | gather_facts: false 6 | no_log: "{{ molecule_no_log }}" 7 | 8 | vars_files: 9 | - inventory/gns3_vars.yml 10 | 11 | collections: 12 | - davidban77.gns3 13 | 14 | pre_tasks: 15 | - name: Set required variables for playbooks 16 | include_tasks: tasks/vars_processing.yml 17 | 18 | tasks: 19 | - name: Docker setup 20 | when: docker_instances | length > 0 21 | include_tasks: tasks/create_docker.yml 22 | 23 | - name: GNS3 Lab creation and setup 24 | include_tasks: tasks/create_gns3.yml 25 | 26 | - when: server.changed | default(false) | bool 27 | block: 28 | - name: Populate instance config dict 29 | set_fact: 30 | instance_conf_dict: { 31 | 'instance': "{{ item.name }}", 32 | 'identity_file': "{{ item.identity_file | default(omit) }}", } 33 | with_items: "{{ docker_instances }}" 34 | # with_items: "{{ server.results }}" 35 | register: instance_config_dict 36 | 37 | - name: Convert instance config dict to a list 38 | set_fact: 39 | instance_conf: "{{ instance_config_dict.results | map(attribute='ansible_facts.instance_conf_dict') | list }}" 40 | 41 | - name: Dump instance config 42 | copy: 43 | content: "{{ instance_conf | to_json | from_json | molecule_to_yaml | molecule_header }}" 44 | dest: "{{ molecule_instance_config }}" 45 | -------------------------------------------------------------------------------- /molecule/gns3/destroy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Destroy 3 | hosts: localhost 4 | connection: local 5 | gather_facts: false 6 | no_log: "{{ molecule_no_log }}" 7 | 8 | vars_files: 9 | - inventory/gns3_vars.yml 10 | 11 | collections: 12 | - davidban77.gns3 13 | 14 | pre_tasks: 15 | - name: Set required variables for playbooks 16 | include_tasks: tasks/vars_processing.yml 17 | 18 | tasks: 19 | - name: GNS3 Stop lab and deletion 20 | include_tasks: tasks/destroy_gns3.yml 21 | 22 | - name: Destroy Docker setup 23 | when: docker_instances | length > 0 24 | include_tasks: tasks/destroy_docker.yml 25 | 26 | - name: Populate instance config 27 | set_fact: 28 | instance_conf: {} 29 | 30 | - name: Dump instance config 31 | copy: 32 | content: "{{ instance_conf | to_json | from_json | molecule_to_yaml | molecule_header }}" 33 | dest: "{{ molecule_instance_config }}" 34 | when: server.changed | default(false) | bool 35 | -------------------------------------------------------------------------------- /molecule/gns3/inventory/gns3_vars.yml: -------------------------------------------------------------------------------- 1 | #### GNS3 Server settings 2 | gns3_url: "http://gns3-server" 3 | gns3_project_name: ansible-molecule-demo 4 | gns3_port: 80 5 | gns3_lab_user: "netops" 6 | gns3_lab_pass: "netops1234" 7 | gns3_nodes_strategy: all 8 | 9 | ### Nodes attributes and specifications 10 | # gns3_nodes_spec: 11 | # - name: router01 12 | # template: "Cisco IOSv 15.7(3)M3" 13 | # - name: cloud-1 14 | # template: "Cloud" 15 | 16 | ### Links 17 | # gns3_links_spec: 18 | # - ["cloud-1", "eth0", "router01", "Gi0/0"] 19 | 20 | ### Boilerplate configuration settings 21 | boilerplate: 22 | config: "deploy" 23 | automated_push: "yes" 24 | automated_push_delay: 1 25 | -------------------------------------------------------------------------------- /molecule/gns3/inventory/group_vars/all.yml: -------------------------------------------------------------------------------- 1 | ansible_network_os: ios 2 | ansible_connection: network_cli 3 | ansible_password: netops1234 4 | ansible_user: netops 5 | ansible_become: yes 6 | ansible_become_method: enable 7 | -------------------------------------------------------------------------------- /molecule/gns3/inventory/host_vars/router01.yml: -------------------------------------------------------------------------------- 1 | mgmt_ip: 192.168.0.210/24 2 | mgmt_interface: GigabitEthernet0/0 3 | ansible_host: "{{ mgmt_ip | ipaddr('address') }}" 4 | -------------------------------------------------------------------------------- /molecule/gns3/library/gns3_telnet_console.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | ANSIBLE_METADATA = { 4 | "metadata_version": "1.1", 5 | "status": ["preview"], 6 | "supported_by": "community", 7 | } 8 | 9 | DOCUMENTATION = """ 10 | --- 11 | module: telnetos 12 | short_description: Deploys boiler plate config over telnet server 13 | version_added: '2.8' 14 | description: 15 | - 'Deploys boiler plate config over telnet server' 16 | requirements: [ gns3fy ] 17 | author: 18 | - David Flores (@netpanda) 19 | options: 20 | remote_addr: 21 | description: 22 | - Remote Address to perform the telnet connection 23 | required: true 24 | type: str 25 | port: 26 | description: 27 | - Telnet port to connect to device console 28 | type: int 29 | required: true 30 | login_prompt: 31 | description: 32 | - List of possible prompt(s) when login to remote device 33 | type: list 34 | password: 35 | description: 36 | - Password string to log into the device 37 | type: str 38 | send_newline: 39 | description: 40 | - To send a newline character before attempting to get prompt 41 | default: false 42 | type: bool 43 | prompts: 44 | description: 45 | - List of prompts expected before sending next command. Can be regexes 46 | type: list 47 | default: ['[#>$]'] 48 | timeout: 49 | description: 50 | - Timeout of the session or the expected prompt 51 | type: int 52 | default: 30 53 | command: 54 | description: 55 | - List of commands to be executed in the sessions. 56 | type: list 57 | required: true 58 | pause: 59 | description: 60 | - Delay in seconds between commands sent 61 | type: int 62 | default: 1 63 | user: 64 | description: 65 | - User to login with device 66 | type: str 67 | root_set_user_prompts: 68 | description: 69 | - List of prompts to expect when you need to set root user on device 70 | type: list 71 | root_set_password_prompts: 72 | description: 73 | - List of prompts to expect when you need to set root password on device 74 | type: list 75 | pre_login_actions: 76 | description: 77 | - Special actions to be performed before login to device 78 | type: str 79 | choices: ['reboot_node'] 80 | post_login_actions: 81 | description: 82 | - Special actions to be performed after login to device 83 | type: str 84 | choices: ['eos_disable_ztp', 'junos_enter_cli', 'xr_wait_system'] 85 | gns3fy_data: 86 | description: 87 | - Data specs used in gns3fy to perform actions on devices. Mainly pre_login 88 | type: dict 89 | """ 90 | 91 | EXAMPLES = """ 92 | # Retrieve the GNS3 server version 93 | - name: Send the initial commands to the device 94 | telnetos: 95 | url: http://localhost 96 | port: 3080 97 | register: result 98 | - debug: var=result 99 | """ 100 | 101 | RETURN = """ 102 | cmd: 103 | description: Arguments passed 104 | type: list 105 | stdout: 106 | description: Stdout message 107 | type: str 108 | changed: 109 | description: If an action was executed 110 | type: bool 111 | """ 112 | import time # noqa: E402 113 | import traceback # noqa: E402 114 | 115 | PEXPECT_IMP_ERR = None 116 | try: 117 | import pexpect 118 | 119 | HAS_PEXPECT = True 120 | except ImportError: 121 | PEXPECT_IMP_ERR = traceback.format_exc() 122 | HAS_PEXPECT = False 123 | 124 | from ansible.utils.display import Display # noqa: E402 125 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib # noqa: E402 126 | from ansible.errors import AnsibleError # noqa: E402 127 | 128 | GNS3FY_IMP_ERR = None 129 | try: 130 | from gns3fy import Gns3Connector, Project 131 | 132 | HAS_GNS3FY = True 133 | except ImportError: 134 | GNS3FY_IMP_ERR = traceback.format_exc() 135 | HAS_GNS3FY = False 136 | 137 | TIMEOUTS = { 138 | "pre_login": 30, 139 | "post_login": 30, 140 | "config_dialog": 30, 141 | "login_prompt": 30, 142 | "general": 30, 143 | } 144 | 145 | display = Display() 146 | 147 | 148 | def enable_prompt_resolve(conn, enable_password, prompts): 149 | try: 150 | conn.sendline("enable") 151 | i = conn.expect(["[pP]assword:", *prompts]) 152 | if i == 0: 153 | conn.sendline(enable_password) 154 | conn.expect(prompts) 155 | except pexpect.EOF: 156 | raise AnsibleError(f"on enable_prompt_resolve enable EOF: {conn.before}") 157 | except pexpect.TIMEOUT: 158 | raise AnsibleError(f"on enable_prompt_resolve TIMEOUT: {conn.before}") 159 | 160 | 161 | def login_prompt_resolve( 162 | conn, 163 | login_prompts, 164 | user, 165 | password, 166 | enable_password, 167 | prompts, 168 | timeout=30, 169 | use_prompts=True, 170 | ): 171 | try: 172 | if use_prompts: 173 | y = conn.expect([*login_prompts, *prompts], timeout=timeout) 174 | else: 175 | y = conn.expect(login_prompts, timeout=timeout) 176 | if y == 0: 177 | conn.sendline(user) 178 | i = conn.expect(["[pP]assword:", ">", *prompts], timeout=timeout) 179 | if i == 0: 180 | conn.sendline(password) 181 | sub_i = conn.expect([">", *prompts], timeout=timeout) 182 | if sub_i == 0: 183 | enable_prompt_resolve(conn, enable_password, prompts) 184 | elif i == 1: 185 | enable_prompt_resolve(conn, enable_password, prompts) 186 | elif y == 1: 187 | # Entering root user setup 188 | conn.sendline(user) 189 | i = conn.expect(["Enter root-system username:"]) 190 | except pexpect.EOF: 191 | raise AnsibleError(f"on login_prompt_resolve enable EOF: {conn.before}") 192 | except pexpect.TIMEOUT: 193 | raise AnsibleError(f"on login_prompt_resolve TIMEOUT: {conn.before}") 194 | 195 | 196 | def set_missing_timeouts(timeout_dict): 197 | general = timeout_dict.get("general") 198 | if not general: 199 | general = [v for k, v in timeout_dict.items() if v][0] 200 | if not general: 201 | return TIMEOUTS 202 | for key in TIMEOUTS.keys(): 203 | if key not in timeout_dict.keys(): 204 | timeout_dict[key] = TIMEOUTS[key] 205 | return timeout_dict 206 | 207 | 208 | def main(): 209 | module = AnsibleModule( 210 | argument_spec=dict( 211 | remote_addr=dict(type="str", required=True), 212 | port=dict(type="int", required=True), 213 | login_prompt=dict(type="list", default=None), 214 | user=dict(type="str", default=None), 215 | password=dict(type="str", default=None, no_log=True), 216 | enable_password=dict(type="str", default="", no_log=True), 217 | send_newline=dict(type="bool", default=False), 218 | prompts=dict(type="list", default=["[#$]"]), 219 | root_set_user_prompts=dict(type="list", default=None), 220 | root_set_password_prompts=dict(type="list", default=None, no_log=True), 221 | config_dialog=dict(type="bool", default=False), 222 | timeout=dict(type="dict", default=TIMEOUTS), 223 | command=dict(type="list", required=True), 224 | pause=dict(type="int", default=1), 225 | pre_login_action=dict( 226 | type="str", 227 | choices=["xr9k_reboot_node", "nxos9k_disable_poap"], 228 | default=None, 229 | ), 230 | post_login_action=dict( 231 | type="str", 232 | choices=["eos_disable_ztp", "junos_enter_cli", "xr_wait_system"], 233 | default=None, 234 | ), 235 | gns3fy_data=dict(type="dict", default=None), 236 | ) 237 | ) 238 | 239 | if not HAS_PEXPECT: 240 | module.fail_json(msg=missing_required_lib("pexpect"), exception=PEXPECT_IMP_ERR) 241 | 242 | result = dict(changed=False) 243 | 244 | remote_addr = module.params["remote_addr"] 245 | port = module.params["port"] 246 | login_prompt = module.params["login_prompt"] 247 | user = module.params["user"] 248 | password = module.params["password"] 249 | enable_password = module.params["enable_password"] 250 | send_newline = module.params["send_newline"] 251 | prompts = module.params["prompts"] 252 | root_set_user_prompts = module.params["root_set_user_prompts"] 253 | root_set_password_prompts = module.params["root_set_password_prompts"] 254 | config_dialog = module.params["config_dialog"] 255 | timeout = set_missing_timeouts(module.params["timeout"]) 256 | command = module.params["command"] 257 | pause = module.params["pause"] 258 | pre_login_action = module.params["pre_login_action"] 259 | post_login_action = module.params["post_login_action"] 260 | gns3fy_data = module.params["gns3fy_data"] 261 | 262 | conn = pexpect.spawn( 263 | f"telnet {remote_addr} {port}", 264 | timeout=timeout["general"], 265 | maxread=4092, 266 | encoding="utf-8", 267 | searchwindowsize=2000, 268 | ignore_sighup=True, 269 | ) 270 | 271 | # Sends newline at the beginning of the connections 272 | if send_newline: 273 | conn.sendline("\r") 274 | 275 | # Pre-login actions: Depends on the platform and image, for example it can be to 276 | # reboot the node or disable POAP 277 | if pre_login_action: 278 | 279 | # NXOS 9K Disable POAP 280 | if pre_login_action == "nxos9k_disable_poap": 281 | try: 282 | # TODO: around 60 283 | conn.expect( 284 | ["Starting Auto Provisioning", pexpect.TIMEOUT], 285 | timeout=timeout["pre_login"], 286 | ) 287 | conn.sendline("\r") 288 | conn.expect(["Abort Power On Auto Provisioning"]) 289 | conn.sendline("yes") 290 | conn.expect(["Do you want to enforce secure password standard"]) 291 | conn.sendline("no") 292 | except pexpect.EOF: 293 | raise AnsibleError(f"on nxos9k_disable_poap EOF: {conn.before}") 294 | except pexpect.TIMEOUT: 295 | raise AnsibleError(f"on nxos9k_disable_poap TIMEOUT: {conn.before}") 296 | 297 | # XR 9K Reboot node first 298 | elif pre_login_action == "xr9k_reboot_node": 299 | if not HAS_GNS3FY: 300 | module.fail_json( 301 | msg=missing_required_lib("gns3fy"), exception=GNS3FY_IMP_ERR 302 | ) 303 | server = Gns3Connector( 304 | url=f"{gns3fy_data['url']}:{gns3fy_data.get('port', 3080)}" 305 | ) 306 | lab = Project(name=gns3fy_data.get("project_name"), connector=server) 307 | lab.get() 308 | node = lab.get_node(gns3fy_data.get("node_name")) 309 | try: 310 | # TODO: around 60 311 | conn.expect(["reboot: Restarting"], timeout=timeout["pre_login"]) 312 | node.stop() 313 | node.start() 314 | except pexpect.EOF: 315 | raise AnsibleError(f"on xr9k_reboot_node EOF: {conn.before}") 316 | except pexpect.TIMEOUT: 317 | node.stop() 318 | node.start() 319 | time.sleep(10) 320 | # TODO: around 30 321 | i = conn.expect( 322 | ["Cisco IOS XR console", pexpect.EOF, pexpect.TIMEOUT], 323 | timeout=timeout["pre_login"], 324 | ) 325 | if i == 0: 326 | conn.sendline("\r") 327 | elif i == 1: 328 | try: 329 | # TODO: around 30 330 | conn.expect(root_set_user_prompts, timeout=timeout["pre_login"]) 331 | except pexpect.EOF: 332 | conn.close(force=True) 333 | # TODO: Set as general timeout (which should be high) 334 | conn = pexpect.spawn( 335 | f"telnet {remote_addr} {port}", 336 | timeout=timeout["general"], 337 | maxread=4092, 338 | encoding="utf-8", 339 | searchwindowsize=2000, 340 | ignore_sighup=True, 341 | ) 342 | # Pause to send command after device initialization 343 | time.sleep(360) 344 | conn.sendline("\r") 345 | conn.sendline("\r") 346 | except pexpect.TIMEOUT: 347 | conn.sendline("\r") 348 | 349 | # Root user section 350 | if root_set_user_prompts: 351 | try: 352 | conn.expect(root_set_user_prompts) 353 | conn.sendline(user) 354 | except pexpect.EOF: 355 | raise AnsibleError(f"on root_set_user_prompts EOF: {conn.before}") 356 | except pexpect.TIMEOUT: 357 | raise AnsibleError(f"on root_set_user_prompts TIMEOUT: {conn.before}") 358 | if root_set_password_prompts: 359 | while True: 360 | try: 361 | conn.expect(root_set_password_prompts, timeout=5) 362 | conn.sendline(password) 363 | except pexpect.EOF: 364 | raise AnsibleError(f"on root_set_password_prompts EOF: {conn.before}") 365 | except pexpect.TIMEOUT: 366 | time.sleep(pause) 367 | conn.sendline("\r") 368 | break 369 | 370 | # Configuration Dialog section 371 | if config_dialog: 372 | # TODO: Was set as general timeout 373 | i = conn.expect( 374 | ["[bB]asic|initial [cC]onfiguration [dD]ialog", pexpect.TIMEOUT], 375 | timeout=timeout["config_dialog"], 376 | ) 377 | if i == 0: 378 | conn.sendline("no") 379 | time.sleep(pause) 380 | # TODO: Was set as general timeout 381 | i = conn.expect( 382 | ["terminate autoinstall?", pexpect.TIMEOUT], 383 | timeout=timeout["config_dialog"], 384 | ) 385 | if i == 0: 386 | conn.sendline("yes") 387 | time.sleep(pause) 388 | conn.sendline("\r") 389 | 390 | # Main login prompt section 391 | if login_prompt: 392 | # TODO: Was set as general timeout 393 | login_prompt_resolve( 394 | conn, 395 | login_prompt, 396 | user, 397 | password, 398 | enable_password, 399 | prompts, 400 | timeout=timeout["login_prompt"], 401 | ) 402 | 403 | # Post-login actions: Depends on the platform and image, for example it can be to 404 | # disable ZTP, enter CLI prompt or just wait for system initialization 405 | if post_login_action: 406 | if post_login_action == "eos_disable_ztp": 407 | conn.sendline("\r") 408 | i = conn.expect([">", *prompts]) 409 | if i == 0: 410 | enable_prompt_resolve(conn, enable_password, prompts) 411 | conn.sendline("zerotouch disable") 412 | time.sleep(pause) 413 | conn.sendline("wr mem") 414 | time.sleep(pause) 415 | conn.sendline("reload force") 416 | # TODO: Was set to 240 417 | login_prompt_resolve( 418 | conn, 419 | login_prompt, 420 | user, 421 | password, 422 | enable_password, 423 | prompts, 424 | timeout=timeout["general"], 425 | use_prompts=False, 426 | ) 427 | elif post_login_action == "junos_enter_cli": 428 | conn.sendline("\r") 429 | i = conn.expect(["%", *prompts]) 430 | if i == 0: 431 | conn.sendline("cli") 432 | time.sleep(pause) 433 | elif post_login_action == "xr_wait_system": 434 | conn.sendline("\r") 435 | time.sleep(pause) 436 | # TODO: Was set to 60 437 | i = conn.expect( 438 | ["SYSTEM CONFIGURATION COMPLETED", pexpect.EOF, pexpect.TIMEOUT], 439 | timeout=timeout["post_login"], 440 | ) 441 | if i == 0: 442 | conn.sendline("\r") 443 | 444 | # Commands push 445 | for comm in command: 446 | for prompt in prompts: 447 | try: 448 | conn.sendline("\r") 449 | time.sleep(pause) 450 | conn.expect(prompt) 451 | conn.sendline(comm) 452 | time.sleep(pause) 453 | result["changed"] = True 454 | except pexpect.EOF: 455 | raise AnsibleError(f"on commands enable EOF: {conn.before}") 456 | except pexpect.TIMEOUT: 457 | raise AnsibleError(f"on commands TIMEOUT: {conn.before}") 458 | 459 | conn.close() 460 | 461 | result.update(stdout=conn.before) 462 | module.exit_json(**result) 463 | 464 | 465 | if __name__ == "__main__": 466 | main() 467 | -------------------------------------------------------------------------------- /molecule/gns3/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | 5 | driver: 6 | name: delegated 7 | options: 8 | managed: True 9 | gns3_external_connection: 10 | nodes: 11 | - name: cloud-1 12 | template: "Cloud" 13 | - name: switch-1 14 | template: "Ethernet switch" 15 | links: 16 | - ["cloud-1", "eth0", "switch-1", "Ethernet0"] 17 | 18 | lint: | 19 | yamllint playbooks/collect_mocked_data.yml playbooks/verify.yml 20 | ansible-lint playbooks/collect_mocked_data.yml playbooks/verify.yml 21 | 22 | platforms: 23 | - name: router01 24 | driver: gns3 25 | template: "Cisco IOSv 15.7(3)M3" 26 | external_connection: 27 | local_interface: Gi0/0 28 | mgmt_device: switch-1 29 | mgmt_device_interface: Ethernet1 30 | 31 | - name: elasticsearch 32 | driver: docker 33 | image: docker.elastic.co/elasticsearch/elasticsearch:7.9.1 34 | from_provider: docker 35 | env: {"discovery.type": "single-node"} 36 | port: 22 37 | exposed_ports: 38 | - 9200 39 | - 9300 40 | published_ports: 41 | - 0.0.0.0:9200:9200/tcp 42 | override_command: False 43 | networks: 44 | - name: molecule_test 45 | links: 46 | - router01 47 | 48 | provisioner: 49 | name: ansible 50 | # log: true 51 | playbooks: 52 | converge: ../../playbooks/collect_mocked_data.yml 53 | ansible_args: 54 | - --limit=router01 55 | inventory: 56 | host_vars: 57 | router01: 58 | elasticsearch_db: elasticsearch 59 | links: 60 | group_vars: inventory/group_vars/ 61 | host_vars: inventory/host_vars/ 62 | verifier: 63 | name: ansible 64 | -------------------------------------------------------------------------------- /molecule/gns3/prepare.yml: -------------------------------------------------------------------------------- 1 | # --- 2 | - name: Prepare 3 | hosts: router01 4 | gather_facts: no 5 | 6 | vars: 7 | ansible_connection: local 8 | ansible_python_interpreter: "{{ ansible_playbook_python }}" 9 | 10 | vars_files: 11 | - inventory/gns3_vars.yml 12 | 13 | collections: 14 | - davidban77.gns3 15 | 16 | pre_tasks: 17 | - name: Set required variables for playbooks 18 | include_tasks: tasks/vars_processing.yml 19 | 20 | tasks: 21 | - name: Collect the nodes 22 | delegate_to: localhost 23 | gns3_nodes_inventory: 24 | url: "{{ gns3_url }}" 25 | port: "{{ gns3_port }}" 26 | project_name: "{{ gns3_project_name }}" 27 | register: nodes_inventory 28 | 29 | - debug: var=nodes_inventory.nodes_inventory.router01 30 | 31 | - name: Deploy boilerplate configuration 32 | when: boilerplate.config == "deploy" and ansible_network_os != "gns3_builtin" 33 | block: 34 | - name: Generate configuration variable 35 | set_fact: 36 | boilerplate_config: "{{ lookup('template', 'templates/{{ ansible_network_os }}.j2') }}" 37 | 38 | - debug: var=boilerplate_config 39 | 40 | - name: "Push config" 41 | when: ansible_network_os == "ios" 42 | gns3_telnet_console: 43 | remote_addr: "{{ nodes_inventory.nodes_inventory[inventory_hostname]['server'] }}" 44 | port: "{{ nodes_inventory.nodes_inventory[inventory_hostname]['console_port'] }}" 45 | send_newline: yes 46 | command: "{{ boilerplate_config.splitlines() }}" 47 | pause: 1 48 | timeout: 49 | general: 180 50 | pre_login: 60 51 | post_login: 60 52 | login_prompt: 30 53 | config_dialog: 30 54 | 55 | - name: Check Elasticsearch 56 | hosts: localhost 57 | connection: local 58 | gather_facts: no 59 | tasks: 60 | 61 | - name: "Wait for Elasticsearch to come up" 62 | uri: 63 | url: "http://{{ elasticsearch_db | default('localhost') }}:9200/_cluster/health?wait_for_status=green&timeout=30s" 64 | status_code: 200 65 | register: result 66 | until: result.status == 200 67 | retries: 30 68 | delay: 1 69 | -------------------------------------------------------------------------------- /molecule/gns3/tasks/create_docker.yml: -------------------------------------------------------------------------------- 1 | - name: Create docker network(s) 2 | action: docker_network 3 | args: "{{ item }}" 4 | with_items: "{{ docker_instances | molecule_get_docker_networks('present') }}" 5 | loop_control: 6 | label: "{{ item.name }}" 7 | no_log: false 8 | 9 | - name: Determine the CMD directives 10 | set_fact: 11 | command_directives_dict: >- 12 | {{ command_directives_dict | default({}) | 13 | combine({ item.name: item.command | default('bash -c "while true; do sleep 10000; done"') }) 14 | }} 15 | with_items: "{{ docker_instances }}" 16 | when: item.override_command | default(true) and item.driver == 'docker' 17 | 18 | - name: Create molecule instance(s) 19 | docker_container: 20 | name: "{{ item.name }}" 21 | docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" 22 | cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 23 | cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 24 | key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 25 | tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" 26 | hostname: "{{ item.hostname | default(item.name) }}" 27 | image: "{{ item.pre_build_image | default(false) | ternary('', 'molecule_local/') }}{{ item.image }}" 28 | pull: "{{ item.pull | default(omit) }}" 29 | memory: "{{ item.memory | default(omit) }}" 30 | memory_swap: "{{ item.memory_swap | default(omit) }}" 31 | state: started 32 | recreate: false 33 | log_driver: json-file 34 | command: "{{ (command_directives_dict | default({}))[item.name] | default(omit) }}" 35 | user: "{{ item.user | default(omit) }}" 36 | pid_mode: "{{ item.pid_mode | default(omit) }}" 37 | privileged: "{{ item.privileged | default(omit) }}" 38 | security_opts: "{{ item.security_opts | default(omit) }}" 39 | devices: "{{ item.devices | default(omit) }}" 40 | volumes: "{{ item.volumes | default(omit) }}" 41 | tmpfs: "{{ item.tmpfs | default(omit) }}" 42 | capabilities: "{{ item.capabilities | default(omit) }}" 43 | sysctls: "{{ item.sysctls | default(omit) }}" 44 | exposed_ports: "{{ item.exposed_ports | default(omit) }}" 45 | published_ports: "{{ item.published_ports | default(omit) }}" 46 | ulimits: "{{ item.ulimits | default(omit) }}" 47 | networks: "{{ item.networks | default(omit) }}" 48 | network_mode: "{{ item.network_mode | default(omit) }}" 49 | networks_cli_compatible: "{{ item.networks_cli_compatible | default(true) }}" 50 | purge_networks: "{{ item.purge_networks | default(omit) }}" 51 | dns_servers: "{{ item.dns_servers | default(omit) }}" 52 | etc_hosts: "{{ item.etc_hosts | default(omit) }}" 53 | env: "{{ item.env | default(omit) }}" 54 | restart_policy: "{{ item.restart_policy | default(omit) }}" 55 | restart_retries: "{{ item.restart_retries | default(omit) }}" 56 | tty: "{{ item.tty | default(omit) }}" 57 | container_default_behavior: "{{ item.container_default_behavior | default('compatibility' if ansible_version.full is version_compare('2.10', '>=') else omit) }}" 58 | register: server 59 | with_items: "{{ docker_instances }}" 60 | when: item.driver == 'docker' 61 | loop_control: 62 | label: "{{ item.name }}" 63 | no_log: false 64 | async: 7200 65 | poll: 0 66 | 67 | - name: Wait for instance(s) creation to complete 68 | async_status: 69 | jid: "{{ item.ansible_job_id }}" 70 | register: docker_jobs 71 | until: docker_jobs.finished 72 | retries: 300 73 | with_items: "{{ server.results }}" 74 | -------------------------------------------------------------------------------- /molecule/gns3/tasks/create_gns3.yml: -------------------------------------------------------------------------------- 1 | - name: Create GNS3 Project 2 | gns3_project: 3 | url: "{{ gns3_url }}" 4 | port: "{{ gns3_port }}" 5 | project_name: "{{ gns3_project_name }}" 6 | state: present 7 | nodes_spec: "{{ gns3_nodes_spec }}" 8 | links_spec: "{{ gns3_links_spec | default(omit) }}" 9 | 10 | - name: Start GNS3 nodes 11 | when: gns3_nodes_strategy == "all" 12 | gns3_project: 13 | url: "{{ gns3_url }}" 14 | port: "{{ gns3_port }}" 15 | project_name: "{{ gns3_project_name }}" 16 | state: opened 17 | nodes_state: started 18 | nodes_strategy: all 19 | poll_wait_time: "{{ gns3_poll_wait_time | default(omit) }}" 20 | 21 | # NOTE: This could potentially be a long task and might need to make it async if role 22 | # is executed on a remote host 23 | - name: Start GNS3 nodes one by one 24 | when: gns3_nodes_strategy == "one_by_one" 25 | gns3_project: 26 | url: "{{ gns3_url }}" 27 | port: "{{ gns3_port }}" 28 | project_name: "{{ gns3_project_name }}" 29 | state: opened 30 | nodes_state: started 31 | nodes_strategy: one_by_one 32 | nodes_delay: "{{ gns3_nodes_delay | default(omit) }}" 33 | 34 | - name: Collect GNS3 nodes inventory 35 | gns3_nodes_inventory: 36 | url: "{{ gns3_url }}" 37 | port: "{{ gns3_port }}" 38 | project_name: "{{ gns3_project_name }}" 39 | register: nodes_inventory 40 | 41 | - name: Pause section 42 | when: boilerplate.config == "deploy" and boilerplate.automated_push == "no" 43 | pause: 44 | prompt: "Press Enter to proceed with configuration deployments:" 45 | 46 | - name: Pause section 47 | when: boilerplate.config == "deploy" and boilerplate.automated_push == "yes" 48 | pause: 49 | minutes: "{{ boilerplate.automated_push_delay | default(3) }}" 50 | -------------------------------------------------------------------------------- /molecule/gns3/tasks/destroy_docker.yml: -------------------------------------------------------------------------------- 1 | - name: Destroy docker molecule instance(s) 2 | docker_container: 3 | name: "{{ item.name }}" 4 | docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" 5 | cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 6 | cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 7 | key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 8 | tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" 9 | state: absent 10 | force_kill: "{{ item.force_kill | default(true) }}" 11 | keep_volumes: "{{ item.keep_volumes | default(true) }}" 12 | container_default_behavior: "{{ item.container_default_behavior | default('compatibility' if ansible_version.full is version_compare('2.10', '>=') else omit) }}" 13 | register: server 14 | with_items: "{{ docker_instances }}" 15 | when: item.driver == 'docker' 16 | loop_control: 17 | label: "{{ item.name }}" 18 | no_log: false 19 | async: 7200 20 | poll: 0 21 | 22 | - name: Wait for instance(s) deletion to complete 23 | async_status: 24 | jid: "{{ item.ansible_job_id }}" 25 | register: docker_jobs 26 | until: docker_jobs.finished 27 | retries: 300 28 | with_items: "{{ server.results }}" 29 | 30 | - name: Delete docker network(s) 31 | action: docker_network 32 | args: "{{ item }}" 33 | with_items: "{{ docker_instances | molecule_get_docker_networks('absent') }}" 34 | loop_control: 35 | label: "{{ item.name }}" 36 | no_log: false 37 | -------------------------------------------------------------------------------- /molecule/gns3/tasks/destroy_gns3.yml: -------------------------------------------------------------------------------- 1 | - name: Stop GNS3 nodes and delete Project 2 | gns3_project: 3 | url: "{{ gns3_url }}" 4 | port: "{{ gns3_port }}" 5 | project_name: "{{ gns3_project_name }}" 6 | state: absent 7 | -------------------------------------------------------------------------------- /molecule/gns3/tasks/vars_processing.yml: -------------------------------------------------------------------------------- 1 | - name: set placeholder vars 2 | set_fact: 3 | gns3_nodes_spec: [] 4 | gns3_links_spec: [] 5 | docker_instances: [] 6 | 7 | - name: set gns3_nodes_spec from driver 8 | set_fact: 9 | gns3_nodes_spec: "{{ gns3_nodes_spec + molecule_yml.driver.options.gns3_external_connection.nodes }}" 10 | 11 | - name: set gns3_nodes_spec from platform 12 | loop: "{{ molecule_yml.platforms }}" 13 | when: item.driver == "gns3" 14 | set_fact: 15 | gns3_nodes_spec: "{{ gns3_nodes_spec + [{'name': item.name, 'template': item.template}] }}" 16 | 17 | - name: set gns3_links_spec from driver 18 | set_fact: 19 | gns3_links_spec: "{{ gns3_links_spec + molecule_yml.driver.options.gns3_external_connection.links }}" 20 | 21 | - name: set gns3_links_spec from platform 22 | loop: "{{ molecule_yml.platforms }}" 23 | when: item.driver == "gns3" 24 | set_fact: 25 | gns3_links_spec: "{{ gns3_links_spec + [[item.name, item.external_connection.local_interface, item.external_connection.mgmt_device, item.external_connection.mgmt_device_interface]] }}" 26 | 27 | # - debug: var=gns3_nodes_spec 28 | # - debug: var=gns3_links_spec 29 | 30 | - name: verify docker setup 31 | loop: "{{ molecule_yml.platforms }}" 32 | when: item.driver == "docker" 33 | set_fact: 34 | docker_instances: "{{ docker_instances + [item] }}" 35 | 36 | # - debug: var=docker_instances 37 | -------------------------------------------------------------------------------- /molecule/gns3/templates/ios.j2: -------------------------------------------------------------------------------- 1 | terminal length 0 2 | configure terminal 3 | hostname {{ inventory_hostname }} 4 | ! Archiving section 5 | archive 6 | log config 7 | logging enable 8 | logging size 500 9 | notify syslog contenttype plaintext 10 | ! AAA 11 | aaa new-model 12 | aaa authentication login default local 13 | aaa authorization exec default local 14 | aaa authorization commands 15 default local 15 | ! SSH 16 | crypto key generate rsa label LAB modulus 2048 17 | ip ssh version 2 18 | line vty 0 4 19 | transport input ssh 20 | ! USER 21 | username {{ gns3_lab_user }} privilege 15 password 0 {{ gns3_lab_pass }} 22 | {% if mgmt_interface is defined %} 23 | interface {{ mgmt_interface }} 24 | {% if mgmt_ip is defined %} 25 | ip address {{ mgmt_ip | ipaddr('address') }} {{ mgmt_ip | ipaddr('netmask') }} 26 | {% endif %} 27 | no shutdown 28 | {% endif %} 29 | {% if interfaces is defined %} 30 | {% for interface in interfaces %} 31 | interface {{ interface.interface }} 32 | ip address {{ interface.ip | ipaddr('address') }} {{ interface.ip | ipaddr('netmask') }} 33 | description *** {{ interface.description }} *** 34 | no shut 35 | {% endfor %} 36 | {% endif %} 37 | exit 38 | exit 39 | -------------------------------------------------------------------------------- /molecule/gns3/verify.yml: -------------------------------------------------------------------------------- 1 | - name: Verify interface data 2 | hosts: all 3 | gather_facts: no 4 | 5 | tasks: 6 | - name: Get the elastic doc 7 | uri: 8 | url: http://{{ elasticsearch_db | default('localhost') }}:9200/interface-data/_search 9 | method: POST 10 | body_format: json 11 | body: 12 | query: {"term": {"interface.keyword": "GigabitEthernet0/0"}} 13 | register: result 14 | 15 | # - debug: 16 | # msg: "{{ result }}" 17 | 18 | - name: verify interface data from elastic doc 19 | assert: 20 | that: 21 | - result.json.hits.hits[0]._source.interface == "GigabitEthernet0/0" 22 | - result.json.hits.hits[0]._source.ip == "192.168.0.210" 23 | - result.json.hits.hits[0]._source.admin_status == "up" 24 | - result.json.hits.hits[0]._source.status == "up" 25 | -------------------------------------------------------------------------------- /molecule/mock/create.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create 3 | hosts: localhost 4 | connection: local 5 | gather_facts: false 6 | no_log: "{{ molecule_no_log }}" 7 | tasks: 8 | 9 | - name: Create docker network(s) 10 | action: docker_network 11 | args: "{{ item }}" 12 | with_items: "{{ molecule_yml.platforms | molecule_get_docker_networks('present') }}" 13 | loop_control: 14 | label: "{{ item.name }}" 15 | no_log: false 16 | 17 | - name: Determine the CMD directives 18 | set_fact: 19 | command_directives_dict: >- 20 | {{ command_directives_dict | default({}) | 21 | combine({ item.name: item.command | default('bash -c "while true; do sleep 10000; done"') }) 22 | }} 23 | with_items: "{{ molecule_yml.platforms }}" 24 | when: item.override_command | default(true) 25 | 26 | - name: Create molecule instance(s) 27 | docker_container: 28 | name: "{{ item.name }}" 29 | docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" 30 | cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 31 | cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 32 | key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 33 | tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" 34 | hostname: "{{ item.hostname | default(item.name) }}" 35 | image: "{{ item.pre_build_image | default(false) | ternary('', 'molecule_local/') }}{{ item.image }}" 36 | pull: "{{ item.pull | default(omit) }}" 37 | memory: "{{ item.memory | default(omit) }}" 38 | memory_swap: "{{ item.memory_swap | default(omit) }}" 39 | state: started 40 | recreate: false 41 | log_driver: json-file 42 | command: "{{ (command_directives_dict | default({}))[item.name] | default(omit) }}" 43 | user: "{{ item.user | default(omit) }}" 44 | pid_mode: "{{ item.pid_mode | default(omit) }}" 45 | privileged: "{{ item.privileged | default(omit) }}" 46 | security_opts: "{{ item.security_opts | default(omit) }}" 47 | devices: "{{ item.devices | default(omit) }}" 48 | volumes: "{{ item.volumes | default(omit) }}" 49 | tmpfs: "{{ item.tmpfs | default(omit) }}" 50 | capabilities: "{{ item.capabilities | default(omit) }}" 51 | sysctls: "{{ item.sysctls | default(omit) }}" 52 | exposed_ports: "{{ item.exposed_ports | default(omit) }}" 53 | published_ports: "{{ item.published_ports | default(omit) }}" 54 | ulimits: "{{ item.ulimits | default(omit) }}" 55 | networks: "{{ item.networks | default(omit) }}" 56 | network_mode: "{{ item.network_mode | default(omit) }}" 57 | networks_cli_compatible: "{{ item.networks_cli_compatible | default(true) }}" 58 | purge_networks: "{{ item.purge_networks | default(omit) }}" 59 | dns_servers: "{{ item.dns_servers | default(omit) }}" 60 | etc_hosts: "{{ item.etc_hosts | default(omit) }}" 61 | env: "{{ item.env | default(omit) }}" 62 | restart_policy: "{{ item.restart_policy | default(omit) }}" 63 | restart_retries: "{{ item.restart_retries | default(omit) }}" 64 | tty: "{{ item.tty | default(omit) }}" 65 | container_default_behavior: "{{ item.container_default_behavior | default('compatibility' if ansible_version.full is version_compare('2.10', '>=') else omit) }}" 66 | register: server 67 | with_items: "{{ molecule_yml.platforms }}" 68 | loop_control: 69 | label: "{{ item.name }}" 70 | no_log: false 71 | async: 7200 72 | poll: 0 73 | 74 | - name: Wait for instance(s) creation to complete 75 | async_status: 76 | jid: "{{ item.ansible_job_id }}" 77 | register: docker_jobs 78 | until: docker_jobs.finished 79 | retries: 300 80 | with_items: "{{ server.results }}" 81 | 82 | - when: server.changed | default(false) | bool 83 | block: 84 | - name: Populate instance config dict 85 | set_fact: 86 | instance_conf_dict: { 87 | 'instance': "{{ item.name }}", 88 | 'address': "localhost", 89 | 'user': "ansible", 90 | 'port': "{{ item.port }}", 91 | 'connection': "network_cli", 92 | 'identity_file': "{{ item.identity_file | default(omit) }}", } 93 | with_items: "{{ molecule_yml.platforms }}" 94 | # with_items: "{{ server.results }}" 95 | register: instance_config_dict 96 | 97 | - name: Convert instance config dict to a list 98 | set_fact: 99 | instance_conf: "{{ instance_config_dict.results | map(attribute='ansible_facts.instance_conf_dict') | list }}" 100 | 101 | - name: Dump instance config 102 | copy: 103 | content: "{{ instance_conf | to_json | from_json | molecule_to_yaml | molecule_header }}" 104 | dest: "{{ molecule_instance_config }}" 105 | -------------------------------------------------------------------------------- /molecule/mock/destroy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Destroy 3 | hosts: localhost 4 | connection: local 5 | gather_facts: false 6 | no_log: "{{ molecule_no_log }}" 7 | tasks: 8 | 9 | - name: Destroy molecule instance(s) 10 | docker_container: 11 | name: "{{ item.name }}" 12 | docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" 13 | cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 14 | cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 15 | key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" 16 | tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" 17 | state: absent 18 | force_kill: "{{ item.force_kill | default(true) }}" 19 | keep_volumes: "{{ item.keep_volumes | default(true) }}" 20 | container_default_behavior: "{{ item.container_default_behavior | default('compatibility' if ansible_version.full is version_compare('2.10', '>=') else omit) }}" 21 | register: server 22 | with_items: "{{ molecule_yml.platforms }}" 23 | loop_control: 24 | label: "{{ item.name }}" 25 | no_log: false 26 | async: 7200 27 | poll: 0 28 | 29 | - name: Wait for instance(s) deletion to complete 30 | async_status: 31 | jid: "{{ item.ansible_job_id }}" 32 | register: docker_jobs 33 | until: docker_jobs.finished 34 | retries: 300 35 | with_items: "{{ server.results }}" 36 | 37 | - name: Delete docker network(s) 38 | action: docker_network 39 | args: "{{ item }}" 40 | with_items: "{{ molecule_yml.platforms | molecule_get_docker_networks('absent') }}" 41 | loop_control: 42 | label: "{{ item.name }}" 43 | no_log: false 44 | 45 | # Mandatory configuration for Molecule to function. 46 | 47 | - name: Populate instance config 48 | set_fact: 49 | instance_conf: {} 50 | 51 | - name: Dump instance config 52 | copy: 53 | content: "{{ instance_conf | to_json | from_json | molecule_to_yaml | molecule_header }}" 54 | dest: "{{ molecule_instance_config }}" 55 | when: server.changed | default(false) | bool 56 | -------------------------------------------------------------------------------- /molecule/mock/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | 5 | driver: 6 | name: delegated 7 | options: 8 | managed: True 9 | 10 | lint: | 11 | yamllint playbooks/collect_mocked_data.yml playbooks/verify.yml 12 | ansible-lint playbooks/collect_mocked_data.yml playbooks/verify.yml 13 | 14 | platforms: 15 | - name: router01 16 | groups: 17 | - ios 18 | image: cisshgo:latest 19 | pull: False 20 | command: go run cissh.go -listeners 1 21 | port: 10000 22 | exposed_ports: 23 | - 10000 24 | published_ports: 25 | - 0.0.0.0:10000:10000/tcp 26 | networks: 27 | - name: molecule_test 28 | links: 29 | - elasticsearch 30 | 31 | - name: elasticsearch 32 | image: docker.elastic.co/elasticsearch/elasticsearch:7.9.1 33 | env: {"discovery.type": "single-node"} 34 | port: 22 35 | exposed_ports: 36 | - 9200 37 | - 9300 38 | published_ports: 39 | - 0.0.0.0:9200:9200/tcp 40 | override_command: False 41 | networks: 42 | - name: molecule_test 43 | links: 44 | - router01 45 | 46 | provisioner: 47 | name: ansible 48 | # log: true 49 | playbooks: 50 | converge: ../../playbooks/collect_mocked_data.yml 51 | verify: ../../playbooks/verify.yml 52 | ansible_args: 53 | - --limit=router01 54 | inventory: 55 | host_vars: 56 | router01: 57 | elasticsearch_db: elasticsearch 58 | links: 59 | group_vars: ../../inventory/group_vars/ 60 | verifier: 61 | name: ansible 62 | -------------------------------------------------------------------------------- /molecule/mock/prepare.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Prepare 3 | hosts: router01 4 | gather_facts: no 5 | tasks: 6 | 7 | - name: "Wait for Elasticsearch to come up" 8 | uri: 9 | url: "http://{{ elasticsearch_db | default('localhost') }}:9200/_cluster/health?wait_for_status=green&timeout=30s" 10 | status_code: 200 11 | register: result 12 | until: result.status == 200 13 | retries: 30 14 | delay: 1 15 | -------------------------------------------------------------------------------- /molecule/static/Dockerfile.j2: -------------------------------------------------------------------------------- 1 | # Molecule managed 2 | 3 | {% if item.registry is defined %} 4 | FROM {{ item.registry.url }}/{{ item.image }} 5 | {% else %} 6 | FROM {{ item.image }} 7 | {% endif %} 8 | 9 | {% if item.env is defined %} 10 | {% for var, value in item.env.items() %} 11 | {% if value %} 12 | ENV {{ var }} {{ value }} 13 | {% endif %} 14 | {% endfor %} 15 | {% endif %} 16 | 17 | RUN if [ $(command -v apt-get) ]; then apt-get update && apt-get install -y python sudo bash ca-certificates iproute2 && apt-get clean; \ 18 | elif [ $(command -v dnf) ]; then dnf makecache && dnf --assumeyes install python sudo python-devel python*-dnf bash iproute && dnf clean all; \ 19 | elif [ $(command -v yum) ]; then yum makecache fast && yum install -y python sudo yum-plugin-ovl bash iproute && sed -i 's/plugins=0/plugins=1/g' /etc/yum.conf && yum clean all; \ 20 | elif [ $(command -v zypper) ]; then zypper refresh && zypper install -y python sudo bash python-xml iproute2 && zypper clean -a; \ 21 | elif [ $(command -v apk) ]; then apk update && apk add --no-cache python sudo bash ca-certificates; \ 22 | elif [ $(command -v xbps-install) ]; then xbps-install -Syu && xbps-install -y python sudo bash ca-certificates iproute2 && xbps-remove -O; fi 23 | 24 | # Create `ansible` user with sudo permissions and membership in `DEPLOY_GROUP` 25 | ENV ANSIBLE_USER=ansible SUDO_GROUP=wheel DEPLOY_GROUP=deployer 26 | RUN set -xe \ 27 | && groupadd -r ${ANSIBLE_USER} \ 28 | && groupadd -r ${DEPLOY_GROUP} \ 29 | && useradd -m -g ${ANSIBLE_USER} ${ANSIBLE_USER} \ 30 | && usermod -aG ${SUDO_GROUP} ${ANSIBLE_USER} \ 31 | && usermod -aG ${DEPLOY_GROUP} ${ANSIBLE_USER} \ 32 | && sed -i "/^%${SUDO_GROUP}/s/ALL\$/NOPASSWD:ALL/g" /etc/sudoers 33 | -------------------------------------------------------------------------------- /molecule/static/data/interface-data.txt: -------------------------------------------------------------------------------- 1 | cisgo1000v#show ip interface brief 2 | Interface IP-Address OK? Method Status Protocol 3 | FastEthernet0/0 10.0.2.27 YES NVRAM up up 4 | Serial0/0 unassigned YES NVRAM administratively down down 5 | FastEthernet0/1 unassigned YES NVRAM administratively down down 6 | Serial0/1 unassigned YES NVRAM administratively down down 7 | FastEthernet1/0 unassigned YES NVRAM administratively down down 8 | FastEthernet2/0 unassigned YES NVRAM administratively down down 9 | FastEthernet3/0 unassigned YES unset up down 10 | FastEthernet3/1 unassigned YES unset up down 11 | FastEthernet3/2 unassigned YES unset up down 12 | FastEthernet3/3 unassigned YES unset up down 13 | FastEthernet3/4 unassigned YES unset up down 14 | FastEthernet3/5 unassigned YES unset up down 15 | FastEthernet3/6 unassigned YES unset up down 16 | FastEthernet3/7 unassigned YES unset up down 17 | FastEthernet3/8 unassigned YES unset up down 18 | FastEthernet3/9 unassigned YES unset up down 19 | FastEthernet3/10 unassigned YES unset up down 20 | FastEthernet3/11 unassigned YES unset up down 21 | FastEthernet3/12 unassigned YES unset up down 22 | FastEthernet3/13 unassigned YES unset up down 23 | FastEthernet3/14 unassigned YES unset up down 24 | FastEthernet3/15 unassigned YES unset up down 25 | Vlan1 unassigned YES NVRAM up down 26 | -------------------------------------------------------------------------------- /molecule/static/elasticsearch/Dockerfile.j2: -------------------------------------------------------------------------------- 1 | # Molecule managed 2 | 3 | {% if item.registry is defined %} 4 | FROM {{ item.registry.url }}/{{ item.image }} 5 | {% else %} 6 | FROM {{ item.image }} 7 | {% endif %} 8 | 9 | {% if item.env is defined %} 10 | {% for var, value in item.env.items() %} 11 | {% if value %} 12 | ENV {{ var }} {{ value }} 13 | {% endif %} 14 | {% endfor %} 15 | {% endif %} 16 | 17 | {# ADD config/elasticsearch.yml /usr/share/elasticsearch/config/elasticsearch.yml #} 18 | -------------------------------------------------------------------------------- /molecule/static/elasticsearch/config/elasticsearch.yml: -------------------------------------------------------------------------------- 1 | cluster.name: "docker-cluster" 2 | network.host: 0.0.0.0 3 | discovery.type: single-node 4 | -------------------------------------------------------------------------------- /molecule/static/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | 5 | driver: 6 | name: docker 7 | 8 | lint: | 9 | yamllint playbooks/collect_static_data.yml 10 | ansible-lint playbooks/collect_static_data.yml 11 | 12 | platforms: 13 | - name: instance 14 | image: centos:7 15 | networks: 16 | - name: molecule 17 | links: 18 | - elasticsearch 19 | 20 | - name: elasticsearch 21 | image: docker.elastic.co/elasticsearch/elasticsearch:7.9.1 22 | dockerfile: elasticsearch/Dockerfile.j2 23 | exposed_ports: 24 | - 9200 25 | - 9300 26 | volumes: 27 | - "${PWD}/molecule/static/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml" 28 | override_command: False 29 | networks: 30 | - name: molecule 31 | links: 32 | - instance 33 | 34 | provisioner: 35 | name: ansible 36 | # log: true 37 | lint: 38 | name: ansible-lint 39 | playbooks: 40 | converge: ../../playbooks/collect_static_data.yml 41 | verify: ../../playbooks/verify.yml 42 | ansible_args: 43 | - --limit=instance 44 | inventory: 45 | host_vars: 46 | instance: 47 | elasticsearch_db: elasticsearch 48 | verifier: 49 | name: ansible 50 | 51 | scenario: 52 | test_sequence: 53 | - dependency 54 | - lint 55 | - cleanup 56 | - destroy 57 | - syntax 58 | - create 59 | - prepare 60 | - converge 61 | - verify 62 | - cleanup 63 | - destroy 64 | -------------------------------------------------------------------------------- /molecule/static/prepare.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Prepare instance 3 | hosts: instance 4 | gather_facts: no 5 | 6 | tasks: 7 | - name: Save interface-data 8 | copy: 9 | src: data/interface-data.txt 10 | dest: /tmp/interface-data.txt 11 | 12 | - name: "Wait for Elasticsearch to come up" 13 | uri: 14 | url: "http://{{ elasticsearch_db | default('localhost') }}:9200/_cluster/health?wait_for_status=green&timeout=30s" 15 | status_code: 200 16 | register: result 17 | until: result.status == 200 18 | retries: 30 19 | delay: 1 20 | -------------------------------------------------------------------------------- /playbooks/collect_mocked_data.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Backup Network Interface Data 3 | hosts: all 4 | gather_facts: no 5 | 6 | tasks: 7 | - name: Collect interface data from {{ inventory_hostname }} 8 | ios_command: 9 | host: "{{ inventory_hostname }}" 10 | commands: 11 | - show ip interface brief 12 | register: output 13 | 14 | - debug: 15 | msg: "{{ output.stdout[0] }}" 16 | 17 | - name: Parse interface raw data 18 | set_fact: 19 | intfs: "{{ output.stdout[0] | parse_cli_textfsm(playbook_dir ~ '/../textfsm/cisco_ios_show_ip_interface_brief.textfsm') }}" 20 | 21 | # - debug: var=intfs 22 | 23 | - name: Save interface data to DB 24 | loop: "{{ intfs }}" 25 | loop_control: 26 | index_var: index 27 | uri: 28 | url: http://{{ elasticsearch_db | default('localhost') }}:9200/interface-data/_doc/{{ index }} 29 | method: PUT 30 | body_format: json 31 | body: "{{ item }}" 32 | status_code: [200, 201] 33 | register: result 34 | 35 | # - debug: var=result 36 | -------------------------------------------------------------------------------- /playbooks/collect_static_data.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Backup Network Interface Data 3 | hosts: all 4 | gather_facts: no 5 | 6 | tasks: 7 | - name: Collect interface data from file on {{ inventory_hostname }} 8 | command: cat /tmp/interface-data.txt 9 | register: output 10 | 11 | - debug: 12 | msg: "{{ output.stdout }}" 13 | 14 | - name: Parse interface raw data 15 | set_fact: 16 | intfs: "{{ output.stdout | parse_cli_textfsm(playbook_dir ~ '/../textfsm/cisco_ios_show_ip_interface_brief.textfsm') }}" 17 | 18 | # - debug: var=intfs 19 | 20 | - name: Save interface data to DB 21 | loop: "{{ intfs }}" 22 | loop_control: 23 | index_var: index 24 | uri: 25 | url: http://{{ elasticsearch_db | default('localhost') }}:9200/interface-data/_doc/{{ index }} 26 | method: PUT 27 | body_format: json 28 | body: "{{ item }}" 29 | status_code: [200, 201] 30 | register: result 31 | 32 | # - debug: var=result 33 | -------------------------------------------------------------------------------- /playbooks/verify.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Verify interface data 3 | hosts: all 4 | gather_facts: no 5 | 6 | tasks: 7 | - name: Get the elastic doc 8 | uri: 9 | url: http://{{ elasticsearch_db | default('localhost') }}:9200/interface-data/_search 10 | method: POST 11 | body_format: json 12 | body: 13 | query: {"term": {"interface.keyword": "FastEthernet3/1"}} 14 | register: result 15 | 16 | # - debug: 17 | # msg: "{{ result }}" 18 | 19 | - name: verify interface data from elastic doc 20 | assert: 21 | that: 22 | - result.json.hits.hits[0]._source.interface == "FastEthernet3/1" 23 | - result.json.hits.hits[0]._source.ip == "unassigned" 24 | - result.json.hits.hits[0]._source.admin_status == "up" 25 | - result.json.hits.hits[0]._source.status == "down" 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ansible==2.9.13 2 | paramiko==2.7.2 3 | textfsm==1.1.0 4 | molecule==3.0.8 5 | docker==4.3.1 6 | ansible-lint==4.3.4 7 | yamllint==1.24.2 8 | -------------------------------------------------------------------------------- /textfsm/cisco_ios_show_ip_interface_brief.textfsm: -------------------------------------------------------------------------------- 1 | Value interface (\S+) 2 | Value ip (\S+) 3 | Value admin_status (up|down|administratively down) 4 | Value status (up|down) 5 | 6 | Start 7 | ^${interface}\s+${ip}\s+\w+\s+\w+\s+${admin_status}\s+${status} -> Record 8 | # Capture time-stamp if vty line has command time-stamping turned on 9 | ^Load\s+for\s+ 10 | ^Time\s+source\s+is 11 | --------------------------------------------------------------------------------