diff --git a/CHANGELOG.md b/CHANGELOG.md index c0bbd6d..ef63f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,42 @@ # Releases +## 1.4.0 + +**Enhacements:** + +- Added `gns3_node` to interact with the node inside a project. Provides the following: + - `start/stop/suspend/reload`: Actions to be applied on the node. These are idempotent with the exception of `reload`. + - Special flags like `retry` for an action to be applied a second time just in case... And a `force_project_open` to interact with a device if the project is closed +- Refactored `gns3_project` to be more pythonic + ## 1.3.0 +**Enhancements:** + - Added `gns3_node_file` and `gns3_project_file` modules. - Improved the Makefile - Added alpine node to the tests ## 1.2.2. +**Fixes:** + - Upgrading to `gns3fy ^0.4.0` ## 1.2.1 -Enhancement: +**Enhancements:** - No more `node_type` needed when creating nodes in a project. ## 1.2.0 -New features: +**Enhancements:** - Modules: - `gns3_nodes_inventory`: Returns inventory-style dictionary of the nodes. -Fixes: +**Fixes:** - Modules: - Error when using the `gns3_version` module when `gns3fy` is not installed @@ -33,7 +46,7 @@ Fixes: ## 1.1.0 -New features: +**Enhancements:** - Roles: - `create_lab`: Create a GNS3 Lab by creating a project and starting up the nodes diff --git a/Makefile b/Makefile index a7f74c0..a2145d4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION=1.2.2 +VERSION=1.4.0 build: rm -rf releases/ @@ -10,6 +10,9 @@ publish: build test-create-lab: cd test/playbooks; ansible-playbook main.yml -e execute=create +test-node-interaction: + cd test/playbooks; ansible-playbook node_interaction.yml + test-create-files: cd test/playbooks; ansible-playbook create_files.yml @@ -19,6 +22,6 @@ test-delete-files: test-delete-lab: cd test/playbooks; ansible-playbook main.yml -e execute=delete -test-create-env: test-create-lab test-create-files +test-create-all: test-create-lab test-create-files test-node-interaction -test-delete-env: test-delete-files test-delete-lab +test-delete-all: test-delete-files test-delete-lab diff --git a/galaxy.yml b/galaxy.yml index a613ae0..4084932 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,6 +1,6 @@ namespace: "davidban77" name: "gns3" -version: "1.3.0" +version: "1.4.0" readme: "README.md" description: "Module to interact with GNS3 server REST API based on gns3fy" authors: diff --git a/plugins/modules/gns3_node.py b/plugins/modules/gns3_node.py new file mode 100644 index 0000000..dca428a --- /dev/null +++ b/plugins/modules/gns3_node.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python + +ANSIBLE_METADATA = { + "metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community", +} + +DOCUMENTATION = """ +--- +module: gns3_node +short_description: Module to operate a node in a GNS3 server project +version_added: '2.8' +description: + - Module to operate a node in a GNS3 server project. + - It starts/stops/suspend/reloads a node. +requirements: [ gns3fy ] +author: + - David Flores (@netpanda) +options: + url: + description: + - URL target of the GNS3 server + required: true + type: str + port: + description: + - TCP port to connect to server REST API + type: int + default: 3080 + project_name: + description: + - Project name + type: str + project_id: + description: + - Project ID + type: str + node_name: + description: + - Node name + type: str + node_id: + description: + - Node ID + type: str + state: + description: + - State of the node, it can be: + - '- C(started): Starts a node' + - '- C(stopped): Stops a node' + - '- C(suspended): Suspends a node' + - '- C(reload): Special non-idempotent action that reloads a node' + type: str + choices: ['started', 'stopped', 'suspended', 'reload'] + retry: + description: + - Retries an action based on the state, if true and state is set to reload + - it will reload the device and try to start it again if the status was not + - changed + type: bool + default: false + poll_wait_time: + description: + - Delay in seconds to wait to poll nodes when they are started/stopped. + - Used when I(nodes_state) is C(started)/C(stopped) + type: int + default: 5 + force_project_open: + description: + - It will open the project (if closed) to interact with the device. + - Otherwise it will throw out an error + type: bool + default: false +""" + +EXAMPLES = """ +# Open a GNS3 project and start router01 node +- name: Start node + gns3_node: + url: http://localhost + project_name: lab_example + node_name: router01 + node_state: started + force_project_open: true + +# Stop a node and wait 10 seconds to poll for status +- name: Stop node + gns3_node: + url: http://localhost + project_name: lab_example + node_name: router01 + state: stopped + +# Suspend a node based on UUID +- name: Suspend node + gns3_node: + url: http://localhost + project_name: lab_example + node_id: 'ROUTER-UUID-SOMETHING-1234567' + state: suspended + +# Reload a node and apply a retry to start if needed +- name: Stop lab + gns3_node: + url: http://localhost + project_id: 'PROJECT-UUID-SOMETHING-1234567' + node_name: router01 + state: reload + retry: true + poll_wait_time: 30 +""" + +RETURN = """ +name: + description: Project name + type: str +project_id: + description: Project UUID + type: str +node_id: + description: Node UUID + type: str +status: + description: Project status. Possible values: opened, closed + type: str +node_directory: + description: Path of the node on the server (works only with compute=local) + type: str +node_type: + description: Network node type + type: str +""" +import time +import traceback +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + +GNS3FY_IMP_ERR = None +try: + from gns3fy import Gns3Connector, Project + + HAS_GNS3FY = True +except Exception: + HAS_GNS3FY = False + GNS3FY_IMP_ERR = traceback.format_exc() + + +def return_node_data(node): + "Returns the node main attributes" + return dict( + name=node.name, + project_id=node.project_id, + node_id=node.node_id, + status=node.status, + node_directory=node.node_directory, + node_type=node.node_type, + ) + + +def state_verification(expected_state, node, retry=False, poll_wait_time=5): + "Verifies node state and returns a changed attribute" + if expected_state == "started" and node.status != "started": + node.start() + if node.status != "started" and retry: + node.start() + return True + elif expected_state == "stopped" and node.status != "stopped": + node.stop() + if node.status != "stopped" and retry: + node.stop() + return True + elif expected_state == "suspended" and node.status != "suspended": + node.suspend() + if node.status != "suspended" and retry: + node.suspend() + return True + elif expected_state == "reload": + node.reload() + time.sleep(poll_wait_time) + node.get() + if node.status != "started" and retry: + node.start() + return True + return False + + +def main(): + module = AnsibleModule( + argument_spec=dict( + url=dict(type="str", required=True), + port=dict(type="int", default=3080), + state=dict( + type="str", + required=True, + choices=["started", "stopped", "suspended", "reload"], + ), + project_name=dict(type="str", default=None), + project_id=dict(type="str", default=None), + node_name=dict(type="str", default=None), + node_id=dict(type="str", default=None), + retry=dict(type="bool", default=False), + poll_wait_time=dict(type="int", default=5), + force_project_open=dict(type="bool", default=False), + ), + required_one_of=[["project_name", "project_id"], ["node_name", "node_id"]], + ) + result = dict(changed=False) + if not HAS_GNS3FY: + module.fail_json(msg=missing_required_lib("gns3fy"), exception=GNS3FY_IMP_ERR) + + server_url = module.params["url"] + server_port = module.params["port"] + state = module.params["state"] + project_name = module.params["project_name"] + project_id = module.params["project_id"] + node_name = module.params["node_name"] + node_id = module.params["node_id"] + retry = module.params["retry"] + poll_wait_time = module.params["poll_wait_time"] + force_project_open = module.params["force_project_open"] + + try: + # Create server session + server = Gns3Connector(url=f"{server_url}:{server_port}") + # Define the project + if project_name is not None: + project = Project(name=project_name, connector=server) + elif project_id is not None: + project = Project(project_id=project_id, connector=server) + if project is None: + module.fail_json(msg="Could not retrieve project. Check name", **result) + + project.get() + if project.status != "opened" and force_project_open: + project.open() + + # Retrieve node + if node_name is not None: + node = project.get_node(name=node_name) + elif node_id is not None: + node = project.get_node(node_id=node_id) + if node is None: + module.fail_json(msg="Could not retrieve node. Check name", **result) + except Exception as err: + module.fail_json(msg=str(err), **result) + + # Apply state change + result["changed"] = state_verification( + expected_state=state, node=node, retry=retry, poll_wait_time=poll_wait_time + ) + + # Return the node data + result["node"] = return_node_data(node) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/gns3_project.py b/plugins/modules/gns3_project.py index 1d53b52..aa1ed15 100644 --- a/plugins/modules/gns3_project.py +++ b/plugins/modules/gns3_project.py @@ -1,7 +1,7 @@ #!/usr/bin/env python ANSIBLE_METADATA = { - "metadata_version": "1.2", + "metadata_version": "1.3", "status": ["preview"], "supported_by": "community", } @@ -152,35 +152,27 @@ name: description: Project name type: str - returned: always project_id: description: Project UUID type: str - returned: always status: description: Project status. Possible values: opened, closed type: str - returned: always path: description: Path of the project on the server (works only with compute=local) type: str - returned: always auto_close: description: Project auto close when client cut off the notifications feed type: bool - returned: always auto_open: description: Project open when GNS3 start type: bool - returned: always auto_start: description: Project start when opened type: bool - returned: always filename: description: Project filename type: str - returned: always """ import time @@ -211,37 +203,39 @@ def return_project_data(project): ) -def nodes_state_verification(module, project, result): +# def nodes_state_verification(module, project, result): +def nodes_state_verification( + expected_nodes_state, nodes_strategy, nodes_delay, poll_wait_time, project +): "Verifies each node state and returns a changed attribute" nodes_statuses = [node.status for node in project.nodes] - expected_state = module.params["nodes_state"] # Verify if nodes do not match expected state - if expected_state == "started" and any( + if expected_nodes_state == "started" and any( status == "stopped" for status in nodes_statuses ): # Turnup the nodes based on strategy - if module.params["nodes_strategy"] == "all": - project.start_nodes(poll_wait_time=module.params["poll_wait_time"]) - elif module.params["nodes_strategy"] == "one_by_one": + if nodes_strategy == "all": + project.start_nodes(poll_wait_time=poll_wait_time) + elif nodes_strategy == "one_by_one": for node in project.nodes: if node.status != "started": node.start() - time.sleep(module.params["nodes_delay"]) - result["changed"] = True - elif expected_state == "stopped" and any( + time.sleep(nodes_delay) + return True + elif expected_nodes_state == "stopped" and any( status == "started" for status in nodes_statuses ): # Shutdown nodes based on strategy - if module.params["nodes_strategy"] == "all": - project.stop_nodes(poll_wait_time=module.params["poll_wait_time"]) - elif module.params["nodes_strategy"] == "one_by_one": + if nodes_strategy == "all": + project.stop_nodes(poll_wait_time=poll_wait_time) + elif nodes_strategy == "one_by_one": for node in project.nodes: if node.status != "stopped": node.stop() - time.sleep(module.params["nodes_delay"]) - result["changed"] = True - # if no match then there are no changes on the nodes to perform + time.sleep(nodes_delay) + return True + return False def create_node(node_spec, project, module): @@ -269,41 +263,60 @@ def create_link(link_spec, project, module): def main(): - module_args = dict( - url=dict(type="str", required=True), - port=dict(type="int", default=3080), - state=dict( - type="str", required=True, choices=["opened", "closed", "present", "absent"] - ), - project_name=dict(type="str", default=None), - project_id=dict(type="str", default=None), - nodes_state=dict(type="str", choices=["started", "stopped"]), - nodes_strategy=dict(type="str", choices=["all", "one_by_one"], default="all"), - nodes_delay=dict(type="int", default=10), - poll_wait_time=dict(type="int", default=5), - nodes_spec=dict(type="list"), - links_spec=dict(type="list"), - ) - result = dict(changed=False) module = AnsibleModule( - argument_spec=module_args, + argument_spec=dict( + url=dict(type="str", required=True), + port=dict(type="int", default=3080), + state=dict( + type="str", + required=True, + choices=["opened", "closed", "present", "absent"], + ), + project_name=dict(type="str", default=None), + project_id=dict(type="str", default=None), + nodes_state=dict(type="str", choices=["started", "stopped"]), + nodes_strategy=dict( + type="str", choices=["all", "one_by_one"], default="all" + ), + nodes_delay=dict(type="int", default=10), + poll_wait_time=dict(type="int", default=5), + nodes_spec=dict(type="list"), + links_spec=dict(type="list"), + ), supports_check_mode=True, required_one_of=[["project_name", "project_id"]], required_if=[["nodes_strategy", "one_by_one", ["nodes_delay"]]], ) + result = dict(changed=False) if not HAS_GNS3FY: module.fail_json(msg=missing_required_lib("gns3fy"), exception=GNS3FY_IMP_ERR) if module.check_mode: module.exit_json(**result) - # Create server session - server = Gns3Connector(url=f"{module.params['url']}:{module.params['port']}") - # Define the project - if module.params["project_name"] is not None: - project = Project(name=module.params["project_name"], connector=server) - elif module.params["project_id"] is not None: - project = Project(project_id=module.params["project_id"], connector=server) + server_url = module.params["url"] + server_port = module.params["port"] + state = module.params["state"] + project_name = module.params["project_name"] + project_id = module.params["project_id"] + nodes_state = module.params["nodes_state"] + nodes_strategy = module.params["nodes_strategy"] + nodes_delay = module.params["nodes_delay"] + poll_wait_time = module.params["poll_wait_time"] + nodes_spec = module.params["nodes_spec"] + links_spec = module.params["links_spec"] + + try: + # Create server session + server = Gns3Connector(url=f"{server_url}:{server_port}") + # Define the project + if project_name is not None: + project = Project(name=project_name, connector=server) + elif project_id is not None: + project = Project(project_id=project_id, connector=server) + except Exception as err: + module.fail_json(msg=str(err), **result) + #  Retrieve project information try: project.get() pr_exists = True @@ -311,29 +324,41 @@ def main(): pr_exists = False reason = str(err) - if module.params["state"] == "opened": + if state == "opened": if pr_exists: if project.status != "opened": # Open project project.open() # Now verify nodes - if module.params["nodes_state"] is not None: + if nodes_state is not None: # Change flag based on the nodes state - nodes_state_verification(module, project, result) + result["changed"] = nodes_state_verification( + expected_nodes_state=nodes_state, + nodes_strategy=nodes_strategy, + nodes_delay=nodes_delay, + poll_wait_time=poll_wait_time, + project=project, + ) else: # Means that nodes are not taken into account for idempotency result["changed"] = True # Even if the project is open if nodes_state has been set, check it else: - if module.params["nodes_state"] is not None: - nodes_state_verification(module, project, result) + if nodes_state is not None: + result["changed"] = nodes_state_verification( + expected_nodes_state=nodes_state, + nodes_strategy=nodes_strategy, + nodes_delay=nodes_delay, + poll_wait_time=poll_wait_time, + project=project, + ) else: module.fail_json(msg=reason, **result) - elif module.params["state"] == "closed": + elif state == "closed": if pr_exists: if project.status != "closed": # Close project @@ -342,19 +367,19 @@ def main(): else: module.fail_json(msg=reason, **result) - elif module.params["state"] == "present": + elif state == "present": if pr_exists: - if module.params["nodes_spec"] is not None: + if nodes_spec is not None: # Need to verify if nodes exist _nodes_already_created = [node.name for node in project.nodes] - for node_spec in module.params["nodes_spec"]: + for node_spec in nodes_spec: if node_spec["name"] not in _nodes_already_created: # Open the project in case it was closed project.open() create_node(node_spec, project, module) result["changed"] = True - if module.params["links_spec"] is not None: - for link_spec in module.params["links_spec"]: + if links_spec is not None: + for link_spec in links_spec: project.open() # Trigger another get to refresh nodes attributes project.get() @@ -366,15 +391,15 @@ def main(): # Create project project.create() # Nodes section - if module.params["nodes_spec"] is not None: - for node_spec in module.params["nodes_spec"]: + if nodes_spec is not None: + for node_spec in nodes_spec: create_node(node_spec, project, module) # Links section - if module.params["links_spec"] is not None: - for link_spec in module.params["links_spec"]: + if links_spec is not None: + for link_spec in links_spec: create_link(link_spec, project, module) result["changed"] = True - elif module.params["state"] == "absent": + elif state == "absent": if pr_exists: # Stop nodes and close project to perform delete gracefully if project.status != "opened": diff --git a/test/playbooks/node_interaction.yml b/test/playbooks/node_interaction.yml new file mode 100644 index 0000000..20828bd --- /dev/null +++ b/test/playbooks/node_interaction.yml @@ -0,0 +1,28 @@ +- hosts: localhost + gather_facts: no + tasks: + - name: Reload a node alpine node which requires a special reload + gns3_node: + url: "{{ gns3_url }}" + project_name: "{{ gns3_project_name }}" + node_name: alpine-1 + state: reload + retry: yes + poll_wait_time: 30 + + - name: Stop ios-1 + gns3_node: + url: "{{ gns3_url }}" + project_name: "{{ gns3_project_name }}" + node_name: ios-1 + state: stopped + + - name: Start ios-1 + gns3_node: + url: "{{ gns3_url }}" + project_name: "{{ gns3_project_name }}" + node_name: ios-1 + state: started + register: node + + - debug: var=node