From 9a4bdfcc4f57fa8ce6330848e8ac0edd3b47cf97 Mon Sep 17 00:00:00 2001 From: krlosromero Date: Fri, 2 Aug 2019 21:26:23 +0100 Subject: [PATCH 01/26] initial creation of nodes_inventory --- gns3fy/api.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/gns3fy/api.py b/gns3fy/api.py index 9a7f406..84b71f6 100644 --- a/gns3fy/api.py +++ b/gns3fy/api.py @@ -1,6 +1,6 @@ import time import requests -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse from requests import ConnectionError, ConnectTimeout, HTTPError from dataclasses import field from typing import Optional, Any, Dict, List @@ -309,7 +309,8 @@ def _verify_before_action(self): if _err: raise ValueError(f"{_err}") - extracted = [node for node in _response.json() if node["name"] == self.name] + extracted = [node for node in _response.json() + if node["name"] == self.name] if len(extracted) > 1: raise ValueError( "Multiple nodes found with same name. Need to submit node_id" @@ -805,6 +806,52 @@ def nodes_summary(self, is_print=True): return _nodes_summary if not is_print else None + def nodes_inventory(self, is_print=True): + """return an ansible-type inventory with the nodes of the project for initial Telnet + configuration + + e.g + { "spine00.cmh": { + "hostname": "127.0.0.1", + "username": "vagrant", + "password": "vagrant", + "port": 12444, + "platform": "eos", + "groups": [ + "cmh" + ], + }, + "spine01.cmh": { + "hostname": "127.0.0.1", + "username": "vagrant" + "password": "", + "platform": "junos", + "port": 12204, + "groups": [ + "cmh" + ] + } + } + """ + + if not self.nodes: + self.get_nodes() + + _nodes_inventory = {} + _hostname = urlparse(self.connector.base_url).hostname + + for _n in self.nodes: + + _nodes_inventory.update({_n.name: {'hostname': _hostname, + 'name': _n.name, + 'console_port': _n.console, + 'type': _n.node_type, + }}) + if is_print: + print(_nodes_inventory) + + return _nodes_inventory if not is_print else None + def links_summary(self, is_print=True): """ Returns a summary of the links insode the project. If `is_print` is False, it @@ -821,14 +868,16 @@ def links_summary(self, is_print=True): for _l in self.links: _side_a = _l.nodes[0] _side_b = _l.nodes[1] - _node_a = [x for x in self.nodes if x.node_id == _side_a["node_id"]][0] + _node_a = [x for x in self.nodes if x.node_id == + _side_a["node_id"]][0] _port_a = [ x["name"] for x in _node_a.ports if x["port_number"] == _side_a["port_number"] and x["adapter_number"] == _side_a["adapter_number"] ][0] - _node_b = [x for x in self.nodes if x.node_id == _side_b["node_id"]][0] + _node_b = [x for x in self.nodes if x.node_id == + _side_b["node_id"]][0] _port_b = [ x["name"] for x in _node_b.ports @@ -839,6 +888,7 @@ def links_summary(self, is_print=True): endpoint_b = f"{_node_b.name}: {_port_b}" if is_print: print(f"{endpoint_a} ---- {endpoint_b}") - _links_summary.append((_node_a.name, _port_a, _node_b.name, _port_b)) + _links_summary.append( + (_node_a.name, _port_a, _node_b.name, _port_b)) return _links_summary if not is_print else None From 0ceadf9da6fc1c8203083fd5dcb3d812ea3ebabe Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 3 Aug 2019 09:52:03 +0100 Subject: [PATCH 02/26] Adding creation feature plus extra connector and project methods --- gns3fy/api.py | 312 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 308 insertions(+), 4 deletions(-) diff --git a/gns3fy/api.py b/gns3fy/api.py index 9a7f406..5f0410a 100644 --- a/gns3fy/api.py +++ b/gns3fy/api.py @@ -131,13 +131,60 @@ def error_checker(response_obj): err = f"[ERROR][{response_obj.status_code}]: {response_obj.text}" return err if 400 <= response_obj.status_code <= 599 else False + def get_version(self): + "Returns the version information" + return self.http_call("get", url=f"{self.base_url}/version").json() + def get_projects(self): "Returns the list of dictionaries of the projects on the server" return self.http_call("get", url=f"{self.base_url}/projects").json() - def get_version(self): + def get_project(self, name): + "Retrives an specific project" + _projects = self.http_call("get", url=f"{self.base_url}/projects").json() + return [p for p in _projects if p["name"] == name][0] + + def get_templates(self): "Returns the version information" - return self.http_call("get", url=f"{self.base_url}/version").json() + return self.http_call("get", url=f"{self.base_url}/templates").json() + + def get_template_by_name(self, name): + "Retrives an specific template searching by name" + _templates = self.http_call("get", url=f"{self.base_url}/templates").json() + try: + return [t for t in _templates if t["name"] == name][0] + except IndexError: + return None + + def get_template_by_id(self, id): + "Retrives an specific template by id" + return self.http_call("get", url=f"{self.base_url}/templates/{id}").json() + + def get_node_by_id(self, project_id, node_id): + """ + Returns the node by locating ID + """ + _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}" + + _response = self.http_call("get", _url) + _err = self.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + return _response.json() + + def get_link_by_id(self, project_id, link_id): + """ + Returns the link by locating ID + """ + _url = f"{self.base_url}/projects/{project_id}/links/{link_id}" + + _response = self.http_call("get", _url) + _err = self.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + return _response.json() @dataclass(config=Config) @@ -220,6 +267,32 @@ def delete(self): self.project_id = None self.link_id = None + def create(self): + """ + Creates a link + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit project_id") + + _url = f"{self.connector.base_url}/projects/{self.project_id}/links" + + data = { + k: v + for k, v in self.__dict__.items() + if k not in ("connector", "__initialised__") + if v is not None + } + + _response = self.connector.http_call("post", _url, json_data=data) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Now update it + self._update(_response.json()) + @dataclass(config=Config) class Node: @@ -242,7 +315,7 @@ class Node: name: Optional[str] = None project_id: Optional[str] = None node_id: Optional[str] = None - compute_id: Optional[str] = None + compute_id: str = "local" node_type: Optional[str] = None node_directory: Optional[str] = None status: Optional[str] = None @@ -265,8 +338,9 @@ class Node: y: Optional[int] = None z: Optional[int] = None template_id: Optional[str] = None - properties: Optional[Any] = None + properties: Dict = field(default_factory=dict) + template: Optional[str] = None links: List[Link] = field(default_factory=list, repr=False) connector: Optional[Any] = field(default=None, repr=False) @@ -443,6 +517,74 @@ def suspend(self): else: self.get() + def create(self, extra_properties={}): + """ + Creates a node. + + Attributes: + - `with_template`: It fetches for the template data and applies it to the nodes + `properties` field. This is needed in order to create the desired node on the + topology. + - `extra_properties`: When issued, it applies the given dictionary of parameters + to the nodes `properties` + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit 'project_id'") + if not self.compute_id: + raise ValueError("Need to submit 'compute_id'") + if not self.name: + raise ValueError("Need to submit 'name'") + if not self.node_type: + raise ValueError("Need to submit 'node_type'") + if self.node_id: + raise ValueError("Node already created") + + _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" + + data = { + k: v + for k, v in self.__dict__.items() + if k + not in ("project_id", "template", "links", "connector", "__initialised__") + if v is not None + } + + # Fetch template for properties + if self.template_id: + _properties = self.connector.get_template_by_id(self.template_id) + elif self.template: + _properties = self.connector.get_template_by_name(self.template) + else: + raise ValueError("You must provide `template` or `template_id`") + + # Delete not needed fields + for _field in ( + "compute_id", + "default_name_format", + "template_type", + "template_id", + "builtin", + "name", # Needs to be deleted because it overrides the name of host + ): + try: + _properties.pop(_field) + except KeyError: + continue + + # Override/Merge extra properties + if extra_properties: + _properties.update(**extra_properties) + data.update(properties=_properties) + + _response = self.connector.http_call("post", _url, json_data=data) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + self._update(_response.json()) + def delete(self): """ Deletes the node from the project @@ -574,6 +716,32 @@ def get(self, get_links=True, get_nodes=True, get_stats=True): if get_links: self.get_links() + def create(self): + """ + Creates the project + """ + if not self.name: + raise ValueError("Need to submit projects `name`") + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + + _url = f"{self.connector.base_url}/projects" + + data = { + k: v + for k, v in self.__dict__.items() + if k not in ("stats", "nodes", "links", "connector", "__initialised__") + if v is not None + } + + _response = self.connector.http_call("post", _url, json_data=data) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Now update it + self._update(_response.json()) + def update(self, **kwargs): """ Updates the project instance by passing the keyword arguments directly into the @@ -842,3 +1010,139 @@ def links_summary(self, is_print=True): _links_summary.append((_node_a.name, _port_a, _node_b.name, _port_b)) return _links_summary if not is_print else None + + def _search_node(self, key, value): + "Performs a search based on a key and value" + # Retrive nodes if neccesary + if not self.nodes: + self.get_nodes() + + try: + return [_p for _p in self.nodes if getattr(_p, key) == value][0] + except IndexError: + return None + + def get_node(self, name=None, node_id=None): + """ + Returns the Node object by searching for the name or the node_id. + + NOTE: Run method `get_nodes()` manually to refresh list of nodes if necessary + """ + if node_id: + return self._search_node(key="node_id", value=node_id) + elif name: + return self._search_node(key="name", value=name) + else: + raise ValueError("name or node_ide must be provided") + + def _search_link(self, key, value): + "Performs a search based on a key and value" + # Retrive links if neccesary + if not self.links: + self.get_links() + + try: + return [_p for _p in self.links if getattr(_p, key) == value][0] + except IndexError: + return None + + def get_link(self, link_id): + """ + Returns the Link object by locating ID + + NOTE: Run method `get_links()` manually to refresh list of nodes if necessary + """ + if link_id: + return self._search_node(key="link_id", value=link_id) + else: + raise ValueError("name or node_ide must be provided") + + def create_node(self, name=None, **kwargs): + """ + Creates a node + """ + if not self.nodes: + self.get_nodes() + + # Even though GNS3 allow same name to be pushed because it automatically + # generates a new name if matches an exising node, here is verified beforehand + # and forces developer to create new name. + _matches = [(_n.name, _n.node_id) for _n in self.nodes if name == _n.name] + if _matches: + raise ValueError( + f"Node with equal name found: {_matches[0][0]} - ID: {_matches[0][-1]}" + ) + + _node = Node( + project_id=self.project_id, connector=self.connector, name=name, **kwargs + ) + _node.create() + self.nodes.append(_node) + print( + f"Created: {_node.name} -- Type: {_node.node_type} -- " + f"Console: {_node.console}" + ) + + def create_link(self, node_a, port_a, node_b, port_b): + """ + Creates a link + """ + if not self.nodes: + self.get_nodes() + if not self.links: + self.get_links() + + _node_a = self.get_node(name=node_a) + if not _node_a: + raise ValueError(f"node_a: {node_a} not found") + try: + _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] + except IndexError: + raise ValueError(f"port_a: {port_a} - not found") + + _node_b = self.get_node(name=node_b) + if not _node_b: + raise ValueError(f"node_b: {node_b} not found") + try: + _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] + except IndexError: + raise ValueError(f"port_b: {port_b} - not found") + + _matches = [] + for _l in self.links: + if ( + _l.nodes[0]["node_id"] == _node_a.node_id + and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] + and _l.nodes[0]["port_number"] == _port_a["port_number"] + ): + _matches.append(_l) + elif ( + _l.nodes[1]["node_id"] == _node_b.node_id + and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"] + and _l.nodes[1]["port_number"] == _port_b["port_number"] + ): + _matches.append(_l) + if _matches: + raise ValueError(f"At least one port is used, ID: {_matches[0].link_id}") + + # Now create the link! + _link = Link( + project_id=self.project_id, + connector=self.connector, + nodes=[ + dict( + node_id=_node_a.node_id, + adapter_number=_port_a["adapter_number"], + port_number=_port_a["port_number"], + ), + dict( + node_id=_node_b.node_id, + adapter_number=_port_b["adapter_number"], + port_number=_port_b["port_number"], + ), + ], + ) + + _link.create() + self.links.append(_link) + print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}") From a0de8f80d95ca4e8f231680088c562b552f42f88 Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 15:17:57 +0100 Subject: [PATCH 03/26] Pushing changes and tests --- gns3fy/api.py | 61 +++- poetry.lock | 37 ++- pyproject.toml | 2 + tests/data/nodes.json | 24 +- tests/data/nodes.py | 18 +- tests/data/projects.json | 10 +- tests/data/projects.py | 6 +- tests/data/templates.json | 174 ++++++++++ tests/data/version.json | 4 + tests/test_api.py | 683 +++++++++++++++++++++++++++++++++++++- tests/test_mock_server.py | 43 +++ 11 files changed, 1008 insertions(+), 54 deletions(-) create mode 100644 tests/data/templates.json create mode 100644 tests/data/version.json create mode 100644 tests/test_mock_server.py diff --git a/gns3fy/api.py b/gns3fy/api.py index 5f0410a..1857bdc 100644 --- a/gns3fy/api.py +++ b/gns3fy/api.py @@ -72,10 +72,17 @@ def __init__(self, url=None, user=None, cred=None, verify=False, api_version=2): self.api_calls = 0 # Create session object + self.create_session() + # self.session = requests.Session() + # self.session.headers["Accept"] = "application/json" + # if self.user: + # self.session.auth = (user, cred) + + def create_session(self): self.session = requests.Session() self.session.headers["Accept"] = "application/json" if self.user: - self.session.auth = (user, cred) + self.session.auth = (self.user, self.cred) def http_call( self, @@ -93,6 +100,7 @@ def http_call( _response = getattr(self.session, method.lower())( url, data=urlencode(data), + # data=data, headers=headers, params=params, verify=verify, @@ -139,10 +147,17 @@ def get_projects(self): "Returns the list of dictionaries of the projects on the server" return self.http_call("get", url=f"{self.base_url}/projects").json() - def get_project(self, name): + def get_project_by_name(self, name): "Retrives an specific project" _projects = self.http_call("get", url=f"{self.base_url}/projects").json() - return [p for p in _projects if p["name"] == name][0] + try: + return [p for p in _projects if p["name"] == name][0] + except IndexError: + return None + + def get_project_by_id(self, id): + "Retrives an specific template by id" + return self.http_call("get", url=f"{self.base_url}/projects/{id}").json() def get_templates(self): "Returns the version information" @@ -160,31 +175,47 @@ def get_template_by_id(self, id): "Retrives an specific template by id" return self.http_call("get", url=f"{self.base_url}/templates/{id}").json() + def get_nodes(self, project_id): + return self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}/nodes" + ).json() + def get_node_by_id(self, project_id, node_id): """ Returns the node by locating ID """ _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}" + return self.http_call("get", _url).json() - _response = self.http_call("get", _url) - _err = self.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - return _response.json() + def get_links(self, project_id): + return self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}/links" + ).json() def get_link_by_id(self, project_id, link_id): """ Returns the link by locating ID """ _url = f"{self.base_url}/projects/{project_id}/links/{link_id}" + return self.http_call("get", _url).json() - _response = self.http_call("get", _url) - _err = self.error_checker(_response) - if _err: - raise ValueError(f"{_err}") + def create_project(self, **kwargs): + """ + Pass a dictionary type object with the project parameters to be created. + Parameter `name` is mandatory. Returns project + """ + _url = f"{self.base_url}/projects" + if "name" not in kwargs: + raise ValueError("Parameter 'name' is mandatory") + return self.http_call("post", _url, json_data=kwargs).json() - return _response.json() + def delete_project(self, project_id): + """ + Deletes a project from server + """ + _url = f"{self.base_url}/projects/{project_id}" + self.http_call("delete", _url) + return @dataclass(config=Config) @@ -987,6 +1018,8 @@ def links_summary(self, is_print=True): _links_summary = [] for _l in self.links: + if not _l.nodes: + continue _side_a = _l.nodes[0] _side_b = _l.nodes[1] _node_a = [x for x in self.nodes if x.node_id == _side_a["node_id"]][0] diff --git a/poetry.lock b/poetry.lock index f25932a..d6fb126 100644 --- a/poetry.lock +++ b/poetry.lock @@ -86,6 +86,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "0.4.1" +[[package]] +category = "dev" +description = "Code coverage measurement for Python" +name = "coverage" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4" +version = "4.5.4" + [[package]] category = "dev" description = "Better living through Python with decorators" @@ -331,6 +339,18 @@ pluggy = ">=0.12,<1.0" py = ">=1.5.0" wcwidth = "*" +[[package]] +category = "dev" +description = "Pytest plugin for measuring coverage." +name = "pytest-cov" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.7.1" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=3.6" + [[package]] category = "main" description = "Python HTTP for Humans." @@ -345,6 +365,18 @@ chardet = ">=3.0.2,<3.1.0" idna = ">=2.5,<2.9" urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +[[package]] +category = "dev" +description = "Mock out responses from the requests package" +name = "requests-mock" +optional = false +python-versions = "*" +version = "1.6.0" + +[package.dependencies] +requests = ">=2.3" +six = "*" + [[package]] category = "dev" description = "Python 2 and 3 compatibility utilities" @@ -399,7 +431,7 @@ python-versions = ">=2.7" version = "0.5.2" [metadata] -content-hash = "f69e7b141942052c2f3891313a0a7e607f34ae74eae2cf527e86c9a9c0c0e0dd" +content-hash = "f8431e7b087331446fa25cc23b03922a0328fcd979c0e1e85aa3b1b8a4083ae7" python-versions = "^3.7" [metadata.hashes] @@ -413,6 +445,7 @@ certifi = ["046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", " chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] +coverage = ["08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", "0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", "141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", "19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", "23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", "245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", "331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", "386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", "3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", "60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", "63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", "6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", "6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", "7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", "826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", "93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", "9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", "af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", "bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", "bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", "c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", "dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", "df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", "e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", "e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", "e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", "eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", "eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", "ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", "efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", "fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", "ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025"] decorator = ["86156361c50488b84a3f148056ea716ca587df2f0de1d34750d35c21312725de", "f069f3a01830ca754ba5258fde2278454a0b5b79e0d7f5c13b3b97e57d4acff6"] entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] flake8 = ["19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", "8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"] @@ -437,7 +470,9 @@ pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] pyparsing = ["6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", "d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"] pytest = ["6ef6d06de77ce2961156013e9dff62f1b2688aa04d0dc244299fe7d67e09370d", "a736fed91c12681a7b34617c8fcefe39ea04599ca72c608751c31d89579a3f77"] +pytest-cov = ["2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", "e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a"] requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] +requests-mock = ["12e17c7ad1397fd1df5ead7727eb3f1bdc9fe1c18293b0492e0e01b57997e38d", "dc9e416a095ee7c3360056990d52e5611fb94469352fc1c2dc85be1ff2189146"] six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] traitlets = ["9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835", "c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9"] diff --git a/pyproject.toml b/pyproject.toml index 446b5b9..6b82a40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ ipython = "^7.7" pytest = "^5.0" flake8 = "^3.7" black = {version = "^18.3-alpha.0",allows-prereleases = true} +requests-mock = "^1.6" +pytest-cov = "^2.7" [build-system] requires = ["poetry>=0.12"] diff --git a/tests/data/nodes.json b/tests/data/nodes.json index 01a21db..c3b9bd7 100644 --- a/tests/data/nodes.json +++ b/tests/data/nodes.json @@ -164,7 +164,8 @@ "width": 72, "x": -11, "y": -114, - "z": 1 + "z": 1, + "template": null }, { "command_line": "/opt/gns3/images/IOU/L3-ADVENTERPRISEK9-M-15.4-2T.bin 1", @@ -387,7 +388,8 @@ "width": 60, "x": -184, "y": -139, - "z": 1 + "z": 1, + "template": null }, { "command_line": "/opt/gns3/images/IOU/L3-ADVENTERPRISEK9-M-15.4-2T.bin 2", @@ -610,10 +612,11 @@ "width": 60, "x": -183, "y": 6, - "z": 1 + "z": 1, + "template": null }, { - "command_line": "/usr/bin/qemu-system-x86_64 -name vEOS-4.21.5F-1 -m 2048M -smp cpus=2 -enable-kvm -machine smm=off -boot order=c -drive file=/opt/gns3/projects/4b21dfb3-675a-4efa-8613-2f7fb32e76fe/project-files/qemu/8283b923-df0e-4bc1-8199-be6fea40f500/hda_disk.qcow2,if=ide,index=0,media=disk -uuid 8283b923-df0e-4bc1-8199-be6fea40f500 -serial telnet:127.0.0.1:5004,server,nowait -monitor tcp:127.0.0.1:35261,server,nowait -net none -device e1000,mac=0c:76:fe:f5:00:00,netdev=gns3-0 -netdev socket,id=gns3-0,udp=127.0.0.1:10011,localaddr=127.0.0.1:10010 -device e1000,mac=0c:76:fe:f5:00:01,netdev=gns3-1 -netdev socket,id=gns3-1,udp=127.0.0.1:10013,localaddr=127.0.0.1:10012 -device e1000,mac=0c:76:fe:f5:00:02,netdev=gns3-2 -netdev socket,id=gns3-2,udp=127.0.0.1:10015,localaddr=127.0.0.1:10014 -device e1000,mac=0c:76:fe:f5:00:03,netdev=gns3-3 -netdev socket,id=gns3-3,udp=127.0.0.1:10017,localaddr=127.0.0.1:10016 -device e1000,mac=0c:76:fe:f5:00:04,netdev=gns3-4 -netdev socket,id=gns3-4,udp=127.0.0.1:10019,localaddr=127.0.0.1:10018 -device e1000,mac=0c:76:fe:f5:00:05,netdev=gns3-5 -netdev socket,id=gns3-5,udp=127.0.0.1:10021,localaddr=127.0.0.1:10020 -device e1000,mac=0c:76:fe:f5:00:06,netdev=gns3-6 -netdev socket,id=gns3-6,udp=127.0.0.1:10023,localaddr=127.0.0.1:10022 -device e1000,mac=0c:76:fe:f5:00:07,netdev=gns3-7 -netdev socket,id=gns3-7,udp=127.0.0.1:10025,localaddr=127.0.0.1:10024 -device e1000,mac=0c:76:fe:f5:00:08,netdev=gns3-8 -netdev socket,id=gns3-8,udp=127.0.0.1:10027,localaddr=127.0.0.1:10026 -device e1000,mac=0c:76:fe:f5:00:09,netdev=gns3-9 -netdev socket,id=gns3-9,udp=127.0.0.1:10029,localaddr=127.0.0.1:10028 -device e1000,mac=0c:76:fe:f5:00:0a,netdev=gns3-10 -netdev socket,id=gns3-10,udp=127.0.0.1:10031,localaddr=127.0.0.1:10030 -device e1000,mac=0c:76:fe:f5:00:0b,netdev=gns3-11 -netdev socket,id=gns3-11,udp=127.0.0.1:10033,localaddr=127.0.0.1:10032 -device e1000,mac=0c:76:fe:f5:00:0c,netdev=gns3-12 -netdev socket,id=gns3-12,udp=127.0.0.1:10035,localaddr=127.0.0.1:10034 -nographic", + "command_line": "/usr/bin/qemu-system-x86_64 -name vEOS -m 2048M -smp cpus=2 -enable-kvm -machine smm=off -boot order=c -drive file=/opt/gns3/projects/4b21dfb3-675a-4efa-8613-2f7fb32e76fe/project-files/qemu/8283b923-df0e-4bc1-8199-be6fea40f500/hda_disk.qcow2,if=ide,index=0,media=disk -uuid 8283b923-df0e-4bc1-8199-be6fea40f500 -serial telnet:127.0.0.1:5004,server,nowait -monitor tcp:127.0.0.1:35261,server,nowait -net none -device e1000,mac=0c:76:fe:f5:00:00,netdev=gns3-0 -netdev socket,id=gns3-0,udp=127.0.0.1:10011,localaddr=127.0.0.1:10010 -device e1000,mac=0c:76:fe:f5:00:01,netdev=gns3-1 -netdev socket,id=gns3-1,udp=127.0.0.1:10013,localaddr=127.0.0.1:10012 -device e1000,mac=0c:76:fe:f5:00:02,netdev=gns3-2 -netdev socket,id=gns3-2,udp=127.0.0.1:10015,localaddr=127.0.0.1:10014 -device e1000,mac=0c:76:fe:f5:00:03,netdev=gns3-3 -netdev socket,id=gns3-3,udp=127.0.0.1:10017,localaddr=127.0.0.1:10016 -device e1000,mac=0c:76:fe:f5:00:04,netdev=gns3-4 -netdev socket,id=gns3-4,udp=127.0.0.1:10019,localaddr=127.0.0.1:10018 -device e1000,mac=0c:76:fe:f5:00:05,netdev=gns3-5 -netdev socket,id=gns3-5,udp=127.0.0.1:10021,localaddr=127.0.0.1:10020 -device e1000,mac=0c:76:fe:f5:00:06,netdev=gns3-6 -netdev socket,id=gns3-6,udp=127.0.0.1:10023,localaddr=127.0.0.1:10022 -device e1000,mac=0c:76:fe:f5:00:07,netdev=gns3-7 -netdev socket,id=gns3-7,udp=127.0.0.1:10025,localaddr=127.0.0.1:10024 -device e1000,mac=0c:76:fe:f5:00:08,netdev=gns3-8 -netdev socket,id=gns3-8,udp=127.0.0.1:10027,localaddr=127.0.0.1:10026 -device e1000,mac=0c:76:fe:f5:00:09,netdev=gns3-9 -netdev socket,id=gns3-9,udp=127.0.0.1:10029,localaddr=127.0.0.1:10028 -device e1000,mac=0c:76:fe:f5:00:0a,netdev=gns3-10 -netdev socket,id=gns3-10,udp=127.0.0.1:10031,localaddr=127.0.0.1:10030 -device e1000,mac=0c:76:fe:f5:00:0b,netdev=gns3-11 -netdev socket,id=gns3-11,udp=127.0.0.1:10033,localaddr=127.0.0.1:10032 -device e1000,mac=0c:76:fe:f5:00:0c,netdev=gns3-12 -netdev socket,id=gns3-12,udp=127.0.0.1:10035,localaddr=127.0.0.1:10034 -nographic", "compute_id": "local", "console": 5003, "console_auto_start": false, @@ -625,12 +628,12 @@ "label": { "rotation": 0, "style": "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;", - "text": "vEOS-4.21.5F-1", + "text": "vEOS", "x": -15, "y": -25 }, "locked": false, - "name": "vEOS-4.21.5F-1", + "name": "vEOS", "node_directory": "/opt/gns3/projects/4b21dfb3-675a-4efa-8613-2f7fb32e76fe/project-files/qemu/8283b923-df0e-4bc1-8199-be6fea40f500", "node_id": "8283b923-df0e-4bc1-8199-be6fea40f500", "node_type": "qemu", @@ -839,7 +842,8 @@ "width": 60, "x": -20, "y": -6, - "z": 1 + "z": 1, + "template": null }, { "command_line": null, @@ -908,7 +912,8 @@ "width": 60, "x": 169, "y": -10, - "z": 1 + "z": 1, + "template": null }, { "command_line": null, @@ -1020,6 +1025,7 @@ "width": 159, "x": 150, "y": -236, - "z": 1 + "z": 1, + "template": null } ] diff --git a/tests/data/nodes.py b/tests/data/nodes.py index 71e6275..7bc89a8 100644 --- a/tests/data/nodes.py +++ b/tests/data/nodes.py @@ -33,7 +33,7 @@ "{'name': 'Ethernet4', 'port_number': 4, 'type': 'access', 'vlan': 1}, {'name" "': 'Ethernet5', 'port_number': 5, 'type': 'access', 'vlan': 1}, {'name': '" "Ethernet6', 'port_number': 6, 'type': 'access', 'vlan': 1}, {'name': '" - "Ethernet7', 'port_number': 7, 'type': 'access', 'vlan': 1}]})" + "Ethernet7', 'port_number': 7, 'type': 'access', 'vlan': 1}]}, template=None)" ), ( "Node(name='IOU1', project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe', " @@ -91,7 +91,7 @@ "a2638382db0e', properties={'application_id': 1, 'ethernet_adapters': 2, '" "l1_keepalives': False, 'md5sum': '50d1c5aaf1976e4622daf9eaa2632212', 'nvram': " "64, 'path': 'L3-ADVENTERPRISEK9-M-15.4-2T.bin', 'ram': 256, 'serial_adapters" - "': 2, 'usage': '', 'use_default_iou_values': True})" + "': 2, 'usage': '', 'use_default_iou_values': True}, template=None)" ), ( "Node(name='IOU2', project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe', " @@ -149,10 +149,10 @@ "a2638382db0e', properties={'application_id': 2, 'ethernet_adapters': 2, '" "l1_keepalives': False, 'md5sum': '50d1c5aaf1976e4622daf9eaa2632212', 'nvram': " "64, 'path': 'L3-ADVENTERPRISEK9-M-15.4-2T.bin', 'ram': 256, 'serial_adapters" - "': 2, 'usage': '', 'use_default_iou_values': True})" + "': 2, 'usage': '', 'use_default_iou_values': True}, template=None)" ), ( - "Node(name='vEOS-4.21.5F-1', project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe" + "Node(name='vEOS', project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe" "', node_id='8283b923-df0e-4bc1-8199-be6fea40f500', compute_id='local', " "node_type='qemu', node_directory='/opt/gns3/projects/4b21dfb3-675a-4efa-8613-" "2f7fb32e76fe/project-files/qemu/8283b923-df0e-4bc1-8199-be6fea40f500', status" @@ -198,9 +198,9 @@ "short_name': 'e11'}], port_name_format='Ethernet{0}', port_segment_size=0, " "first_port_name='Management1', locked=False, label={'rotation': 0, 'style': '" "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-" - "opacity: 1.0;', 'text': 'vEOS-4.21.5F-1', 'x': -15, 'y': -25}, console='5003" + "opacity: 1.0;', 'text': 'vEOS', 'x': -15, 'y': -25}, console='5003" "', console_host='0.0.0.0', console_type='telnet', console_auto_start=False, " - "command_line='/usr/bin/qemu-system-x86_64 -name vEOS-4.21.5F-1 -m 2048M -smp " + "command_line='/usr/bin/qemu-system-x86_64 -name vEOS -m 2048M -smp " "cpus=2 -enable-kvm -machine smm=off -boot order=c -drive file=/opt/gns3/" "projects/4b21dfb3-675a-4efa-8613-2f7fb32e76fe/project-files/qemu/" "8283b923-df0e-4bc1-8199-be6fea40f500/hda_disk.qcow2,if=ide,index=0,media=disk " @@ -242,7 +242,7 @@ "kernel_image_md5sum': None, 'legacy_networking': False, 'linked_clone': True, " "'mac_address': '0c:76:fe:f5:00:00', 'on_close': 'power_off', 'options': '-" "nographic', 'platform': 'x86_64', 'process_priority': 'normal', 'qemu_path': " - "'/usr/bin/qemu-system-x86_64', 'ram': 2048, 'usage': ''})" + "'/usr/bin/qemu-system-x86_64', 'ram': 2048, 'usage': ''}, template=None)" ), ( "Node(name='alpine-1', project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe', " @@ -265,7 +265,7 @@ "console_resolution': '1024x768', 'container_id': " "'a2109a13328c2a5f57a7405b43bcf791811f85c1d90267693fe3b57dfefe81d9', '" "environment': None, 'extra_hosts': None, 'extra_volumes': [], 'image': '" - "alpine:latest', 'start_command': None, 'usage': ''})" + "alpine:latest', 'start_command': None, 'usage': ''}, template=None)" ), ( "Node(name='Cloud-1', project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe', " @@ -292,6 +292,6 @@ "[{'interface': 'eth0', 'name': 'eth0', 'port_number': 0, 'type': 'ethernet'}, " "{'interface': 'eth1', 'name': 'eth1', 'port_number': 1, 'type': 'ethernet'}], " "'remote_console_host': '', 'remote_console_http_path': '/', '" - "remote_console_port': 23, 'remote_console_type': 'none'})" + "remote_console_port': 23, 'remote_console_type': 'none'}, template=None)" ), ] diff --git a/tests/data/projects.json b/tests/data/projects.json index edf09fc..8f64766 100644 --- a/tests/data/projects.json +++ b/tests/data/projects.json @@ -15,7 +15,7 @@ "show_interface_labels": false, "show_layers": false, "snap_to_grid": false, - "status": "opened", + "status": "closed", "supplier": null, "variables": null, "zoom": 100, @@ -45,12 +45,6 @@ "status": "opened", "supplier": null, "variables": null, - "zoom": 100, - "stats": { - "drawings": 0, - "links": 4, - "nodes": 6, - "snapshots": 0 - } + "zoom": 100 } ] diff --git a/tests/data/projects.py b/tests/data/projects.py index 45f6877..baaa30b 100644 --- a/tests/data/projects.py +++ b/tests/data/projects.py @@ -1,7 +1,7 @@ PROJECTS_REPR = [ ( "Project(project_id='c9dc56bf-37b9-453b-8f95-2845ce8908e3', name='test2', " - "status='opened', path='/opt/gns3/projects/c9dc56bf-37b9-453b-8f95-" + "status='closed', path='/opt/gns3/projects/c9dc56bf-37b9-453b-8f95-" "2845ce8908e3', filename='test2.gns3', auto_start=False, auto_close=False, " "auto_open=False, drawing_grid_size=25, grid_size=75, scene_height=1000, " "scene_width=2000, show_grid=True, show_interface_labels=False, show_layers=" @@ -14,7 +14,7 @@ "2f7fb32e76fe', filename='test_api1.gns3', auto_start=True, auto_close=False, " "auto_open=False, drawing_grid_size=25, grid_size=75, scene_height=1000, " "scene_width=2000, show_grid=False, show_interface_labels=False, show_layers=" - "False, snap_to_grid=False, supplier=None, variables=None, zoom=100, stats={'" - "drawings': 0, 'links': 4, 'nodes': 6, 'snapshots': 0})" + "False, snap_to_grid=False, supplier=None, variables=None, zoom=100," + " stats=None)" ), ] diff --git a/tests/data/templates.json b/tests/data/templates.json new file mode 100644 index 0000000..d0ba152 --- /dev/null +++ b/tests/data/templates.json @@ -0,0 +1,174 @@ +[ + { + "builtin": false, + "category": "router", + "compute_id": "local", + "console_auto_start": false, + "console_type": "telnet", + "default_name_format": "IOU{0}", + "ethernet_adapters": 2, + "l1_keepalives": false, + "name": "IOU-L3", + "nvram": 128, + "path": "L3-image.bin", + "private_config": "", + "ram": 256, + "serial_adapters": 2, + "startup_config": "iou_l3_base_startup-config.txt", + "symbol": ":/symbols/affinity/circle/gray/router.svg", + "template_id": "8504c605-7914-4a8f-9cd4-a2638382db0e", + "template_type": "iou", + "usage": "", + "use_default_iou_values": true + }, + { + "builtin": false, + "category": "switch", + "compute_id": "local", + "console_auto_start": false, + "console_type": "telnet", + "default_name_format": "IOU{0}", + "ethernet_adapters": 4, + "l1_keepalives": false, + "name": "IOU-L2", + "nvram": 128, + "path": "L2-image.bin", + "private_config": "", + "ram": 256, + "serial_adapters": 0, + "startup_config": "iou_l2_base_startup-config.txt", + "symbol": ":/symbols/affinity/square/gray/switch.svg", + "template_id": "92cccfb2-6401-48f2-8964-3c75323be3cb", + "template_type": "iou", + "usage": "", + "use_default_iou_values": true + }, + { + "adapter_type": "e1000", + "adapters": 13, + "bios_image": "", + "boot_priority": "c", + "builtin": false, + "category": "router", + "cdrom_image": "", + "compute_id": "local", + "console_auto_start": false, + "console_type": "telnet", + "cpu_throttling": 0, + "cpus": 2, + "custom_adapters": [], + "default_name_format": "{name}-{0}", + "first_port_name": "Management1", + "hda_disk_image": "vEOS-image.vmdk", + "hda_disk_interface": "ide", + "hdb_disk_image": "", + "hdb_disk_interface": "ide", + "hdc_disk_image": "", + "hdc_disk_interface": "ide", + "hdd_disk_image": "", + "hdd_disk_interface": "ide", + "initrd": "", + "kernel_command_line": "", + "kernel_image": "", + "legacy_networking": false, + "linked_clone": true, + "mac_address": null, + "name": "vEOS", + "on_close": "power_off", + "options": "-nographic", + "platform": "", + "port_name_format": "Ethernet{0}", + "port_segment_size": 0, + "process_priority": "normal", + "qemu_path": "/usr/bin/qemu-system-x86_64", + "ram": 2048, + "symbol": ":/symbols/affinity/square/gray/switch_multilayer.svg", + "template_id": "c6203d4b-d0ce-4951-bf18-c44369d46804", + "template_type": "qemu", + "usage": "" + }, + { + "adapters": 2, + "builtin": false, + "category": "guest", + "compute_id": "local", + "console_auto_start": false, + "console_http_path": "/", + "console_http_port": 80, + "console_resolution": "1024x768", + "console_type": "telnet", + "custom_adapters": [], + "default_name_format": "{name}-{0}", + "environment": "", + "extra_hosts": "", + "extra_volumes": [], + "image": "alpine", + "name": "alpine", + "start_command": "", + "symbol": ":/symbols/affinity/circle/gray/docker.svg", + "template_id": "847e5333-6ac9-411f-a400-89838584371b", + "template_type": "docker", + "usage": "" + }, + { + "builtin": true, + "category": "guest", + "name": "Cloud", + "symbol": ":/symbols/cloud.svg", + "template_id": "39e257dc-8412-3174-b6b3-0ee3ed6a43e9", + "template_type": "cloud" + }, + { + "builtin": true, + "category": "guest", + "name": "NAT", + "symbol": ":/symbols/cloud.svg", + "template_id": "df8f4ea9-33b7-3e96-86a2-c39bc9bb649c", + "template_type": "nat" + }, + { + "builtin": true, + "category": "guest", + "default_name_format": "PC-{0}", + "name": "VPCS", + "properties": { + "base_script_file": "vpcs_base_config.txt" + }, + "symbol": ":/symbols/vpcs_guest.svg", + "template_id": "19021f99-e36f-394d-b4a1-8aaa902ab9cc", + "template_type": "vpcs" + }, + { + "builtin": true, + "category": "switch", + "console_type": "none", + "name": "Ethernet switch", + "symbol": ":/symbols/ethernet_switch.svg", + "template_id": "1966b864-93e7-32d5-965f-001384eec461", + "template_type": "ethernet_switch" + }, + { + "builtin": true, + "category": "switch", + "name": "Ethernet hub", + "symbol": ":/symbols/hub.svg", + "template_id": "b4503ea9-d6b6-3695-9fe4-1db3b39290b0", + "template_type": "ethernet_hub" + }, + { + "builtin": true, + "category": "switch", + "name": "Frame Relay switch", + "symbol": ":/symbols/frame_relay_switch.svg", + "template_id": "dd0f6f3a-ba58-3249-81cb-a1dd88407a47", + "template_type": "frame_relay_switch" + }, + { + "builtin": true, + "category": "switch", + "name": "ATM switch", + "symbol": ":/symbols/atm_switch.svg", + "template_id": "aaa764e2-b383-300f-8a0e-3493bbfdb7d2", + "template_type": "atm_switch" + } +] diff --git a/tests/data/version.json b/tests/data/version.json new file mode 100644 index 0000000..eb4dc49 --- /dev/null +++ b/tests/data/version.json @@ -0,0 +1,4 @@ +{ + "local": true, + "version": "2.2.0" +} diff --git a/tests/test_api.py b/tests/test_api.py index d3ce1d9..e61520d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,46 +1,709 @@ import json import pytest +import requests +import requests_mock from pathlib import Path -from gns3fy import Link, Node, Project +from pydantic.error_wrappers import ValidationError +from gns3fy import Link, Node, Project, Gns3Connector from .data import links, nodes, projects + DATA_FILES = Path(__file__).resolve().parent / "data" +BASE_URL = "mock://gns3server:3080" +CPROJECT = {"name": "API_TEST", "id": "4b21dfb3-675a-4efa-8613-2f7fb32e76fe"} +CNODE = {"name": "alpine-1", "id": "ef503c45-e998-499d-88fc-2765614b313e"} +CTEMPLATE = {"name": "alpine", "id": "847e5333-6ac9-411f-a400-89838584371b"} +CLINK = {"link_type": "ethernet", "id": "4d9f1235-7fd1-466b-ad26-0b4b08beb778"} -@pytest.fixture def links_data(): with open(DATA_FILES / "links.json") as fdata: data = json.load(fdata) return data -@pytest.fixture def nodes_data(): with open(DATA_FILES / "nodes.json") as fdata: data = json.load(fdata) return data -@pytest.fixture def projects_data(): with open(DATA_FILES / "projects.json") as fdata: data = json.load(fdata) return data +def templates_data(): + with open(DATA_FILES / "templates.json") as fdata: + data = json.load(fdata) + return data + + +def version_data(): + with open(DATA_FILES / "version.json") as fdata: + data = json.load(fdata) + return data + + +def json_api_test_project(): + "Fetches the API_TEST project response" + return next((_p for _p in projects_data() if _p["project_id"] == CPROJECT["id"])) + + +def json_api_test_node(): + "Fetches the alpine-1 node response" + return next((_n for _n in nodes_data() if _n["node_id"] == CNODE["id"])) + + +def json_api_test_template(): + "Fetches the alpine template response" + return next((_t for _t in templates_data() if _t["template_id"] == CTEMPLATE["id"])) + + +def json_api_test_link(): + "Fetches the alpine link response" + return next((_l for _l in links_data() if _l["link_id"] == CLINK["id"])) + + +def post_put_matcher(request): + resp = requests.Response() + if request.method == "POST": + if request.path_url.endswith("/projects"): + # Now verify the data + _data = request.json() + if _data["name"] == "API_TEST": + resp.status_code = 200 + resp.json = json_api_test_project + return resp + elif _data["name"] == "DUPLICATE": + resp.status_code = 409 + resp.json = lambda: dict( + message="Project 'DUPLICATE' already exists", status=409 + ) + return resp + elif request.path_url.endswith(f"/{CPROJECT['id']}/close"): + _returned = json_api_test_project() + _returned.update(status="closed") + resp.status_code = 204 + resp.json = lambda: _returned + return resp + elif request.path_url.endswith(f"/{CPROJECT['id']}/open"): + _returned = json_api_test_project() + _returned.update(status="opened") + resp.status_code = 204 + resp.json = lambda: _returned + return resp + elif request.path_url.endswith(f"/{CPROJECT['id']}/nodes"): + _data = request.json() + if not any(x in _data for x in ("compute_id", "name", "node_id")): + resp.status_code == 400 + resp.json = lambda: dict(message="Invalid request", status=400) + return resp + resp.status_code = 201 + resp.json = json_api_test_node + return resp + elif request.path_url.endswith( + f"/{CPROJECT['id']}/nodes/{CNODE['id']}/start" + ) or request.path_url.endswith(f"/{CPROJECT['id']}/nodes/{CNODE['id']}/reload"): + _returned = json_api_test_node() + _returned.update(status="started") + resp.status_code = 200 + resp.json = lambda: _returned + return resp + elif request.path_url.endswith(f"/{CPROJECT['id']}/nodes/{CNODE['id']}/stop"): + _returned = json_api_test_node() + _returned.update(status="stopped") + resp.status_code = 200 + resp.json = lambda: _returned + return resp + elif request.path_url.endswith( + f"/{CPROJECT['id']}/nodes/{CNODE['id']}/suspend" + ): + _returned = json_api_test_node() + _returned.update(status="suspended") + resp.status_code = 200 + resp.json = lambda: _returned + return resp + elif request.path_url.endswith(f"/{CPROJECT['id']}/links"): + _data = request.json() + nodes = _data.get("nodes") + if len(nodes) != 2: + resp.status_code = 400 + resp.json = lambda: dict(message="Invalid request", status=400) + return resp + elif nodes[0]["node_id"] == nodes[1]["node_id"]: + resp.status_code = 409 + resp.json = lambda: dict(message="Cannot connect to itself", status=409) + return resp + _returned = json_api_test_link() + resp.status_code = 201 + resp.json = lambda: _returned + return resp + elif request.method == "PUT": + if request.path_url.endswith(f"/{CPROJECT['id']}"): + _data = request.json() + _returned = json_api_test_project() + resp.status_code = 200 + resp.json = lambda: {**_returned, **_data} + return resp + return None + + +class Gns3ConnectorMock(Gns3Connector): + def create_session(self): + self.session = requests.Session() + self.adapter = requests_mock.Adapter() + self.session.mount("mock", self.adapter) + self.session.headers["Accept"] = "application/json" + if self.user: + self.session.auth = (self.user, self.cred) + + # Apply responses + self._apply_responses() + + def _apply_responses(self): + # Record the API expected responses + # Version + self.adapter.register_uri( + "GET", f"{self.base_url}/version", json=version_data() + ) + # Templates + self.adapter.register_uri( + "GET", f"{self.base_url}/templates", json=templates_data() + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/templates/{CTEMPLATE['id']}", + json=next( + (_t for _t in templates_data() if _t["template_id"] == CTEMPLATE["id"]) + ), + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/templates/7777-4444-0000", + json={"message": "Template ID 7777-4444-0000 doesn't exist", "status": 404}, + status_code=404, + ) + # Projects + self.adapter.register_uri( + "GET", f"{self.base_url}/projects", json=projects_data() + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}", + json=json_api_test_project(), + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/stats", + json={"drawings": 0, "links": 4, "nodes": 6, "snapshots": 0}, + ) + self.adapter.register_uri( + "POST", + f"{self.base_url}/projects/{CPROJECT['id']}/nodes/start", + status_code=204, + ) + self.adapter.register_uri( + "POST", + f"{self.base_url}/projects/{CPROJECT['id']}/nodes/stop", + status_code=204, + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/7777-4444-0000", + json={"message": "Project ID 7777-4444-0000 doesn't exist", "status": 404}, + status_code=404, + ) + self.adapter.register_uri( + "DELETE", f"{self.base_url}/projects/{CPROJECT['id']}" + ) + self.adapter.add_matcher(post_put_matcher) + # Nodes + self.adapter.register_uri( + "GET", f"{self.base_url}/projects/{CPROJECT['id']}/nodes", json=nodes_data() + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/nodes/{CNODE['id']}", + json=next((_n for _n in nodes_data() if _n["node_id"] == CNODE["id"])), + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/nodes/{CNODE['id']}/links", + json=[ + _link + for _link in links_data() + for _node in _link["nodes"] + if _node["node_id"] == CNODE["id"] + ], + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/nodes/" "7777-4444-0000", + json={"message": "Node ID 7777-4444-0000 doesn't exist", "status": 404}, + status_code=404, + ) + self.adapter.register_uri( + "DELETE", + f"{self.base_url}/projects/{CPROJECT['id']}/nodes/{CNODE['id']}", + status_code=204, + ) + # Links + self.adapter.register_uri( + "GET", f"{self.base_url}/projects/{CPROJECT['id']}/links", json=links_data() + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/links/{CLINK['id']}", + json=next((_l for _l in links_data() if _l["link_id"] == CLINK["id"])), + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/links/" "7777-4444-0000", + json={"message": "Link ID 7777-4444-0000 doesn't exist", "status": 404}, + status_code=404, + ) + self.adapter.register_uri( + "DELETE", + f"{self.base_url}/projects/{CPROJECT['id']}/links/{CLINK['id']}", + status_code=204, + ) + + +@pytest.fixture(scope="class") +def gns3_server(): + return Gns3ConnectorMock(url=BASE_URL) + + +def test_Gns3Connector_wrong_server_url(gns3_server): + # NOTE: Outside of class beacuse it changes the base_url + gns3_server.base_url = "WRONG URL" + with pytest.raises(requests.exceptions.InvalidURL): + gns3_server.get_version() + + +class TestGns3Connector: + def test_get_version(self, gns3_server): + assert dict(local=True, version="2.2.0") == gns3_server.get_version() + + def test_get_templates(self, gns3_server): + # gns3_server = Gns3ConnectorMock(url="mock://gns3server:3080") + response = gns3_server.get_templates() + for index, n in enumerate( + [ + ("IOU-L3", "iou", "router"), + ("IOU-L2", "iou", "switch"), + ("vEOS", "qemu", "router"), + ("alpine", "docker", "guest"), + ("Cloud", "cloud", "guest"), + ("NAT", "nat", "guest"), + ("VPCS", "vpcs", "guest"), + ("Ethernet switch", "ethernet_switch", "switch"), + ("Ethernet hub", "ethernet_hub", "switch"), + ("Frame Relay switch", "frame_relay_switch", "switch"), + ("ATM switch", "atm_switch", "switch"), + ] + ): + assert n[0] == response[index]["name"] + assert n[1] == response[index]["template_type"] + assert n[2] == response[index]["category"] + + def test_get_template_by_name(self, gns3_server): + response = gns3_server.get_template_by_name(name="alpine") + assert "alpine" == response["name"] + assert "docker" == response["template_type"] + assert "guest" == response["category"] + + def test_get_template_by_id(self, gns3_server): + response = gns3_server.get_template_by_id(CTEMPLATE["id"]) + assert "alpine" == response["name"] + assert "docker" == response["template_type"] + assert "guest" == response["category"] + + def test_template_not_found(self, gns3_server): + response = gns3_server.get_template_by_id("7777-4444-0000") + assert "Template ID 7777-4444-0000 doesn't exist" == response["message"] + assert 404 == response["status"] + + def test_get_projects(self, gns3_server): + response = gns3_server.get_projects() + for index, n in enumerate( + [ + ("test2", "test2.gns3", "closed"), + ("API_TEST", "test_api1.gns3", "opened"), + ] + ): + assert n[0] == response[index]["name"] + assert n[1] == response[index]["filename"] + assert n[2] == response[index]["status"] + + def test_get_project_by_name(self, gns3_server): + response = gns3_server.get_project_by_name(name="API_TEST") + assert "API_TEST" == response["name"] + assert "test_api1.gns3" == response["filename"] + assert "opened" == response["status"] + + def test_get_project_by_id(self, gns3_server): + response = gns3_server.get_project_by_id(CPROJECT["id"]) + assert "API_TEST" == response["name"] + assert "test_api1.gns3" == response["filename"] + assert "opened" == response["status"] + + def test_project_not_found(self, gns3_server): + response = gns3_server.get_project_by_id("7777-4444-0000") + assert "Project ID 7777-4444-0000 doesn't exist" == response["message"] + assert 404 == response["status"] + + def test_get_nodes(self, gns3_server): + response = gns3_server.get_nodes(project_id=CPROJECT["id"]) + for index, n in enumerate( + [ + ("Ethernetswitch-1", "ethernet_switch"), + ("IOU1", "iou"), + ("IOU2", "iou"), + ("vEOS", "qemu"), + ("alpine-1", "docker"), + ("Cloud-1", "cloud"), + ] + ): + assert n[0] == response[index]["name"] + assert n[1] == response[index]["node_type"] + + def test_get_node_by_id(self, gns3_server): + response = gns3_server.get_node_by_id( + project_id=CPROJECT["id"], node_id=CNODE["id"] + ) + assert "alpine-1" == response["name"] + assert "docker" == response["node_type"] + assert 5005 == response["console"] + + def test_node_not_found(self, gns3_server): + response = gns3_server.get_node_by_id( + project_id=CPROJECT["id"], node_id="7777-4444-0000" + ) + assert "Node ID 7777-4444-0000 doesn't exist" == response["message"] + assert 404 == response["status"] + + def test_get_links(self, gns3_server): + response = gns3_server.get_links(project_id=CPROJECT["id"]) + assert "ethernet" == response[0]["link_type"] + + def test_get_link_by_id(self, gns3_server): + response = gns3_server.get_link_by_id( + project_id=CPROJECT["id"], link_id=CLINK["id"] + ) + assert "ethernet" == response["link_type"] + assert CPROJECT["id"] == response["project_id"] + assert response["suspend"] is False + + def test_link_not_found(self, gns3_server): + response = gns3_server.get_link_by_id( + project_id=CPROJECT["id"], link_id="7777-4444-0000" + ) + assert "Link ID 7777-4444-0000 doesn't exist" == response["message"] + assert 404 == response["status"] + + def test_create_project(self, gns3_server): + response = gns3_server.create_project(name="API_TEST") + assert "API_TEST" == response["name"] + assert "opened" == response["status"] + + def test_create_duplicate_project(self, gns3_server): + response = gns3_server.create_project(name="DUPLICATE") + assert "Project 'DUPLICATE' already exists" == response["message"] + assert 409 == response["status"] + + def test_delete_project(self, gns3_server): + response = gns3_server.delete_project(project_id=CPROJECT["id"]) + assert response is None + + +@pytest.fixture(scope="class") +def api_test_link(gns3_server): + link = Link(link_id=CLINK["id"], connector=gns3_server, project_id=CPROJECT["id"]) + link.get() + return link + + class TestLink: - def test_instatiation(self, links_data): - for index, link_data in enumerate(links_data): + def test_instatiation(self): + for index, link_data in enumerate(links_data()): assert links.LINKS_REPR[index] == repr(Link(**link_data)) + def test_get(self, api_test_link): + assert api_test_link.link_type == "ethernet" + assert api_test_link.filters == {} + assert api_test_link.capturing is False + assert api_test_link.suspend is False + assert api_test_link.nodes[-1]["node_id"] == CNODE["id"] + assert api_test_link.nodes[-1]["adapter_number"] == 0 + assert api_test_link.nodes[-1]["port_number"] == 0 + + def test_create(self, gns3_server): + _link_data = [ + { + "adapter_number": 2, + "port_number": 0, + "node_id": "8283b923-df0e-4bc1-8199-be6fea40f500", + }, + {"adapter_number": 0, "port_number": 0, "node_id": CNODE["id"]}, + ] + link = Link(connector=gns3_server, project_id=CPROJECT["id"], nodes=_link_data) + link.create() + assert link.link_type == "ethernet" + assert link.filters == {} + assert link.capturing is False + assert link.suspend is False + assert link.nodes[-1]["node_id"] == CNODE["id"] + + def test_create_with_incomplete_node_data(self, gns3_server): + _link_data = [{"adapter_number": 0, "port_number": 0, "node_id": CNODE["id"]}] + link = Link(connector=gns3_server, project_id=CPROJECT["id"], nodes=_link_data) + with pytest.raises(ValueError, match="400"): + link.create() + + def test_create_with_invalid_nodes_id(self, gns3_server): + _link_data = [ + {"adapter_number": 2, "port_number": 0, "node_id": CNODE["id"]}, + {"adapter_number": 0, "port_number": 0, "node_id": CNODE["id"]}, + ] + link = Link(connector=gns3_server, project_id=CPROJECT["id"], nodes=_link_data) + with pytest.raises(ValueError, match="409"): + link.create() + + def test_delete(self, api_test_link): + api_test_link.delete() + assert api_test_link.project_id is None + assert api_test_link.link_id is None + + +@pytest.fixture(scope="class") +def api_test_node(gns3_server): + node = Node(name="alpine-1", connector=gns3_server, project_id=CPROJECT["id"]) + node.get() + return node + class TestNode: - def test_instatiation(self, nodes_data): - for index, node_data in enumerate(nodes_data): + def test_instatiation(self): + for index, node_data in enumerate(nodes_data()): assert nodes.NODES_REPR[index] == repr(Node(**node_data)) + def test_get(self, api_test_node): + assert "alpine-1" == api_test_node.name + assert "started" == api_test_node.status + assert "docker" == api_test_node.node_type + assert "alpine:latest" == api_test_node.properties["image"] + + def test_get_links(self, api_test_node): + api_test_node.get_links() + assert "ethernet" == api_test_node.links[0].link_type + assert 2 == api_test_node.links[0].nodes[0]["adapter_number"] + assert 0 == api_test_node.links[0].nodes[0]["port_number"] + + def test_start(self, api_test_node): + api_test_node.start() + assert "alpine-1" == api_test_node.name + assert "started" == api_test_node.status + + def test_stop(self, api_test_node): + api_test_node.stop() + assert "alpine-1" == api_test_node.name + assert "stopped" == api_test_node.status + + def test_suspend(self, api_test_node): + api_test_node.suspend() + assert "alpine-1" == api_test_node.name + assert "suspended" == api_test_node.status + + def test_reload(self, api_test_node): + api_test_node.reload() + assert "alpine-1" == api_test_node.name + assert "started" == api_test_node.status + + def test_create(self, gns3_server): + node = Node( + name="alpine-1", + node_type="docker", + template=CTEMPLATE["name"], + connector=gns3_server, + project_id=CPROJECT["id"], + ) + node.create() + assert "alpine-1" == node.name + assert "started" == node.status + assert "docker" == node.node_type + assert "alpine:latest" == node.properties["image"] + + def test_create_with_invalid_parameter_type(self, gns3_server): + with pytest.raises(ValidationError): + Node( + name="alpine-1", + node_type="docker", + template=CTEMPLATE["name"], + connector=gns3_server, + project_id=CPROJECT["id"], + compute_id=None, + ) + + def test_create_with_incomplete_parameters(self, gns3_server): + node = Node( + name="alpine-1", + connector=gns3_server, + project_id=CPROJECT["id"], + ) + with pytest.raises(ValueError, match="Need to submit 'node_type'"): + node.create() + + def test_delete(self, api_test_node): + api_test_node.delete() + assert api_test_node.project_id is None + assert api_test_node.node_id is None + assert api_test_node.name is None + + +@pytest.fixture(scope="class") +def api_test_project(gns3_server): + project = Project(name="API_TEST", connector=gns3_server) + project.get() + return project + class TestProject: - def test_instatiation(self, projects_data): - for index, project_data in enumerate(projects_data): + def test_instatiation(self): + for index, project_data in enumerate(projects_data()): assert projects.PROJECTS_REPR[index] == repr(Project(**project_data)) + + def test_create(self, gns3_server): + api_test_project = Project(name="API_TEST", connector=gns3_server) + api_test_project.create() + assert "API_TEST" == api_test_project.name + assert "opened" == api_test_project.status + assert False is api_test_project.auto_close + + def test_delete(self, gns3_server): + api_test_project = Project(name="API_TEST", connector=gns3_server) + api_test_project.create() + resp = api_test_project.delete() + assert resp is None + + def test_get(self, api_test_project): + assert "API_TEST" == api_test_project.name + assert "opened" == api_test_project.status + assert { + "drawings": 0, + "links": 4, + "nodes": 6, + "snapshots": 0, + } == api_test_project.stats + + def test_update(self, api_test_project): + api_test_project.update(filename="file_updated.gns3") + assert "API_TEST" == api_test_project.name + assert "opened" == api_test_project.status + assert "file_updated.gns3" == api_test_project.filename + + def test_open(self, api_test_project): + api_test_project.open() + assert "API_TEST" == api_test_project.name + assert "opened" == api_test_project.status + + def test_close(self, api_test_project): + api_test_project.close() + assert "API_TEST" == api_test_project.name + assert "closed" == api_test_project.status + + def test_get_stats(self, api_test_project): + api_test_project.get_stats() + assert { + "drawings": 0, + "links": 4, + "nodes": 6, + "snapshots": 0, + } == api_test_project.stats + + def test_get_nodes(self, api_test_project): + api_test_project.get_nodes() + for index, n in enumerate( + [ + ("Ethernetswitch-1", "ethernet_switch"), + ("IOU1", "iou"), + ("IOU2", "iou"), + ("vEOS", "qemu"), + ("alpine-1", "docker"), + ("Cloud-1", "cloud"), + ] + ): + assert n[0] == api_test_project.nodes[index].name + assert n[1] == api_test_project.nodes[index].node_type + + def test_get_links(self, api_test_project): + api_test_project.get_links() + assert "ethernet" == api_test_project.links[0].link_type + + # TODO: Need to make a way to dynamically change the status of the nodes to started + # when the inner method `get_nodes` hits again the server REST endpoint + @pytest.mark.skip + def test_start_nodes(self, api_test_project): + api_test_project.start_nodes() + for node in api_test_project.nodes: + assert "started" == node.status + + @pytest.mark.skip + def test_stop_nodes(self, api_test_project): + api_test_project.stop_nodes() + for node in api_test_project.nodes: + assert "stopped" == node.status + + @pytest.mark.skip + def test_reload_nodes(self, api_test_project): + api_test_project.reload_nodes() + for node in api_test_project.nodes: + assert "started" == node.status + + @pytest.mark.skip + def test_suspend_nodes(self, api_test_project): + api_test_project.suspend_nodes() + for node in api_test_project.nodes: + assert "suspended" == node.status + + def test_nodes_summary(self, api_test_project): + nodes_summary = api_test_project.nodes_summary(is_print=False) + assert str(nodes_summary) == ( + "[('Ethernetswitch-1', 'started', '5000', " + "'da28e1c0-9465-4f7c-b42c-49b2f4e1c64d'), ('IOU1', 'started', '5001', " + "'de23a89a-aa1f-446a-a950-31d4bf98653c'), ('IOU2', 'started', '5002', " + "'0d10d697-ef8d-40af-a4f3-fafe71f5458b'), ('vEOS', 'started', '5003', " + "'8283b923-df0e-4bc1-8199-be6fea40f500'), ('alpine-1', 'started', '5005', " + "'ef503c45-e998-499d-88fc-2765614b313e'), ('Cloud-1', 'started', None, " + "'cde85a31-c97f-4551-9596-a3ed12c08498')]" + ) + + def test_links_summary(self, api_test_project): + api_test_project.get_links() + links_summary = api_test_project.links_summary(is_print=False) + assert str(links_summary) == ( + "[('IOU1', 'Ethernet0/0', 'Ethernetswitch-1', 'Ethernet1'), ('IOU1', " + "'Ethernet1/0', 'IOU2', 'Ethernet1/0'), ('vEOS', 'Management1', " + "'Ethernetswitch-1', 'Ethernet0'), ('vEOS', 'Ethernet1', 'alpine-1', " + "'eth0'), ('Cloud-1', 'eth1', 'Ethernetswitch-1', 'Ethernet7')]" + ) + + def test_get_node_by_name(self, api_test_project): + switch = api_test_project.get_node(name="IOU1") + assert "IOU1" == switch.name + assert "started" == switch.status + assert "5001" == switch.console + + def test_get_node_by_id(self, api_test_project): + host = api_test_project.get_node(node_id=CNODE["id"]) + assert "alpine-1" == host.name + assert "started" == host.status + assert "5005" == host.console + + # TODO: `get_link` is dependent on the nodes information of the links + @pytest.mark.skip + def test_get_link_by_id(self, api_test_project): + link = api_test_project.get_link(link_id=CLINK["id"]) + assert "ethernet" == link.link_type diff --git a/tests/test_mock_server.py b/tests/test_mock_server.py new file mode 100644 index 0000000..400b97e --- /dev/null +++ b/tests/test_mock_server.py @@ -0,0 +1,43 @@ +from http.server import BaseHTTPRequestHandler, HTTPServer +import socket +from threading import Thread + +import requests + + +class MockServerRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + # Process an HTTP GET request and return a response with an HTTP 200 status. + self.send_response(requests.codes.ok) + self.end_headers() + return + + +def get_free_port(): + s = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM) + s.bind(("localhost", 0)) + address, port = s.getsockname() + s.close() + return port + + +class TestMockServer: + @classmethod + def setup_class(cls): + # Configure mock server. + cls.mock_server_port = get_free_port() + cls.mock_server = HTTPServer( + ("localhost", cls.mock_server_port), MockServerRequestHandler + ) + + # Start running mock server in a separate thread. + # Daemon threads automatically shut down when the main process exits. + cls.mock_server_thread = Thread(target=cls.mock_server.serve_forever) + cls.mock_server_thread.setDaemon(True) + cls.mock_server_thread.start() + + def test_get_version(self): + version_url = f"http://localhost:{self.mock_server_port}/v2/version" + response = requests.get(version_url) + assert 200 == response.status_code + # assert dict(local=True, version="2.2.0") == response.json() From 081767710c1cc155e993a6d35d3a244745428a6e Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 15:23:43 +0100 Subject: [PATCH 04/26] Updating license --- LICENSE | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 201 insertions(+), 21 deletions(-) diff --git a/LICENSE b/LICENSE index 2732caa..4eefde6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2019 David Flores - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 David Flores + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From ec853b686509311fcb184e7925bc8d1b8a67d12d Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 15:57:44 +0100 Subject: [PATCH 05/26] Adding circlecli configuration --- .circleci/config.yml | 26 ++++++++++++++++++++++++++ setup.cfg | 2 ++ 2 files changed, 28 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 setup.cfg diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..a1b726c --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,26 @@ +version: 2 +jobs: + build-and-test: + docker: + - image: circleci/python:3.7 + steps: + - checkout + - restore_cache: + keys: + - deps-{{ checksum "poetry.lock" }} + - run: + name: Install Dependencies + command: | + poetry install + - save_cache: + key: deps-{{ checksum "poetry.lock" }} + paths: + - /home/circleci/.cache/pypoetry/virtualenvs + - run: + name: Run flake8 + command: | + poetry run flake8 . + - run: + name: Running tests + command: | + poetry run pytest -v --cov=gns3fy tests diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e14b761 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +max-line-length=88 From e3c5d60903b738e3b21ce5480940b475f59beb52 Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 15:59:56 +0100 Subject: [PATCH 06/26] tuning circleci --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a1b726c..c82b036 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,6 +1,6 @@ version: 2 jobs: - build-and-test: + build: docker: - image: circleci/python:3.7 steps: From 0bbb78f74901f35a3200d9620400b97098e46d75 Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 19:27:34 +0100 Subject: [PATCH 07/26] Adding some badges --- .circleci/config.yml | 8 ++++++-- README.md | 2 ++ gns3fy/api.py | 42 ++++++++++++++++---------------------- tests/test_mock_server.py | 43 --------------------------------------- 4 files changed, 25 insertions(+), 70 deletions(-) delete mode 100644 tests/test_mock_server.py diff --git a/.circleci/config.yml b/.circleci/config.yml index c82b036..5066044 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,4 +1,6 @@ -version: 2 +version: 2.1 +orbs: + codecov: codecov/codecov@1.0.2 jobs: build: docker: @@ -23,4 +25,6 @@ jobs: - run: name: Running tests command: | - poetry run pytest -v --cov=gns3fy tests + poetry run pytest -v --cov=gns3fy --cov-report=html tests + - codecov/upload: + path: htmlcov diff --git a/README.md b/README.md index c9a0455..b4b5679 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Circle CI](https://circleci.com/gh/davidban77/gns3fy/tree/develop.svg?style=shield&circle-token=:circle-token)](https://circleci.com/gh/davidban77/gns3fy/tree/develop)[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) + # gns3fy Python wrapper around [GNS3 Server API](http://api.gns3.net/en/2.2/index.html). diff --git a/gns3fy/api.py b/gns3fy/api.py index 98161ba..d530e12 100644 --- a/gns3fy/api.py +++ b/gns3fy/api.py @@ -1006,31 +1006,23 @@ def nodes_summary(self, is_print=True): return _nodes_summary if not is_print else None def nodes_inventory(self, is_print=True): - """return an ansible-type inventory with the nodes of the project for initial Telnet - configuration - - e.g - { "spine00.cmh": { - "hostname": "127.0.0.1", - "username": "vagrant", - "password": "vagrant", - "port": 12444, - "platform": "eos", - "groups": [ - "cmh" - ], - }, - "spine01.cmh": { - "hostname": "127.0.0.1", - "username": "vagrant" - "password": "", - "platform": "junos", - "port": 12204, - "groups": [ - "cmh" - ] - } - } + """ + Returns an ansible-type inventory with the nodes of the project + + Example: + + { + "router01": { + "hostname": "127.0.0.1", + "username": "vagrant", + "password": "vagrant", + "port": 12444, + "platform": "eos", + "groups": [ + "border" + ], + } + } """ if not self.nodes: diff --git a/tests/test_mock_server.py b/tests/test_mock_server.py deleted file mode 100644 index 400b97e..0000000 --- a/tests/test_mock_server.py +++ /dev/null @@ -1,43 +0,0 @@ -from http.server import BaseHTTPRequestHandler, HTTPServer -import socket -from threading import Thread - -import requests - - -class MockServerRequestHandler(BaseHTTPRequestHandler): - def do_GET(self): - # Process an HTTP GET request and return a response with an HTTP 200 status. - self.send_response(requests.codes.ok) - self.end_headers() - return - - -def get_free_port(): - s = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM) - s.bind(("localhost", 0)) - address, port = s.getsockname() - s.close() - return port - - -class TestMockServer: - @classmethod - def setup_class(cls): - # Configure mock server. - cls.mock_server_port = get_free_port() - cls.mock_server = HTTPServer( - ("localhost", cls.mock_server_port), MockServerRequestHandler - ) - - # Start running mock server in a separate thread. - # Daemon threads automatically shut down when the main process exits. - cls.mock_server_thread = Thread(target=cls.mock_server.serve_forever) - cls.mock_server_thread.setDaemon(True) - cls.mock_server_thread.start() - - def test_get_version(self): - version_url = f"http://localhost:{self.mock_server_port}/v2/version" - response = requests.get(version_url) - assert 200 == response.status_code - # assert dict(local=True, version="2.2.0") == response.json() From 63a8ffe0d4c0f7157a0a832ef90851bed1bcc015 Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 19:45:32 +0100 Subject: [PATCH 08/26] Fixing some stuff --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b4b5679..d51fc48 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Circle CI](https://circleci.com/gh/davidban77/gns3fy/tree/develop.svg?style=shield&circle-token=:circle-token)](https://circleci.com/gh/davidban77/gns3fy/tree/develop)[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) +[![Circle CI](https://circleci.com/gh/davidban77/gns3fy/tree/develop.svg?style=shield&circle-token=:circle-token)](https://circleci.com/gh/davidban77/gns3fy/tree/develop)[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black)[![codecov](https://img.shields.io/codecov/c/github/davidban77/gns3fy)](https://codecov.io/gh/davidban77/gns3fy) # gns3fy Python wrapper around [GNS3 Server API](http://api.gns3.net/en/2.2/index.html). From 093c62e3df63cd032682e81af3dd38c4fe0643ca Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 19:46:17 +0100 Subject: [PATCH 09/26] Fixing some stuff --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5066044..7834aa3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,4 +27,4 @@ jobs: command: | poetry run pytest -v --cov=gns3fy --cov-report=html tests - codecov/upload: - path: htmlcov + file: htmlcov From 2b498b4211d16d69d5c0967a0218a862ddee4f98 Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 20:09:10 +0100 Subject: [PATCH 10/26] Testing coverage --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7834aa3..4ef9e9e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,6 +1,6 @@ version: 2.1 orbs: - codecov: codecov/codecov@1.0.2 + codecov: codecov/codecov@1.0.5 jobs: build: docker: @@ -25,6 +25,6 @@ jobs: - run: name: Running tests command: | - poetry run pytest -v --cov=gns3fy --cov-report=html tests + poetry run pytest --cov=gns3fy tests/ - codecov/upload: - file: htmlcov + file: .coverage From 8379cb5abd79ac6a0a7fdf1b9093a7ddc9539351 Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 20:15:31 +0100 Subject: [PATCH 11/26] Testing coverage --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4ef9e9e..cc32b4c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,6 +25,6 @@ jobs: - run: name: Running tests command: | - poetry run pytest --cov=gns3fy tests/ + poetry run pytest --cov=./ - codecov/upload: file: .coverage From adf4932fe8b5f5b93808efb40f65c503adc19e05 Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 20:19:27 +0100 Subject: [PATCH 12/26] Testing coverage --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cc32b4c..f1d3585 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,6 +25,6 @@ jobs: - run: name: Running tests command: | - poetry run pytest --cov=./ + poetry run pytest --cov=gns3fy tests/ - codecov/upload: - file: .coverage + file: From 5360f0f20547add57957022c1de5bf2d89118b1d Mon Sep 17 00:00:00 2001 From: David Flores Date: Wed, 14 Aug 2019 20:23:59 +0100 Subject: [PATCH 13/26] Testing coverage --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f1d3585..864ba9e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,6 +25,6 @@ jobs: - run: name: Running tests command: | - poetry run pytest --cov=gns3fy tests/ + poetry run pytest --cov-report=xml --cov=gns3fy tests/ - codecov/upload: - file: + file: coverage.xml From fd0461cc8d856401922a215877d72c7a06540514 Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 17 Aug 2019 15:18:47 +0100 Subject: [PATCH 14/26] Pushing docs --- CHANGELOG.md | 10 - docs/content/about/changelog.md | 20 + LICENSE => docs/content/about/license.md | 0 docs/content/api_reference.md | 793 ++++++++++++++ docs/content/img/create_lab_example.png | Bin 0 -> 101883 bytes docs/content/index.md | 57 + docs/content/user-guide.md | 422 ++++++++ docs/mkdocs.yml | 15 + docs/pydoc-markdown.yml | 16 + gns3fy/__init__.py | 2 +- gns3fy/api.py | 1223 ---------------------- poetry.lock | 152 ++- pyproject.toml | 3 + tests/data/projects.py | 4 +- 14 files changed, 1472 insertions(+), 1245 deletions(-) delete mode 100644 CHANGELOG.md create mode 100644 docs/content/about/changelog.md rename LICENSE => docs/content/about/license.md (100%) create mode 100644 docs/content/api_reference.md create mode 100644 docs/content/img/create_lab_example.png create mode 100644 docs/content/index.md create mode 100644 docs/content/user-guide.md create mode 100644 docs/mkdocs.yml create mode 100644 docs/pydoc-markdown.yml delete mode 100644 gns3fy/api.py diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index c702102..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,10 +0,0 @@ -# gns3fy Change History - -## 0.1.1 - -**Enhancement** -- Adding `Gns3Connector` method `get_version` - -## 0.1.0 - -- Initial Push diff --git a/docs/content/about/changelog.md b/docs/content/about/changelog.md new file mode 100644 index 0000000..70a1376 --- /dev/null +++ b/docs/content/about/changelog.md @@ -0,0 +1,20 @@ +# Releases + +## 0.2.0 + +**New features:** + +- Ability to create `Project`, `Node` and `Link` instances +- Created most of the methods to interact with the REST API Endpoints. +- Added some commodity methods like `nodes_summary` +- Created the `docs` +- Improved the tests and coverage + +## 0.1.1 + +**Enhancement** +- Adding `Gns3Connector` method `get_version` + +## 0.1.0 + +- Initial Push diff --git a/LICENSE b/docs/content/about/license.md similarity index 100% rename from LICENSE rename to docs/content/about/license.md diff --git a/docs/content/api_reference.md b/docs/content/api_reference.md new file mode 100644 index 0000000..91c30d0 --- /dev/null +++ b/docs/content/api_reference.md @@ -0,0 +1,793 @@ +## `Gns3Connector` Objects + +```python +def __init__(self, url=None, user=None, cred=None, verify=False, api_version=2) +``` + +Connector to be use for interaction against GNS3 server controller API. + +**Attributes:** + +- `url` (str): URL of the GNS3 server (**required**) +- `user` (str): User used for authentication +- `cred` (str): Password used for authentication +- `verify` (bool): Whether or not to verify SSL +- `api_version` (int): GNS3 server REST API version +- `api_calls`: Counter of amount of `http_calls` has been performed +- `base_url`: url passed + api_version +- `session`: Requests Session object + +**Returns:** + +`Gns3Connector` instance + +**Example:** + +```python +>>> server = Gns3Connector(url="http://
:3080") +>>> print(server.get_version()) +{'local': False, 'version': '2.2.0b4'} +``` + +### `Gns3Connector.__init__()` + +```python +def __init__(self, url=None, user=None, cred=None, verify=False, api_version=2) +``` + + +### `Gns3Connector.create_session()` + +```python +def create_session(self) +``` + +Creates the requests.Session object and applies the necessary parameters + +### `Gns3Connector.http_call()` + +```python +def http_call(self, method, url, data=None, json_data=None, headers=None, verify=False, params=None) +``` + +Performs the HTTP operation actioned + +**Required Attributes:** + +- `method` (enum): HTTP method to perform: get, post, put, delete, head, +patch (**required**) +- `url` (str): URL target (**required**) +- `data`: Dictionary or byte of request body data to attach to the Request +- `json_data`: Dictionary or List of dicts to be passed as JSON object/array +- `headers`: ictionary of HTTP Headers to attach to the Request +- `verify`: SSL Verification +- `params`: Dictionary or bytes to be sent in the query string for the Request + +### `Gns3Connector.error_checker()` + +```python +@staticmethod +def error_checker(response_obj) +``` + +Returns the error if found + +### `Gns3Connector.get_version()` + +```python +def get_version(self) +``` + +Returns the version information of GNS3 server + +### `Gns3Connector.get_projects()` + +```python +def get_projects(self) +``` + +Returns the list of the projects on the server + +### `Gns3Connector.get_project_by_name()` + +```python +def get_project_by_name(self, name) +``` + +Retrives a specific project + +### `Gns3Connector.get_project_by_id()` + +```python +def get_project_by_id(self, id) +``` + +Retrives a specific project by id + +### `Gns3Connector.get_templates()` + +```python +def get_templates(self) +``` + +Returns the templates defined on the server + +### `Gns3Connector.get_template_by_name()` + +```python +def get_template_by_name(self, name) +``` + +Retrives a specific template searching by name + +### `Gns3Connector.get_template_by_id()` + +```python +def get_template_by_id(self, id) +``` + +Retrives a specific template by id + +### `Gns3Connector.get_nodes()` + +```python +def get_nodes(self, project_id) +``` + +Retieves the nodes defined on the project + +### `Gns3Connector.get_node_by_id()` + +```python +def get_node_by_id(self, project_id, node_id) +``` + +Returns the node by locating its ID + +### `Gns3Connector.get_links()` + +```python +def get_links(self, project_id) +``` + +Retrieves the links defined in the project + +### `Gns3Connector.get_link_by_id()` + +```python +def get_link_by_id(self, project_id, link_id) +``` + +Returns the link by locating its ID + +### `Gns3Connector.create_project()` + +```python +def create_project(self, kwargs) +``` + +Pass a dictionary type object with the project parameters to be created. +Parameter `name` is mandatory. Returns project + +### `Gns3Connector.delete_project()` + +```python +def delete_project(self, project_id) +``` + +Deletes a project from server + +## `Link` Objects + +GNS3 Link API object. For more information visit: [Links Endpoint API information]( +http://api.gns3.net/en/2.2/api/v2/controller/link/projectsprojectidlinks.html) + +**Attributes:** + +- `link_id` (str): Link UUID (**required** to be set when using `get` method) +- `link_type` (enum): Possible values: ethernet, serial +- `project_id` (str): Project UUID (**required**) +- `connector` (object): `Gns3Connector` instance used for interaction (**required**) +- `suspend` (bool): Suspend the link +- `nodes` (list): List of the Nodes and ports (**required** when using `create` +method, see Features/Link creation on the docs) +- `filters` (dict): Packet filter. This allow to simulate latency and errors +- `capturing` (bool): Read only property. True if a capture running on the link +- `capture_file_path` (str): Read only property. The full path of the capture file +if capture is running +- `capture_file_name` (str): Read only property. The name of the capture file if +capture is running + +**Returns:** + +`Link` instance + +**Example:** + +```python +>>> link = Link(project_id=, link_id= connector=) +>>> link.get() +>>> print(link.link_type) +'ethernet' +``` + +### `Link.get()` + +```python +def get(self) +``` + +Retrieves the information from the link endpoint. + +**Required Attributes:** + +- `project_id` +- `connector` +- `link_id` + +### `Link.delete()` + +```python +def delete(self) +``` + +Deletes a link endpoint from the project. It sets to `None` the attributes +`link_id` when executed sucessfully + +**Required Attributes:** + +- `project_id` +- `connector` +- `link_id` + +### `Link.create()` + +```python +def create(self) +``` + +Creates a link endpoint + +**Required Attributes:** + +- `project_id` +- `connector` +- `nodes` + +## `Node` Objects + +GNS3 Node API object. For more information visit: [Node Endpoint API information]( +http://api.gns3.net/en/2.2/api/v2/controller/node/projectsprojectidnodes.html) + +**Attributes:** + +- `name` (str): Node name (**required** when using `create` method) +- `project_id` (str): Project UUID (**required**) +- `node_id` (str): Node UUID (**required** when using `get` method) +- `compute_id` (str): Compute identifier (**required**, default=local) +- `node_type` (enum): frame_relay_switch, atm_switch, docker, dynamips, vpcs, +traceng, virtualbox, vmware, iou, qemu (**required** when using `create` method) +- `connector` (object): `Gns3Connector` instance used for interaction (**required**) +- `template_id`: Template UUID from the which the node is from. +- `template`: Template name from the which the node is from. +- `node_directory` (str): Working directory of the node. Read only +- `status` (enum): Possible values: stopped, started, suspended +- `ports` (list): List of node ports, READ only +- `port_name_format` (str): Formating for port name {0} will be replace by port +number +- `port_segment_size` (int): Size of the port segment +- `first_port_name` (str): Name of the first port +- `properties` (dict): Properties specific to an emulator +- `locked` (bool): Whether the element locked or not +- `label` (dict): TBC +- `console` (int): Console TCP port +- `console_host` (str): Console host +- `console_auto_start` (bool): Automatically start the console when the node has +started +- `command_line` (str): Command line use to start the node +- `custom_adapters` (list): TBC +- `height` (int): Height of the node, READ only +- `width` (int): Width of the node, READ only +- `symbol` (str): Symbol of the node +- `x` (int): X position of the node +- `y` (int): Y position of the node +- `z (int): Z position of the node + +**Returns:** + +`Node` instance + +**Example:** + +```python +>>> alpine = Node(name="alpine1", node_type="docker", template="alpine", +project_id=, connector=) +>>> alpine.create() +>>> print(alpine.node_id) +'SOME-UUID-GENERATED' +``` + +### `Node.get()` + +```python +def get(self, get_links=True) +``` + +Retrieves the node information. When `get_links` is `True` it also retrieves the +links respective to the node. + +**Required Attributes:** + +- `project_id` +- `connector` +- `node_id` + +### `Node.get_links()` + +```python +def get_links(self) +``` + +Retrieves the links of the respective node. They will be saved at the `links` +attribute + +**Required Attributes:** + +- `project_id` +- `connector` +- `node_id` + +### `Node.start()` + +```python +def start(self) +``` + +Starts the node. + +**Required Attributes:** + +- `project_id` +- `connector` +- `node_id` + +### `Node.stop()` + +```python +def stop(self) +``` + +Stops the node. + +**Required Attributes:** + +- `project_id` +- `connector` +- `node_id` + +### `Node.reload()` + +```python +def reload(self) +``` + +Reloads the node. + +**Required Attributes:** + +- `project_id` +- `connector` +- `node_id` + +### `Node.suspend()` + +```python +def suspend(self) +``` + +Suspends the node. + +**Required Attributes:** + +- `project_id` +- `connector` +- `node_id` + +### `Node.create()` + +```python +def create(self, extra_properties={}) +``` + +Creates a node. + +By default it will fetch the nodes properties for creation based on the +`template` or `template_id` attribute supplied. This can be overriden/updated +by sending a dictionary of the properties under `extra_properties`. + +**Required Attributes:** + +- `project_id` +- `connector` +- `compute_id`: Defaults to "local" +- `name` +- `node_type` +- `template` or `template_id` + +### `Node.delete()` + +```python +def delete(self) +``` + +Deletes the node from the project. It sets to `None` the attributes `node_id` +and `name` when executed successfully + +**Required Attributes:** + +- `project_id` +- `connector` +- `node_id` + +## `Project` Objects + +GNS3 Project API object. For more information visit: [Project Endpoint API +information](http://api.gns3.net/en/2.2/api/v2/controller/project/projects.html) + +**Attributes:** + +- `name`: Project name (**required** when using `create` method) +- `project_id` (str): Project UUID (**required**) +- `connector` (object): `Gns3Connector` instance used for interaction (**required**) +- `status` (enum): Possible values: opened, closed +- `path` (str): Path of the project on the server +- `filename` (str): Project filename +- `auto_start` (bool): Project start when opened +- `auto_close` (bool): Project auto close when client cut off the notifications feed +- `auto_open` (bool): Project open when GNS3 start +- `drawing_grid_size` (int): Grid size for the drawing area for drawings +- `grid_size` (int): Grid size for the drawing area for nodes +- `scene_height` (int): Height of the drawing area +- `scene_width` (int): Width of the drawing area +- `show_grid` (bool): Show the grid on the drawing area +- `show_interface_labels` (bool): Show interface labels on the drawing area +- `show_layers` (bool): Show layers on the drawing area +- `snap_to_grid` (bool): Snap to grid on the drawing area +- `supplier` (dict): Supplier of the project +- `variables` (list): Variables required to run the project +- `zoom` (int): Zoom of the drawing area +- `stats` (dict): Project stats +- `nodes` (list): List of `Node` instances present on the project +- `links` (list): List of `Link` instances present on the project + +**Returns:** + +`Project` instance + +**Example:** + +```python +>>> lab = Project(name="lab", connector=) +>>> lab.create() +>>> print(lab.status) +'opened' +``` + +### `Project.get()` + +```python +def get(self, get_links=True, get_nodes=True, get_stats=True) +``` + +Retrieves the projects information. + +- `get_links`: When true it also queries for the links inside the project +- `get_nodes`: When true it also queries for the nodes inside the project +- `get_stats`: When true it also queries for the stats inside the project + +**Required Attributes:** + +- `connector` +- `project_id` or `name` + +### `Project.create()` + +```python +def create(self) +``` + +Creates the project. + +**Required Attributes:** + +- `name` +- `connector` + +### `Project.update()` + +```python +def update(self, kwargs) +``` + +Updates the project instance by passing the keyword arguments of the attributes +you want to be updated + +Example: `lab.update(auto_close=True)` + +This will update the project `auto_close` attribute to `True` + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.delete()` + +```python +def delete(self) +``` + +Deletes the project from the server. It sets to `None` the attributes +`project_id` and `name` when executed successfully + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.close()` + +```python +def close(self) +``` + +Closes the project on the server. + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.open()` + +```python +def open(self) +``` + +Opens the project on the server. + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.get_stats()` + +```python +def get_stats(self) +``` + +Retrieve the stats of the project. + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.get_nodes()` + +```python +def get_nodes(self) +``` + +Retrieve the nodes of the project. + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.get_links()` + +```python +def get_links(self) +``` + +Retrieve the links of the project. + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.start_nodes()` + +```python +def start_nodes(self, poll_wait_time=5) +``` + +Starts all the nodes inside the project. + +- `poll_wait_time` is used as a delay when performing the next query of the +nodes status. + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.stop_nodes()` + +```python +def stop_nodes(self, poll_wait_time=5) +``` + +Stops all the nodes inside the project. + +- `poll_wait_time` is used as a delay when performing the next query of the +nodes status. + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.reload_nodes()` + +```python +def reload_nodes(self, poll_wait_time=5) +``` + +Reloads all the nodes inside the project. + +- `poll_wait_time` is used as a delay when performing the next query of the +nodes status. + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.suspend_nodes()` + +```python +def suspend_nodes(self, poll_wait_time=5) +``` + +Suspends all the nodes inside the project. + +- `poll_wait_time` is used as a delay when performing the next query of the +nodes status. + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.nodes_summary()` + +```python +def nodes_summary(self, is_print=True) +``` + +Returns a summary of the nodes insode the project. If `is_print` is `False`, it +will return a list of tuples like: + +`[(node_name, node_status, node_console, node_id) ...]` + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.nodes_inventory()` + +```python +def nodes_inventory(self) +``` + +Returns an inventory-style with the nodes of the project + +Example: + +`{ + "router01": { + "hostname": "127.0.0.1", + "name": "router01", + "console_port": 5077, + "type": "vEOS" + } +}` + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.links_summary()` + +```python +def links_summary(self, is_print=True) +``` + +Returns a summary of the links insode the project. If `is_print` is False, it +will return a list of tuples like: + +`[(node_a, port_a, node_b, port_b) ...]` + +**Required Attributes:** + +- `project_id` +- `connector` + +### `Project.get_node()` + +```python +def get_node(self, name=None, node_id=None) +``` + +Returns the Node object by searching for the `name` or the `node_id`. + +**Required Attributes:** + +- `project_id` +- `connector` +- `name` or `node_id` + +**NOTE:** Run method `get_nodes()` manually to refresh list of nodes if +necessary + +### `Project.get_link()` + +```python +def get_link(self, link_id) +``` + +Returns the Link object by locating its ID + +**Required Attributes:** + +- `project_id` +- `connector` +- `link_id` + +**NOTE:** Run method `get_links()` manually to refresh list of links if +necessary + +### `Project.create_node()` + +```python +def create_node(self, name=None, kwargs) +``` + +Creates a node. + +**Required Attributes:** + +- `project_id` +- `connector` +- `name` +- `node_type` +- `compute_id`: Defaults to "local" +- `template` or `template_id` + +### `Project.create_link()` + +```python +def create_link(self, node_a, port_a, node_b, port_b) +``` + +Creates a link. + +**Required Attributes:** + +- `project_id` +- `connector` +- `node_a`: Node name of the A side +- `port_a`: Port name of the A side (must match the `name` attribute of the +port) +- `node_b`: Node name of the B side +- `port_b`: Port name of the B side (must match the `name` attribute of the +port) + diff --git a/docs/content/img/create_lab_example.png b/docs/content/img/create_lab_example.png new file mode 100644 index 0000000000000000000000000000000000000000..532f264630eeb83aa951a6e4db0c4d090dcca022 GIT binary patch literal 101883 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 literal 0 HcmV?d00001 diff --git a/docs/content/index.md b/docs/content/index.md new file mode 100644 index 0000000..1fe4d31 --- /dev/null +++ b/docs/content/index.md @@ -0,0 +1,57 @@ +# Welcome to gns3fy Docs! + +gns3fy is a Python wrapper around [GNS3 Server API](http://api.gns3.net/en/2.2/index.html). + +Its main objective is to interact with the GNS3 server in a programatic way, so it can be integrated with the likes of Ansible, docker and scripts. + +## Install + +``` +pip install gns3fy +``` + +### Development version + +Use [poetry](https://github.com/sdispater/poetry) to install the package when cloning it. + + +## Quick Start + +``` +import gns3fy + +# Define the server object to establish the connection +gns3_server = gns3fy.Gns3Connector("http://:3080") + +# Define the lab you want to load and assign the server connector +lab = gns3fy.Project(name="API_TEST", connector=gns3_server) + +# Retrieve its information and display +lab.get() + +print(lab) +# Project(project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe', name='API_TEST', status='opened', ... + +# Access the project attributes +print(f"Name: {lab.name} -- Status: {lab.status} -- Is auto_closed?: {lab.auto_close}") +# Name: API_TEST -- Status: closed -- Is auto_closed?: False + +# Open the project +lab.open() +print(lab.status) +# opened + +# Verify the stats +print(lab.stats) +# {'drawings': 0, 'links': 4, 'nodes': 6, 'snapshots': 0} + +# List the names and status of all the nodes in the project +for node in lab.nodes: + print(f"Node: {node.name} -- Node Type: {node.node_type} -- Status: {node.status}") +# Node: Ethernetswitch-1 -- Node Type: ethernet_switch -- Status: started +# ... +``` + +## Release Notes + +Please see the [Release Notes](changelog.md) for details diff --git a/docs/content/user-guide.md b/docs/content/user-guide.md new file mode 100644 index 0000000..a353051 --- /dev/null +++ b/docs/content/user-guide.md @@ -0,0 +1,422 @@ +# How it works + +The library provide the following interfaces: + +- `Gns3Connector`: Main object that interacts with the GNS3 server REST API and its objective is to be its interface. +- `Project`: Interface of a project/lab defined. +- `Node`: Interface of a node/entity defined. +- `Link`: Interface of a link defined. + +The `Gns3Connector` is mandatory and needs to be assigned to the `Project`, `Node` or `Link` object you want to work with, the latter uses the connector to interact with the REST API. + +Next you can see different ways to interact with the library. + +## Interact with existing Project + +### Gns3Connector and Project objects + +Here is an example of defining a connector object and a project that is already configured on a local GNS3 server: + +```python +from gns3fy import Gns3Connector, Project + +server = Gns3Connector("http://localhost:3080") + +lab = Project(name="API_TEST", connector=server) + +# Retrieve its information and display +lab.get() + +print(lab) +# Project(project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe', name='API_TEST', status='opened', ... + +# Access the project attributes +print(f"Name: {lab.name} -- Status: {lab.status} -- Is auto_closed?: {lab.auto_close}") +# Name: API_TEST -- Status: closed -- Is auto_closed?: False + +# Open the project +lab.open() +print(lab.status) +# opened + +# Verify the stats +print(lab.stats) +# {'drawings': 0, 'links': 4, 'nodes': 6, 'snapshots': 0} + +# List the names and status of all the nodes in the project +for node in lab.nodes: + print(f"Node: {node.name} -- Node Type: {node.node_type} -- Status: {node.status}") +# Node: Ethernetswitch-1 -- Node Type: ethernet_switch -- Status: started +# ... +``` + +As noted before you can also use the `Gns3Connector` as an interface object to the GNS3 server REST API. + +```python +In [1]: from gns3fy import Gns3Connector, Project, Node, Link + +In [2]: server = Gns3Connector(url="http://localhost:3080") + +In [3]: server.get_version() +Out[3]: {'local': False, 'version': '2.2.0b4'} + +In [4]: server.get_templates() +Out[4]: +[{'adapter_type': 'e1000', + 'adapters': 13, + 'bios_image': '', + 'boot_priority': 'c', + 'builtin': False, + 'category': 'router', + 'cdrom_image': '', + 'compute_id': 'local', + ... +``` +### Node and Link objects + +You have access to the `Node` and `Link` objects as well, and this gives you the ability to start, stop, suspend the individual element in a GNS3 project. + +```python +from gns3fy import Node, Link, Gns3Connector + +PROJECT_ID = "" +server = Gns3Connector("http://:3080") + +alpine1 = Node(project_id=PROJECT_ID, name="alpine-1", connector=server) + +alpine1.get() +print(alpine1) +# Node(name='alpine-1', node_type='docker', node_directory= ... + +# And you can access the attributes the same way as the project +print(f"Name: {alpine1.name} -- Status: {alpine1.status} -- Console: {alpine1.console}") +# Name: alpine-1 -- Status: started -- Console: 5005 + +# Stop the node and start (you can just restart it as well) +alpine1.stop() +print(alpine1.status) +# stopped + +alpine1.start() +print(alpine1.status) +# started + +# You can also see the Link objects assigned to this node +print(alpine1.links) +# [Link(link_id='4d9f1235-7fd1-466b-ad26-0b4b08beb778', link_type='ethernet', .... + +# And in the same way you can interact with a Link object +link1 = alpine1.links[0] +print(f"Link Type: {link1.link_type} -- Capturing?: {link1.capturing} -- Endpoints: {link1.nodes}") +# Link Type: ethernet -- Capturing?: False -- Endpoints: [{'adapter_number': 2, ... +``` + +## Creating a new Project + +You can find here a couple of methods that are available on the interfaces provided by the library. + +To navigate to some of them and see their value, lets create a simple lab on the server with 2 nodes connected betweem each other. + +### Project creation + +Lets start by creating a lab called `test_lab` + +```python +In [1]: from gns3fy import Gns3Connector, Project, Node, Link + +In [2]: server = Gns3Connector(url="http://localhost:3080") + +In [3]: lab = Project(name="test_lab", connector=server) + +In [4]: lab.create() + +In [5]: + +In [5]: lab +Out[5]: Project(project_id='e83f1275-3a6f-48f7-88ee-36386ee27a55', name='test_lab', status='opened',... +``` + +You can see you get the `project_id`. In GNS3 the project ID is key for all interactions under that project. + +!!! Note + For a complete list of the attibutes you can see the [API Reference](api_reference.md#project-objects) + +### Node creation + +Next, lets try and create a Ethernet switch node. For this we need to know the template and node type of it. + +```python hl_lines="6" +In [7]: for template in server.get_templates(): + ...: if "switch" in template["name"]: + ...: print(f"Template: {template['name']} -- ID: {template['template_id']}") + ...: + ...: +Template: Ethernet switch -- ID: 1966b864-93e7-32d5-965f-001384eec461 +Template: Frame Relay switch -- ID: dd0f6f3a-ba58-3249-81cb-a1dd88407a47 +Template: ATM switch -- ID: aaa764e2-b383-300f-8a0e-3493bbfdb7d2 + +In [8]: server.get_template_by_name("Ethernet switch") +Out[8]: +{'builtin': True, + 'category': 'switch', + 'console_type': 'none', + 'name': 'Ethernet switch', + 'symbol': ':/symbols/ethernet_switch.svg', + 'template_id': '1966b864-93e7-32d5-965f-001384eec461', + 'template_type': 'ethernet_switch'} +``` + +By knowing the template information of the device we can create the Node instace of it + +```python + +In [9]: switch = Node( + project_id=lab.project_id, + connector=server, + name="Ethernet-switch", + node_type="ethernet_switch", + template="Ethernet switch" +) + +In [10]: switch.create() + +In [11]: switch +Out[11]: Node(name='Ethernet-switch', project_id='6e75bca5-3fa0-4219-a7cf-f82c0540fb73', node_id='c3607609-49... +``` + +!!! Note + For a complete list of the attibutes you can see the [API Reference](api_reference.md#node-objects) + +Now lets add an docker Alpine host to the project (**NOTE:** The docker image and template needs to be already configured in GNS3) + +```python +In [12]: alpine = Node( + project_id=lab.project_id, + connector=server, + name="alpine-host", + node_type="docker", + template="alpine" +) + +In [13]: alpine.create() + +In [14]: alpine +Out[14]: Node(name='alpine-host', project_id='6e75bca5-3fa0-4219-a7cf-f82c0540fb73', node_id='8c11eb8b... + +In [15]: alpine.properties +Out[15]: +{'adapters': 2, + 'aux': 5026, + 'category': 'guest', + 'console_auto_start': False, + 'console_http_path': '/', + 'console_http_port': 80, + 'console_resolution': '1024x768', + 'console_type': 'telnet', + 'container_id': 'f26b6aee763a9399c93c86032b75717c57b260e5010e88c4d410ce13554771df', + 'custom_adapters': [], + 'environment': '', + 'extra_hosts': '', + 'extra_volumes': [], + 'image': 'alpine:latest', + 'start_command': '', + 'symbol': ':/symbols/affinity/circle/gray/docker.svg', + 'usage': ''} + +In [16]: alpine.ports +Out[16]: +[{'adapter_number': 0, + 'data_link_types': {'Ethernet': 'DLT_EN10MB'}, + 'link_type': 'ethernet', + 'name': 'eth0', + 'port_number': 0, + 'short_name': 'eth0'}, + {'adapter_number': 1, + 'data_link_types': {'Ethernet': 'DLT_EN10MB'}, + 'link_type': 'ethernet', + 'name': 'eth1', + 'port_number': 0, + 'short_name': 'eth1'}] +``` + +You can access all of the host attributes and see their specifications based on the template defined on the server. + +To update the `lab` object with their latest nodes added + +```python +In [17]: lab.get() + +# I have shorten the output shown +In [18]: lab.nodes +Out[18]: +[Node(name='Ethernet-switch', project_id='6e75bca5-3fa0-4219-a7cf-f82c0540fb73', node_id='c3607609... +Node(name='alpine-host1', project_id='6e75bca5-3fa0-4219-a7cf-f82c0540fb73', node_id='8c11eb8b... +] +``` + +### Link creation + +Next lets create a link between the switch and the alpine host. + +`Switch Etherner0 <--> Alpine Eth1` + +```python + +In [15]: lab.create_link('Ethernet-switch', 'Ethernet0', 'alpine-host1', 'eth1') +Created Link-ID: b0d0df11-8ed8-4d1d-98e4-3776c9b7bdce -- Type: ethernet + +In [16]: lab.links +Out[16]: [Link(link_id='b0d0df11-8ed8-4d1d-98e4-3776c9b7bdce', link_type='ethernet', project_id='6e7 +``` +!!! Note + For a complete list of the attibutes you can see the [API Reference](api_reference.md#link-objects) + +This is one way to create a link (using the `lab` object), but you can also create it using a `Link` instance as well. + +We need the link mapping to be set under the `nodes` attribute of the `Link` instance. For this we need: + +- `node_id` +- `adapter_number` +- `port_number` + +```python hl_lines="9 13 19 23" + +In [17]: switch.ports +Out[17]: +[{'adapter_number': 0, + 'data_link_types': {'Ethernet': 'DLT_EN10MB'}, + 'link_type': 'ethernet', + 'name': 'Ethernet0', + 'port_number': 0, + 'short_name': 'e0'}, + {'adapter_number': 0, + 'data_link_types': {'Ethernet': 'DLT_EN10MB'}, + 'link_type': 'ethernet', + 'name': 'Ethernet1', + 'port_number': 1, + 'short_name': 'e1'}, + ... + +In [18]: alpine.ports +Out[18]: +[{'adapter_number': 0, + 'data_link_types': {'Ethernet': 'DLT_EN10MB'}, + 'link_type': 'ethernet', + 'name': 'eth0', + 'port_number': 0, + 'short_name': 'eth0'}, + {'adapter_number': 1, + 'data_link_types': {'Ethernet': 'DLT_EN10MB'}, + 'link_type': 'ethernet', + 'name': 'eth1', + 'port_number': 0, + 'short_name': 'eth1'}] +``` + +Gettings this information from both nodes we can create the Link. + +```python hl_lines="2 3" + +In [19]: nodes = [ + dict(node_id=switch.node_id, adapter_number=0, port_number=1), + dict(node_id=alpine.node_id, adapter_number=0, port_number=0) +] + +In [20]: extra_link = Link(project_id=lab.project_id, connector=server, nodes=nodes) + +In [21]: extra_link.create() + +In [22]: extra_link +Out[22]: Link(link_id='edf38e1a-67e7-4060-8493-0e222ec22072', link_type='ethernet', project_id='6e75bca5... +``` + +You can get the latest link information on the project + +```python + +In [41]: lab.get_links() + +# You can see the 2 links created earlier +In [42]: lab.links +Out[42]: +[Link(link_id='b0d0df11-8ed8-4d1d-98e4-3776c9b7bdce', link_type='ethernet'... +Link(link_id='=', link_type='ethernet'...] +``` + +You can see the final result if you open the lab on your GNS3 client: + +![test_lab](img/create_lab_example.png) + +!!! note + The devices are all clustered together. This will be addressed in the future. For te moment you can re-arrange them the way you want + +## Examples + +Here are some examples of what you can do with the library + +### Get Nodes and Links summary + +For a given project you can use `nodes_summary` and `links_summary`, that if used with a library like `tabulate` you can obtain the following: + +```python +... +from tabulate import tabulate + +nodes_summary = lab.nodes_summary(is_print=False) + +print( + tabulate(nodes_summary, headers=["Node", "Status", "Console Port", "ID"]) +) +# Node Status Console Port ID +# ---------------- -------- -------------- ------------------------------------ +# Ethernetswitch-1 started 5000 da28e1c0-9465-4f7c-b42c-49b2f4e1c64d +# IOU1 started 5001 de23a89a-aa1f-446a-a950-31d4bf98653c +# IOU2 started 5002 0d10d697-ef8d-40af-a4f3-fafe71f5458b +# vEOS-4.21.5F-1 started 5003 8283b923-df0e-4bc1-8199-be6fea40f500 +# alpine-1 started 5005 ef503c45-e998-499d-88fc-2765614b313e +# Cloud-1 started cde85a31-c97f-4551-9596-a3ed12c08498 + +links_summary = lab.links_summary(is_print=False) +print( + tabulate(links_summary, headers=["Node A", "Port A", "Node B", "Port B"]) +) +# Node A Port A Node B Port B +# -------------- ----------- ---------------- ----------- +# IOU1 Ethernet1/0 IOU2 Ethernet1/0 +# vEOS-4.21.5F-1 Management1 Ethernetswitch-1 Ethernet0 +# vEOS-4.21.5F-1 Ethernet1 alpine-1 eth0 +# Cloud-1 eth1 Ethernetswitch-1 Ethernet7 +``` + +### Manipulate a Node from a Project + +The `Project` object gives you all the nodes configured on it. This is typically saved under `Project.nodes` as a list of `Node` instances. + +When collecting the information of a project on a given time, you also retrieve by default all of its nodes. Each of the nodes are assigned the `Gns3Connector` by default if you follow the procedure below. + +```python +>>> server = Gns3Connector(url="http://
:3080")] +>>> print(server) +'' +>>> lab = Project(name="lab", connector=server) + +# Retrieve the lab information and print the amount of nodes configured +>>> lab.get() +>>> print(len(lab.nodes)) +2 + +# Assign one of the nodes to a varaible and start manipulating it +>>> node_1 = lab.nodes[0] +>>> print(node_1.status) +'stopped' + +>>> node_1.start() +>>> print(node_1.status) +'started' +>>> print(node_1.connector) +'' +``` + +`node_1` has the same connector object as reference for interaction with the server. + +The same can be done with a `Link` by interacting with the `Project.links` attribute. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 0000000..9d40327 --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,15 @@ +site_name: gns3fy +repo_url: https://github.com/davidban77/gns3fy/ +docs_dir: "content" +theme: cinder +markdown_extensions: + - toc: + toc_depth: 2-4 + - admonition +nav: + - Overview: index.md + - User Guide: user-guide.md + - API Reference: api_reference.md + - About: + - Release Notes: about/changelog.md + - License: about/license.md diff --git a/docs/pydoc-markdown.yml b/docs/pydoc-markdown.yml new file mode 100644 index 0000000..7e07ad7 --- /dev/null +++ b/docs/pydoc-markdown.yml @@ -0,0 +1,16 @@ +loaders: + - type: python + modules: [gns3fy] + search_path: [../gns3fy] +processors: + - type: pydocmd + - type: filter + document_only: true + - type: filter + expression: not name.endswith("_TYPES") + - type: filter + expression: not name.startswith("Config") + # - type: filter + # expression: not name.startswith("valid_") +renderer: + type: markdown diff --git a/gns3fy/__init__.py b/gns3fy/__init__.py index 78d8cae..8b82710 100644 --- a/gns3fy/__init__.py +++ b/gns3fy/__init__.py @@ -1,3 +1,3 @@ -from .api import Gns3Connector, Project, Node, Link +from .gns3fy import Gns3Connector, Project, Node, Link __all__ = ["Gns3Connector", "Project", "Node", "Link"] diff --git a/gns3fy/api.py b/gns3fy/api.py deleted file mode 100644 index d530e12..0000000 --- a/gns3fy/api.py +++ /dev/null @@ -1,1223 +0,0 @@ -import time -import requests -from urllib.parse import urlencode, urlparse -from requests import ConnectionError, ConnectTimeout, HTTPError -from dataclasses import field -from typing import Optional, Any, Dict, List -from pydantic import validator -from pydantic.dataclasses import dataclass - - -class Config: - validate_assignment = True - # TODO: Not really working.. Need to investigate more and possibly open an issue - extra = "ignore" - - -NODE_TYPES = [ - "cloud", - "nat", - "ethernet_hub", - "ethernet_switch", - "frame_relay_switch", - "atm_switch", - "docker", - "dynamips", - "vpcs", - "traceng", - "virtualbox", - "vmware", - "iou", - "qemu", -] - -CONSOLE_TYPES = [ - "vnc", - "telnet", - "http", - "https", - "spice", - "spice+agent", - "none", - "null", -] - -LINK_TYPES = ["ethernet", "serial"] - - -class Gns3Connector: - """ - Connector to be use for interaction against GNS3 server controller API. - - Attributes: - - `base_url`: url passed + api_version - - `user`: User used for authentication - - `cred`: Password used for authentication - - `verify`: Whether or not to verify SSL - - `api_calls`: Counter of amount of `http_calls` has been performed - - `session`: Requests Session object - - Methods: - - `http_call`: Main method to perform REST API calls - - `error_checker`: Performs basic HTTP code checking - - `get_projects`: Retrieves ALL the projects configured on a GNS3 server - """ - - def __init__(self, url=None, user=None, cred=None, verify=False, api_version=2): - requests.packages.urllib3.disable_warnings() - self.base_url = f"{url.strip('/')}/v{api_version}" - self.user = user - self.headers = {"Content-Type": "application/json"} - self.verify = verify - self.api_calls = 0 - - # Create session object - self.create_session() - # self.session = requests.Session() - # self.session.headers["Accept"] = "application/json" - # if self.user: - # self.session.auth = (user, cred) - - def create_session(self): - self.session = requests.Session() - self.session.headers["Accept"] = "application/json" - if self.user: - self.session.auth = (self.user, self.cred) - - def http_call( - self, - method, - url, - data=None, - json_data=None, - headers=None, - verify=False, - params=None, - ): - "Standard method to perform calls" - try: - if data: - _response = getattr(self.session, method.lower())( - url, - data=urlencode(data), - # data=data, - headers=headers, - params=params, - verify=verify, - ) - - elif json_data: - _response = getattr(self.session, method.lower())( - url, json=json_data, headers=headers, params=params, verify=verify - ) - - else: - _response = getattr(self.session, method.lower())( - url, headers=headers, params=params, verify=verify - ) - - time.sleep(0.5) - self.api_calls += 1 - - except (ConnectTimeout, ConnectionError) as err: - print( - f"[ERROR] Connection Error, could not perform {method}" - f" operation: {err}" - ) - return False - except HTTPError as err: - print( - f"[ERROR] An unknown error has been encountered: {err} -" - f" {_response.text}" - ) - return False - - return _response - - @staticmethod - def error_checker(response_obj): - err = f"[ERROR][{response_obj.status_code}]: {response_obj.text}" - return err if 400 <= response_obj.status_code <= 599 else False - - def get_version(self): - "Returns the version information" - return self.http_call("get", url=f"{self.base_url}/version").json() - - def get_projects(self): - "Returns the list of dictionaries of the projects on the server" - return self.http_call("get", url=f"{self.base_url}/projects").json() - - def get_project_by_name(self, name): - "Retrives an specific project" - _projects = self.http_call("get", url=f"{self.base_url}/projects").json() - try: - return [p for p in _projects if p["name"] == name][0] - except IndexError: - return None - - def get_project_by_id(self, id): - "Retrives an specific template by id" - return self.http_call("get", url=f"{self.base_url}/projects/{id}").json() - - def get_templates(self): - "Returns the version information" - return self.http_call("get", url=f"{self.base_url}/templates").json() - - def get_template_by_name(self, name): - "Retrives an specific template searching by name" - _templates = self.http_call("get", url=f"{self.base_url}/templates").json() - try: - return [t for t in _templates if t["name"] == name][0] - except IndexError: - return None - - def get_template_by_id(self, id): - "Retrives an specific template by id" - return self.http_call("get", url=f"{self.base_url}/templates/{id}").json() - - def get_nodes(self, project_id): - return self.http_call( - "get", url=f"{self.base_url}/projects/{project_id}/nodes" - ).json() - - def get_node_by_id(self, project_id, node_id): - """ - Returns the node by locating ID - """ - _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}" - return self.http_call("get", _url).json() - - def get_links(self, project_id): - return self.http_call( - "get", url=f"{self.base_url}/projects/{project_id}/links" - ).json() - - def get_link_by_id(self, project_id, link_id): - """ - Returns the link by locating ID - """ - _url = f"{self.base_url}/projects/{project_id}/links/{link_id}" - return self.http_call("get", _url).json() - - def create_project(self, **kwargs): - """ - Pass a dictionary type object with the project parameters to be created. - Parameter `name` is mandatory. Returns project - """ - _url = f"{self.base_url}/projects" - if "name" not in kwargs: - raise ValueError("Parameter 'name' is mandatory") - return self.http_call("post", _url, json_data=kwargs).json() - - def delete_project(self, project_id): - """ - Deletes a project from server - """ - _url = f"{self.base_url}/projects/{project_id}" - self.http_call("delete", _url) - return - - -@dataclass(config=Config) -class Link: - """ - GNS3 Link API object - - Attributes: - http://api.gns3.net/en/2.2/api/v2/controller/link/projectsprojectidlinks.html - - Methods: - - `get`: Retrieves the link information - - `delete`: Deletes the link - """ - - link_id: Optional[str] = None - link_type: Optional[str] = None - project_id: Optional[str] = None - suspend: Optional[bool] = None - nodes: Optional[List[Any]] = None - filters: Optional[Any] = None - capturing: Optional[bool] = None - capture_file_path: Optional[str] = None - capture_file_name: Optional[str] = None - capture_compute_id: Optional[str] = None - - connector: Optional[Any] = field(default=None, repr=False) - - @validator("link_type") - def valid_node_type(cls, value): - if value not in LINK_TYPES: - raise ValueError(f"Not a valid link_type - {value}") - return value - - def _update(self, data_dict): - for k, v in data_dict.items(): - if k in self.__dict__.keys(): - self.__setattr__(k, v) - - def _verify_before_action(self): - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - if not self.link_id: - raise ValueError("Need to submit link_id") - - def get(self): - """ - Retrieves the information from the link - """ - self._verify_before_action() - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/links/{self.link_id}" - ) - _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - self._update(_response.json()) - - def delete(self): - """ - Deletes a link from the project - """ - self._verify_before_action() - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/links/{self.link_id}" - ) - - _response = self.connector.http_call("delete", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - self.project_id = None - self.link_id = None - - def create(self): - """ - Creates a link - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - _url = f"{self.connector.base_url}/projects/{self.project_id}/links" - - data = { - k: v - for k, v in self.__dict__.items() - if k not in ("connector", "__initialised__") - if v is not None - } - - _response = self.connector.http_call("post", _url, json_data=data) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Now update it - self._update(_response.json()) - - -@dataclass(config=Config) -class Node: - """ - GNS3 Node API object. - - Attributes: - http://api.gns3.net/en/2.2/api/v2/controller/node/projectsprojectidnodes.html - - Methods: - - `get`: Retrieves the node information - - `delete`: Deletes the node - - `get_links`: Retrieves the links of the node - - `start`: Starts the node - - `stop`: Stops the node - - `suspend`: Suspends the node - - `reload`: Reloads the node - """ - - name: Optional[str] = None - project_id: Optional[str] = None - node_id: Optional[str] = None - compute_id: str = "local" - node_type: Optional[str] = None - node_directory: Optional[str] = None - status: Optional[str] = None - ports: Optional[List] = None - port_name_format: Optional[str] = None - port_segment_size: Optional[int] = None - first_port_name: Optional[str] = None - locked: Optional[bool] = None - label: Optional[Any] = None - console: Optional[str] = None - console_host: Optional[str] = None - console_type: Optional[str] = None - console_auto_start: Optional[bool] = None - command_line: Optional[str] = None - custom_adapters: Optional[List[Any]] = None - height: Optional[int] = None - width: Optional[int] = None - symbol: Optional[str] = None - x: Optional[int] = None - y: Optional[int] = None - z: Optional[int] = None - template_id: Optional[str] = None - properties: Dict = field(default_factory=dict) - - template: Optional[str] = None - links: List[Link] = field(default_factory=list, repr=False) - connector: Optional[Any] = field(default=None, repr=False) - - @validator("node_type") - def valid_node_type(cls, value): - if value not in NODE_TYPES: - raise ValueError(f"Not a valid node_type - {value}") - return value - - @validator("console_type") - def valid_console_type(cls, value): - if value not in CONSOLE_TYPES: - raise ValueError(f"Not a valid console_type - {value}") - return value - - @validator("status") - def valid_status(cls, value): - if value not in ("stopped", "started", "suspended"): - raise ValueError(f"Not a valid status - {value}") - return value - - def _update(self, data_dict): - for k, v in data_dict.items(): - if k in self.__dict__.keys(): - self.__setattr__(k, v) - - def _verify_before_action(self): - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - if not self.node_id: - if not self.name: - raise ValueError("Need to either submit node_id or name") - - # Try to retrieve the node_id - _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" - _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - extracted = [node for node in _response.json() - if node["name"] == self.name] - if len(extracted) > 1: - raise ValueError( - "Multiple nodes found with same name. Need to submit node_id" - ) - self.node_id = extracted[0]["node_id"] - - def get(self, get_links=True): - """ - Retrieves the node information. - - - `get_links`: When True is also retrieves the links of the node - """ - self._verify_before_action() - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes/{self.node_id}" - ) - _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - self._update(_response.json()) - - if get_links: - self.get_links() - - def get_links(self): - """ - Retrieves the links of the respective node - """ - self._verify_before_action() - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes" - f"/{self.node_id}/links" - ) - _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Create the Link array but cleanup cache if there is one - if self.links: - self.links = [] - for _link in _response.json(): - self.links.append(Link(connector=self.connector, **_link)) - - def start(self): - """ - Starts the node - """ - self._verify_before_action() - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes" - f"/{self.node_id}/start" - ) - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "started": - self._update(_response.json()) - else: - self.get() - - def stop(self): - """ - Stops the node - """ - self._verify_before_action() - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes" - f"/{self.node_id}/stop" - ) - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "stopped": - self._update(_response.json()) - else: - self.get() - - def reload(self): - """ - Reloads the node - """ - self._verify_before_action() - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes" - f"/{self.node_id}/reload" - ) - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "started": - self._update(_response.json()) - else: - self.get() - - def suspend(self): - """ - Suspends the node - """ - self._verify_before_action() - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes" - f"/{self.node_id}/suspend" - ) - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "suspended": - self._update(_response.json()) - else: - self.get() - - def create(self, extra_properties={}): - """ - Creates a node. - - Attributes: - - `with_template`: It fetches for the template data and applies it to the nodes - `properties` field. This is needed in order to create the desired node on the - topology. - - `extra_properties`: When issued, it applies the given dictionary of parameters - to the nodes `properties` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit 'project_id'") - if not self.compute_id: - raise ValueError("Need to submit 'compute_id'") - if not self.name: - raise ValueError("Need to submit 'name'") - if not self.node_type: - raise ValueError("Need to submit 'node_type'") - if self.node_id: - raise ValueError("Node already created") - - _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" - - data = { - k: v - for k, v in self.__dict__.items() - if k - not in ("project_id", "template", "links", "connector", "__initialised__") - if v is not None - } - - # Fetch template for properties - if self.template_id: - _properties = self.connector.get_template_by_id(self.template_id) - elif self.template: - _properties = self.connector.get_template_by_name(self.template) - else: - raise ValueError("You must provide `template` or `template_id`") - - # Delete not needed fields - for _field in ( - "compute_id", - "default_name_format", - "template_type", - "template_id", - "builtin", - "name", # Needs to be deleted because it overrides the name of host - ): - try: - _properties.pop(_field) - except KeyError: - continue - - # Override/Merge extra properties - if extra_properties: - _properties.update(**extra_properties) - data.update(properties=_properties) - - _response = self.connector.http_call("post", _url, json_data=data) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - self._update(_response.json()) - - def delete(self): - """ - Deletes the node from the project - """ - self._verify_before_action() - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes/{self.node_id}" - ) - - _response = self.connector.http_call("delete", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - self.project_id = None - self.node_id = None - self.name = None - - -@dataclass(config=Config) -class Project: - """ - GNS3 Project API object - - Attributes: - http://api.gns3.net/en/2.2/api/v2/controller/project/projects.html - - Methods: - - `get`: Retrieves the project information - - `open`: Opens up a project - - `close`: Closes a project - - `update`: Updates a project instance - - `delete`: Deletes the project - - `get_nodes`: Retrieves the nodes of the project - - `get_stats`: Retrieves the stats of the project - - `get_links`: Retrieves the links of the project - - `start_nodes`: Starts ALL nodes inside the project - - `stop_nodes`: Stops ALL nodes inside the project - - `suspend_nodes`: Suspends ALL nodes inside the project - - `nodes_summary`: Shows summary of the nodes created inside the project - - `links_summary`: Shows summary of the links created inside the project - """ - - project_id: Optional[str] = None - name: Optional[str] = None - status: Optional[str] = None - path: Optional[str] = None - filename: Optional[str] = None - auto_start: Optional[bool] = None - auto_close: Optional[bool] = None - auto_open: Optional[bool] = None - drawing_grid_size: Optional[int] = None - grid_size: Optional[int] = None - scene_height: Optional[int] = None - scene_width: Optional[int] = None - show_grid: Optional[bool] = None - show_interface_labels: Optional[bool] = None - show_layers: Optional[bool] = None - snap_to_grid: Optional[bool] = None - supplier: Optional[Any] = None - variables: Optional[List] = None - zoom: Optional[int] = None - - stats: Optional[Dict[str, Any]] = None - nodes: List[Node] = field(default_factory=list, repr=False) - links: List[Link] = field(default_factory=list, repr=False) - connector: Optional[Any] = field(default=None, repr=False) - # There are more... but will develop them in due time - - @validator("status") - def valid_status(cls, value): - if value != "opened" and value != "closed": - raise ValueError("status must be 'opened' or 'closed'") - return value - - def _update(self, data_dict): - for k, v in data_dict.items(): - if k in self.__dict__.keys(): - self.__setattr__(k, v) - - def _verify_before_action(self): - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - def get(self, get_links=True, get_nodes=True, get_stats=True): - """ - Retrieves the projects information. - - - `get_links`: When true it also queries for the links inside the project - - `get_nodes`: When true it also queries for the nodes inside the project - - `get_stats`: When true it also queries for the stats inside the project - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - - # Get projects if no ID was provided by the name - if not self.project_id: - if not self.name: - raise ValueError("Need to submit either project_id or name") - _url = f"{self.connector.base_url}/projects" - # Get all projects and filter the respective project - _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Filter the respective project - for _project in _response.json(): - if _project.get("name") == self.name: - self.project_id = _project.get("project_id") - - # Get project - _url = f"{self.connector.base_url}/projects/{self.project_id}" - _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - self._update(_response.json()) - - if get_stats: - self.get_stats() - if get_nodes: - self.get_nodes() - if get_links: - self.get_links() - - def create(self): - """ - Creates the project - """ - if not self.name: - raise ValueError("Need to submit projects `name`") - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - - _url = f"{self.connector.base_url}/projects" - - data = { - k: v - for k, v in self.__dict__.items() - if k not in ("stats", "nodes", "links", "connector", "__initialised__") - if v is not None - } - - _response = self.connector.http_call("post", _url, json_data=data) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Now update it - self._update(_response.json()) - - def update(self, **kwargs): - """ - Updates the project instance by passing the keyword arguments directly into the - body of the PUT method. - - Example: - lab.update(auto_close=True) - - This will update the project `auto_close` attribute to True - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}" - - # TODO: Verify that the passed kwargs are supported ones - _response = self.connector.http_call("put", _url, json_data=kwargs) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - self._update(_response.json()) - - def delete(self): - """ - Deletes the project from the server. It DOES NOT DELETE THE OBJECT locally, it - justs sets the `project_id` and `name` to None - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}" - - _response = self.connector.http_call("delete", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - self.project_id = None - self.name = None - - def close(self): - """ - Closes the project on the server. - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}/close" - - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - self._update(_response.json()) - - def open(self): - """ - Opens the project on the server. - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}/open" - - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - self._update(_response.json()) - - def get_stats(self): - """ - Retrieve the stats of the project - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}/stats" - - _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - self.stats = _response.json() - - def get_nodes(self): - """ - Retrieve the nodes of the project - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" - - _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Create the Nodes array but cleanup cache if there is one - if self.nodes: - self.nodes = [] - for _node in _response.json(): - _n = Node(connector=self.connector, **_node) - _n.project_id = self.project_id - self.nodes.append(_n) - - def get_links(self): - """ - Retrieve the links of the project - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}/links" - - _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Create the Nodes array but cleanup cache if there is one - if self.links: - self.links = [] - for _link in _response.json(): - _l = Link(connector=self.connector, **_link) - _l.project_id = self.project_id - self.links.append(_l) - - def start_nodes(self, poll_wait_time=5): - """ - Starts all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/start" - - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - def stop_nodes(self, poll_wait_time=5): - """ - Stops all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/stop" - - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - def reload_nodes(self, poll_wait_time=5): - """ - Reloads all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/reload" - - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - def suspend_nodes(self, poll_wait_time=5): - """ - Suspends all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - """ - self._verify_before_action() - - _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/suspend" - - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - def nodes_summary(self, is_print=True): - """ - Returns a summary of the nodes insode the project. If `is_print` is False, it - will return a list of tuples like: - - [(node_name, node_status, node_console, node_id) ...] - """ - if not self.nodes: - self.get_nodes() - - _nodes_summary = [] - for _n in self.nodes: - if is_print: - print( - f"{_n.name}: {_n.status} -- Console: {_n.console} -- " - f"ID: {_n.node_id}" - ) - _nodes_summary.append((_n.name, _n.status, _n.console, _n.node_id)) - - return _nodes_summary if not is_print else None - - def nodes_inventory(self, is_print=True): - """ - Returns an ansible-type inventory with the nodes of the project - - Example: - - { - "router01": { - "hostname": "127.0.0.1", - "username": "vagrant", - "password": "vagrant", - "port": 12444, - "platform": "eos", - "groups": [ - "border" - ], - } - } - """ - - if not self.nodes: - self.get_nodes() - - _nodes_inventory = {} - _hostname = urlparse(self.connector.base_url).hostname - - for _n in self.nodes: - - _nodes_inventory.update({_n.name: {'hostname': _hostname, - 'name': _n.name, - 'console_port': _n.console, - 'type': _n.node_type, - }}) - if is_print: - print(_nodes_inventory) - - return _nodes_inventory if not is_print else None - - def links_summary(self, is_print=True): - """ - Returns a summary of the links insode the project. If `is_print` is False, it - will return a list of tuples like: - - [(node_a, port_a, node_b, port_b) ...] - """ - if not self.nodes: - self.get_nodes() - if not self.links: - self.get_links() - - _links_summary = [] - for _l in self.links: - if not _l.nodes: - continue - _side_a = _l.nodes[0] - _side_b = _l.nodes[1] - _node_a = [x for x in self.nodes if x.node_id == - _side_a["node_id"]][0] - _port_a = [ - x["name"] - for x in _node_a.ports - if x["port_number"] == _side_a["port_number"] - and x["adapter_number"] == _side_a["adapter_number"] - ][0] - _node_b = [x for x in self.nodes if x.node_id == - _side_b["node_id"]][0] - _port_b = [ - x["name"] - for x in _node_b.ports - if x["port_number"] == _side_b["port_number"] - and x["adapter_number"] == _side_b["adapter_number"] - ][0] - endpoint_a = f"{_node_a.name}: {_port_a}" - endpoint_b = f"{_node_b.name}: {_port_b}" - if is_print: - print(f"{endpoint_a} ---- {endpoint_b}") - _links_summary.append( - (_node_a.name, _port_a, _node_b.name, _port_b)) - - return _links_summary if not is_print else None - - def _search_node(self, key, value): - "Performs a search based on a key and value" - # Retrive nodes if neccesary - if not self.nodes: - self.get_nodes() - - try: - return [_p for _p in self.nodes if getattr(_p, key) == value][0] - except IndexError: - return None - - def get_node(self, name=None, node_id=None): - """ - Returns the Node object by searching for the name or the node_id. - - NOTE: Run method `get_nodes()` manually to refresh list of nodes if necessary - """ - if node_id: - return self._search_node(key="node_id", value=node_id) - elif name: - return self._search_node(key="name", value=name) - else: - raise ValueError("name or node_ide must be provided") - - def _search_link(self, key, value): - "Performs a search based on a key and value" - # Retrive links if neccesary - if not self.links: - self.get_links() - - try: - return [_p for _p in self.links if getattr(_p, key) == value][0] - except IndexError: - return None - - def get_link(self, link_id): - """ - Returns the Link object by locating ID - - NOTE: Run method `get_links()` manually to refresh list of nodes if necessary - """ - if link_id: - return self._search_node(key="link_id", value=link_id) - else: - raise ValueError("name or node_ide must be provided") - - def create_node(self, name=None, **kwargs): - """ - Creates a node - """ - if not self.nodes: - self.get_nodes() - - # Even though GNS3 allow same name to be pushed because it automatically - # generates a new name if matches an exising node, here is verified beforehand - # and forces developer to create new name. - _matches = [(_n.name, _n.node_id) for _n in self.nodes if name == _n.name] - if _matches: - raise ValueError( - f"Node with equal name found: {_matches[0][0]} - ID: {_matches[0][-1]}" - ) - - _node = Node( - project_id=self.project_id, connector=self.connector, name=name, **kwargs - ) - _node.create() - self.nodes.append(_node) - print( - f"Created: {_node.name} -- Type: {_node.node_type} -- " - f"Console: {_node.console}" - ) - - def create_link(self, node_a, port_a, node_b, port_b): - """ - Creates a link - """ - if not self.nodes: - self.get_nodes() - if not self.links: - self.get_links() - - _node_a = self.get_node(name=node_a) - if not _node_a: - raise ValueError(f"node_a: {node_a} not found") - try: - _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] - except IndexError: - raise ValueError(f"port_a: {port_a} - not found") - - _node_b = self.get_node(name=node_b) - if not _node_b: - raise ValueError(f"node_b: {node_b} not found") - try: - _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] - except IndexError: - raise ValueError(f"port_b: {port_b} - not found") - - _matches = [] - for _l in self.links: - if ( - _l.nodes[0]["node_id"] == _node_a.node_id - and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] - and _l.nodes[0]["port_number"] == _port_a["port_number"] - ): - _matches.append(_l) - elif ( - _l.nodes[1]["node_id"] == _node_b.node_id - and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"] - and _l.nodes[1]["port_number"] == _port_b["port_number"] - ): - _matches.append(_l) - if _matches: - raise ValueError(f"At least one port is used, ID: {_matches[0].link_id}") - - # Now create the link! - _link = Link( - project_id=self.project_id, - connector=self.connector, - nodes=[ - dict( - node_id=_node_a.node_id, - adapter_number=_port_a["adapter_number"], - port_number=_port_a["port_number"], - ), - dict( - node_id=_node_b.node_id, - adapter_number=_port_b["adapter_number"], - port_number=_port_b["port_number"], - ), - ], - ) - - _link.create() - self.links.append(_link) - print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}") diff --git a/poetry.lock b/poetry.lock index d6fb126..b931f85 100644 --- a/poetry.lock +++ b/poetry.lock @@ -178,11 +178,53 @@ description = "An autocompletion tool for Python that can be used for text edito name = "jedi" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.14.1" +version = "0.15.1" [package.dependencies] parso = ">=0.5.0" +[[package]] +category = "dev" +description = "A small but fast and easy to use stand-alone template engine written in pure python." +name = "jinja2" +optional = false +python-versions = "*" +version = "2.10.1" + +[package.dependencies] +MarkupSafe = ">=0.23" + +[[package]] +category = "dev" +description = "Python LiveReload is an awesome tool for web developers" +name = "livereload" +optional = false +python-versions = "*" +version = "2.6.1" + +[package.dependencies] +six = "*" +tornado = "*" + +[[package]] +category = "dev" +description = "Python implementation of Markdown." +name = "markdown" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" +version = "3.1.1" + +[package.dependencies] +setuptools = ">=36" + +[[package]] +category = "dev" +description = "Safely add untrusted strings to HTML/XML markup." +name = "markupsafe" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.1.1" + [[package]] category = "dev" description = "McCabe checker, plugin for flake8" @@ -191,6 +233,30 @@ optional = false python-versions = "*" version = "0.6.1" +[[package]] +category = "dev" +description = "Project documentation with Markdown." +name = "mkdocs" +optional = false +python-versions = ">=2.7.9,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.0.4" + +[package.dependencies] +Jinja2 = ">=2.7.1" +Markdown = ">=2.3.1" +PyYAML = ">=3.10" +click = ">=3.3" +livereload = ">=2.5.1" +tornado = ">=5.0" + +[[package]] +category = "dev" +description = "A clean responsive theme for the MkDocs static documentation site generator" +name = "mkdocs-cinder" +optional = false +python-versions = "*" +version = "0.17.0" + [[package]] category = "dev" description = "More routines for operating on iterables, beyond itertools" @@ -199,6 +265,18 @@ optional = false python-versions = ">=3.4" version = "7.2.0" +[[package]] +category = "main" +description = "Toolbox with useful Python classes and type magic." +name = "nr.types" +optional = false +python-versions = "*" +version = "2.5.4" + +[package.dependencies] +six = "*" +typing = "*" + [[package]] category = "dev" description = "Core utilities for Python packages" @@ -294,7 +372,25 @@ description = "Data validation and settings management using python 3.6 type hin name = "pydantic" optional = false python-versions = ">=3.6" -version = "0.31" +version = "0.31.1" + +[[package]] +category = "main" +description = "Create Python API documentation in Markdown format." +name = "pydoc-markdown" +optional = false +python-versions = "*" +version = "3.0.0" + +[package.dependencies] +"nr.types" = ">=2.3.0" +pyyaml = ">=3.12" +six = ">=0.11.0" + +[package.source] +reference = "468cace9378a64a267848c07c21a5aedbfab2cf3" +type = "git" +url = "https://github.com/NiklasRosenstein/pydoc-markdown.git" [[package]] category = "dev" @@ -326,19 +422,22 @@ description = "pytest: simple powerful testing with Python" name = "pytest" optional = false python-versions = ">=3.5" -version = "5.0.1" +version = "5.1.0" [package.dependencies] atomicwrites = ">=1.0" attrs = ">=17.4.0" colorama = "*" -importlib-metadata = ">=0.12" more-itertools = ">=4.0.0" packaging = "*" pluggy = ">=0.12,<1.0" py = ">=1.5.0" wcwidth = "*" +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + [[package]] category = "dev" description = "Pytest plugin for measuring coverage." @@ -351,6 +450,14 @@ version = "2.7.1" coverage = ">=4.4" pytest = ">=3.6" +[[package]] +category = "main" +description = "YAML parser and emitter for Python" +name = "pyyaml" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "5.1.2" + [[package]] category = "main" description = "Python HTTP for Humans." @@ -378,7 +485,7 @@ requests = ">=2.3" six = "*" [[package]] -category = "dev" +category = "main" description = "Python 2 and 3 compatibility utilities" name = "six" optional = false @@ -393,6 +500,14 @@ optional = false python-versions = "*" version = "0.10.0" +[[package]] +category = "dev" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +name = "tornado" +optional = false +python-versions = ">= 3.5" +version = "6.0.3" + [[package]] category = "dev" description = "Traitlets Python config system" @@ -406,6 +521,14 @@ decorator = "*" ipython-genutils = "*" six = "*" +[[package]] +category = "main" +description = "Type Hints for Python" +name = "typing" +optional = false +python-versions = "*" +version = "3.7.4" + [[package]] category = "main" description = "HTTP library with thread-safe connection pooling, file post, and more." @@ -431,7 +554,7 @@ python-versions = ">=2.7" version = "0.5.2" [metadata] -content-hash = "f8431e7b087331446fa25cc23b03922a0328fcd979c0e1e85aa3b1b8a4083ae7" +content-hash = "ce2489518292598aba61b374aa124eb114857064aab67ef919c604513b5d109b" python-versions = "^3.7" [metadata.hashes] @@ -453,9 +576,16 @@ idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8 importlib-metadata = ["23d3d873e008a513952355379d93cbcab874c58f4f034ff657c7a87422fa64e8", "80d2de76188eabfbfcf27e6a37342c2827801e59c4cc14b0371c56fed43820e3"] ipython = ["1d3a1692921e932751bc1a1f7bb96dc38671eeefdc66ed33ee4cbc57e92a410e", "537cd0176ff6abd06ef3e23f2d0c4c2c8a4d9277b7451544c6cbf56d1c79a83d"] ipython-genutils = ["72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", "eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"] -jedi = ["53c850f1a7d3cfcd306cc513e2450a54bdf5cacd7604b74e42dd1f0758eaaf36", "e07457174ef7cb2342ff94fa56484fe41cec7ef69b0059f01d3f812379cb6f7c"] +jedi = ["786b6c3d80e2f06fd77162a07fed81b8baa22dde5d62896a790a331d6ac21a27", "ba859c74fa3c966a22f2aeebe1b74ee27e2a462f56d3f5f7ca4a59af61bfe42e"] +jinja2 = ["065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", "14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"] +livereload = ["78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b", "89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66"] +markdown = ["2e50876bcdd74517e7b71f3e7a76102050edec255b3983403f1a63e7c8a41e7a", "56a46ac655704b91e5b7e6326ce43d5ef72411376588afa1dd90e881b83c7e8c"] +markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] +mkdocs = ["17d34329aad75d5de604b9ed4e31df3a4d235afefdc46ce7b1964fddb2e1e939", "8cc8b38325456b9e942c981a209eaeb1e9f3f77b493ad755bfef889b9c8d356a"] +mkdocs-cinder = ["0623b3b8c3a70fb735e966da805ff6f33e6c547ae9b26e6146d4995053e182f5", "984ba6df20916240647e5386d8444e6dbf3a8200e4d6db2d58cb145f40f99ff2"] more-itertools = ["409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"] +"nr.types" = ["cabd34798976abb3466442008f3ae11c490ebe3c0573c2db9631346db3a45f34"] packaging = ["a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9", "c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe"] parso = ["63854233e1fadb5da97f2744b6b24346d2750b85965e7e399bec1620232797dc", "666b0ee4a7a1220f65d367617f2cd3ffddff3e205f3f16a0284df30e774c2a9c"] pexpect = ["2094eefdfcf37a1fdbfb9aa090862c1a4878e5c7e0e7e7088bdb511c558e5cd1", "9e2c1fd0e6ee3a49b28f95d4b33bc389c89b20af6a1255906e90ff1262ce62eb"] @@ -465,17 +595,21 @@ prompt-toolkit = ["11adf3389a996a6d45cc277580d0d53e8a5afd281d0c9ec71b28e6f121463 ptyprocess = ["923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0", "d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"] py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] -pydantic = ["1bd13f418ea2200b3b3782543f7cb9efca3ca2b6dc35dbc8090ce75e924a74e8", "2914429657a106b09e4e64d4470c493effad145bfcdd98567d431e3c59d2bff9", "29a941ebda15480624d3c970ffe099cedf6ca1f90748e0ae801626f337063bc6", "534f8d9f4b4c4d107774afff0da039c262e6e83c94c38ba42549b47a5344823d", "6d9448b92dc1ab15918c00ee0915eb50b950f671a92fc25a018e667f61613d75", "817bda425bcdeb664be40a27de9e071d42b59a4e631f69eeb610e249d9d73d5d"] +pydantic = ["08dd1503f0524b78d03df76daf8b66ae213b9eaa0f072ad89d05a981e7aeaad2", "2b5db9a3e976fad1ddcb5e2daf4de87017f6b429e8e4871d3f52c5f004f7eaf7", "68085bebf700c2dbdf0ccdd5f09a4b19c13ced26b9bdd3447920f710fcd98c57", "772300e1efbd1271aade7bb03127c51bf8a2f705c861a8f5e82e0bc97f1f125e", "8bf39547da39abf5e02c79ed13ed377e65a095e2a71069ac7ce625de97840e63", "9324efe3eaa10cca3a488dc8ede1e0907f64394949fc6b51ced68cd2c8c30048"] +pydoc-markdown = [] pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] pyparsing = ["6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", "d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"] -pytest = ["6ef6d06de77ce2961156013e9dff62f1b2688aa04d0dc244299fe7d67e09370d", "a736fed91c12681a7b34617c8fcefe39ea04599ca72c608751c31d89579a3f77"] +pytest = ["3805d095f1ea279b9870c3eeae5dddf8a81b10952c8835cd628cf1875b0ef031", "abc562321c2d190dd63c2faadf70b86b7af21a553b61f0df5f5e1270717dc5a3"] pytest-cov = ["2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", "e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a"] +pyyaml = ["0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", "01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", "5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", "5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", "7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", "7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", "87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", "9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", "a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", "b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", "b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", "bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", "f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"] requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] requests-mock = ["12e17c7ad1397fd1df5ead7727eb3f1bdc9fe1c18293b0492e0e01b57997e38d", "dc9e416a095ee7c3360056990d52e5611fb94469352fc1c2dc85be1ff2189146"] six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] +tornado = ["349884248c36801afa19e342a77cc4458caca694b0eda633f5878e458a44cb2c", "398e0d35e086ba38a0427c3b37f4337327231942e731edaa6e9fd1865bbd6f60", "4e73ef678b1a859f0cb29e1d895526a20ea64b5ffd510a2307b5998c7df24281", "559bce3d31484b665259f50cd94c5c28b961b09315ccd838f284687245f416e5", "abbe53a39734ef4aba061fca54e30c6b4639d3e1f59653f0da37a0003de148c7", "c845db36ba616912074c5b1ee897f8e0124df269468f25e4fe21fe72f6edd7a9", "c9399267c926a4e7c418baa5cbe91c7d1cf362d505a1ef898fde44a07c9dd8a5"] traitlets = ["9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835", "c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9"] +typing = ["38566c558a0a94d6531012c8e917b1b8518a41e418f7f15f00e129cc80162ad3", "53765ec4f83a2b720214727e319607879fec4acde22c4fbb54fa2604e79e44ce", "84698954b4e6719e912ef9a42a2431407fe3755590831699debda6fba92aac55"] urllib3 = ["b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1", "dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"] wcwidth = ["3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", "f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"] zipp = ["4970c3758f4e89a7857a973b1e2a5d75bcdc47794442f2e2dd4fe8e0466e809a", "8a5712cfd3bb4248015eb3b0b3c54a5f6ee3f2425963ef2a0125b8bc40aafaec"] diff --git a/pyproject.toml b/pyproject.toml index 6b82a40..e80d469 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ flake8 = "^3.7" black = {version = "^18.3-alpha.0",allows-prereleases = true} requests-mock = "^1.6" pytest-cov = "^2.7" +mkdocs = "^1.0" +pydoc-markdown = { git = "https://github.com/NiklasRosenstein/pydoc-markdown.git", branch = "develop", category="dev" } +mkdocs-cinder = "^0.17.0" [build-system] requires = ["poetry>=0.12"] diff --git a/tests/data/projects.py b/tests/data/projects.py index baaa30b..acd81ff 100644 --- a/tests/data/projects.py +++ b/tests/data/projects.py @@ -1,6 +1,6 @@ PROJECTS_REPR = [ ( - "Project(project_id='c9dc56bf-37b9-453b-8f95-2845ce8908e3', name='test2', " + "Project(name='test2', project_id='c9dc56bf-37b9-453b-8f95-2845ce8908e3', " "status='closed', path='/opt/gns3/projects/c9dc56bf-37b9-453b-8f95-" "2845ce8908e3', filename='test2.gns3', auto_start=False, auto_close=False, " "auto_open=False, drawing_grid_size=25, grid_size=75, scene_height=1000, " @@ -9,7 +9,7 @@ "drawings': 0, 'links': 9, 'nodes': 10, 'snapshots': 0})" ), ( - "Project(project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe', name='API_TEST', " + "Project(name='API_TEST', project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe', " "status='opened', path='/opt/gns3/projects/4b21dfb3-675a-4efa-8613-" "2f7fb32e76fe', filename='test_api1.gns3', auto_start=True, auto_close=False, " "auto_open=False, drawing_grid_size=25, grid_size=75, scene_height=1000, " From ecc721bd5895d27edf99cf4e96184c799e29b2ac Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 17 Aug 2019 15:19:00 +0100 Subject: [PATCH 15/26] Pushing docs --- gns3fy/gns3fy.py | 1505 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1505 insertions(+) create mode 100644 gns3fy/gns3fy.py diff --git a/gns3fy/gns3fy.py b/gns3fy/gns3fy.py new file mode 100644 index 0000000..9aea1fb --- /dev/null +++ b/gns3fy/gns3fy.py @@ -0,0 +1,1505 @@ +import time +import requests +from urllib.parse import urlencode, urlparse +from requests import ConnectionError, ConnectTimeout, HTTPError +from dataclasses import field +from typing import Optional, Any, Dict, List +from pydantic import validator +from pydantic.dataclasses import dataclass + + +class Config: + validate_assignment = True + # TODO: Not really working.. Need to investigate more and possibly open an issue + extra = "ignore" + + +NODE_TYPES = [ + "cloud", + "nat", + "ethernet_hub", + "ethernet_switch", + "frame_relay_switch", + "atm_switch", + "docker", + "dynamips", + "vpcs", + "traceng", + "virtualbox", + "vmware", + "iou", + "qemu", +] + +CONSOLE_TYPES = [ + "vnc", + "telnet", + "http", + "https", + "spice", + "spice+agent", + "none", + "null", +] + +LINK_TYPES = ["ethernet", "serial"] + + +class Gns3Connector: + """ + Connector to be use for interaction against GNS3 server controller API. + + **Attributes:** + + - `url` (str): URL of the GNS3 server (**required**) + - `user` (str): User used for authentication + - `cred` (str): Password used for authentication + - `verify` (bool): Whether or not to verify SSL + - `api_version` (int): GNS3 server REST API version + - `api_calls`: Counter of amount of `http_calls` has been performed + - `base_url`: url passed + api_version + - `session`: Requests Session object + + **Returns:** + + `Gns3Connector` instance + + **Example:** + + ```python + >>> server = Gns3Connector(url="http://
:3080") + >>> print(server.get_version()) + {'local': False, 'version': '2.2.0b4'} + ``` + """ + + def __init__(self, url=None, user=None, cred=None, verify=False, api_version=2): + requests.packages.urllib3.disable_warnings() + self.base_url = f"{url.strip('/')}/v{api_version}" + self.user = user + self.headers = {"Content-Type": "application/json"} + self.verify = verify + self.api_calls = 0 + + # Create session object + self.create_session() + + def create_session(self): + """ + Creates the requests.Session object and applies the necessary parameters + """ + self.session = requests.Session() + self.session.headers["Accept"] = "application/json" + if self.user: + self.session.auth = (self.user, self.cred) + + def http_call( + self, + method, + url, + data=None, + json_data=None, + headers=None, + verify=False, + params=None, + ): + """ + Performs the HTTP operation actioned + + **Required Attributes:** + + - `method` (enum): HTTP method to perform: get, post, put, delete, head, + patch (**required**) + - `url` (str): URL target (**required**) + - `data`: Dictionary or byte of request body data to attach to the Request + - `json_data`: Dictionary or List of dicts to be passed as JSON object/array + - `headers`: ictionary of HTTP Headers to attach to the Request + - `verify`: SSL Verification + - `params`: Dictionary or bytes to be sent in the query string for the Request + """ + try: + if data: + _response = getattr(self.session, method.lower())( + url, + data=urlencode(data), + # data=data, + headers=headers, + params=params, + verify=verify, + ) + + elif json_data: + _response = getattr(self.session, method.lower())( + url, json=json_data, headers=headers, params=params, verify=verify + ) + + else: + _response = getattr(self.session, method.lower())( + url, headers=headers, params=params, verify=verify + ) + + time.sleep(0.5) + self.api_calls += 1 + + except (ConnectTimeout, ConnectionError) as err: + print( + f"[ERROR] Connection Error, could not perform {method}" + f" operation: {err}" + ) + return False + except HTTPError as err: + print( + f"[ERROR] An unknown error has been encountered: {err} -" + f" {_response.text}" + ) + return False + + return _response + + @staticmethod + def error_checker(response_obj): + "Returns the error if found" + err = f"[ERROR][{response_obj.status_code}]: {response_obj.text}" + return err if 400 <= response_obj.status_code <= 599 else False + + def get_version(self): + "Returns the version information of GNS3 server" + return self.http_call("get", url=f"{self.base_url}/version").json() + + def get_projects(self): + "Returns the list of the projects on the server" + return self.http_call("get", url=f"{self.base_url}/projects").json() + + def get_project_by_name(self, name): + "Retrives a specific project" + _projects = self.http_call("get", url=f"{self.base_url}/projects").json() + try: + return [p for p in _projects if p["name"] == name][0] + except IndexError: + return None + + def get_project_by_id(self, id): + "Retrives a specific project by id" + return self.http_call("get", url=f"{self.base_url}/projects/{id}").json() + + def get_templates(self): + "Returns the templates defined on the server" + return self.http_call("get", url=f"{self.base_url}/templates").json() + + def get_template_by_name(self, name): + "Retrives a specific template searching by name" + _templates = self.http_call("get", url=f"{self.base_url}/templates").json() + try: + return [t for t in _templates if t["name"] == name][0] + except IndexError: + return None + + def get_template_by_id(self, id): + "Retrives a specific template by id" + return self.http_call("get", url=f"{self.base_url}/templates/{id}").json() + + def get_nodes(self, project_id): + "Retieves the nodes defined on the project" + return self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}/nodes" + ).json() + + def get_node_by_id(self, project_id, node_id): + """ + Returns the node by locating its ID + """ + _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}" + return self.http_call("get", _url).json() + + def get_links(self, project_id): + "Retrieves the links defined in the project" + return self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}/links" + ).json() + + def get_link_by_id(self, project_id, link_id): + """ + Returns the link by locating its ID + """ + _url = f"{self.base_url}/projects/{project_id}/links/{link_id}" + return self.http_call("get", _url).json() + + def create_project(self, **kwargs): + """ + Pass a dictionary type object with the project parameters to be created. + Parameter `name` is mandatory. Returns project + """ + _url = f"{self.base_url}/projects" + if "name" not in kwargs: + raise ValueError("Parameter 'name' is mandatory") + return self.http_call("post", _url, json_data=kwargs).json() + + def delete_project(self, project_id): + """ + Deletes a project from server + """ + _url = f"{self.base_url}/projects/{project_id}" + self.http_call("delete", _url) + return + + +@dataclass(config=Config) +class Link: + """ + GNS3 Link API object. For more information visit: [Links Endpoint API information]( + http://api.gns3.net/en/2.2/api/v2/controller/link/projectsprojectidlinks.html) + + **Attributes:** + + - `link_id` (str): Link UUID (**required** to be set when using `get` method) + - `link_type` (enum): Possible values: ethernet, serial + - `project_id` (str): Project UUID (**required**) + - `connector` (object): `Gns3Connector` instance used for interaction (**required**) + - `suspend` (bool): Suspend the link + - `nodes` (list): List of the Nodes and ports (**required** when using `create` + method, see Features/Link creation on the docs) + - `filters` (dict): Packet filter. This allow to simulate latency and errors + - `capturing` (bool): Read only property. True if a capture running on the link + - `capture_file_path` (str): Read only property. The full path of the capture file + if capture is running + - `capture_file_name` (str): Read only property. The name of the capture file if + capture is running + + **Returns:** + + `Link` instance + + **Example:** + + ```python + >>> link = Link(project_id=, link_id= connector=) + >>> link.get() + >>> print(link.link_type) + 'ethernet' + ``` + """ + + link_id: Optional[str] = None + link_type: Optional[str] = None + project_id: Optional[str] = None + suspend: Optional[bool] = None + nodes: Optional[List[Any]] = None + filters: Optional[Any] = None + capturing: Optional[bool] = None + capture_file_path: Optional[str] = None + capture_file_name: Optional[str] = None + capture_compute_id: Optional[str] = None + + connector: Optional[Any] = field(default=None, repr=False) + + @validator("link_type") + def _valid_node_type(cls, value): + if value not in LINK_TYPES: + raise ValueError(f"Not a valid link_type - {value}") + return value + + def _update(self, data_dict): + for k, v in data_dict.items(): + if k in self.__dict__.keys(): + self.__setattr__(k, v) + + def _verify_before_action(self): + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit project_id") + if not self.link_id: + raise ValueError("Need to submit link_id") + + def get(self): + """ + Retrieves the information from the link endpoint. + + **Required Attributes:** + + - `project_id` + - `connector` + - `link_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/links/{self.link_id}" + ) + _response = self.connector.http_call("get", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + self._update(_response.json()) + + def delete(self): + """ + Deletes a link endpoint from the project. It sets to `None` the attributes + `link_id` when executed sucessfully + + **Required Attributes:** + + - `project_id` + - `connector` + - `link_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/links/{self.link_id}" + ) + + _response = self.connector.http_call("delete", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + self.project_id = None + self.link_id = None + + def create(self): + """ + Creates a link endpoint + + **Required Attributes:** + + - `project_id` + - `connector` + - `nodes` + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit project_id") + + _url = f"{self.connector.base_url}/projects/{self.project_id}/links" + + data = { + k: v + for k, v in self.__dict__.items() + if k not in ("connector", "__initialised__") + if v is not None + } + + _response = self.connector.http_call("post", _url, json_data=data) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Now update it + self._update(_response.json()) + + +@dataclass(config=Config) +class Node: + """ + GNS3 Node API object. For more information visit: [Node Endpoint API information]( + http://api.gns3.net/en/2.2/api/v2/controller/node/projectsprojectidnodes.html) + + **Attributes:** + + - `name` (str): Node name (**required** when using `create` method) + - `project_id` (str): Project UUID (**required**) + - `node_id` (str): Node UUID (**required** when using `get` method) + - `compute_id` (str): Compute identifier (**required**, default=local) + - `node_type` (enum): frame_relay_switch, atm_switch, docker, dynamips, vpcs, + traceng, virtualbox, vmware, iou, qemu (**required** when using `create` method) + - `connector` (object): `Gns3Connector` instance used for interaction (**required**) + - `template_id`: Template UUID from the which the node is from. + - `template`: Template name from the which the node is from. + - `node_directory` (str): Working directory of the node. Read only + - `status` (enum): Possible values: stopped, started, suspended + - `ports` (list): List of node ports, READ only + - `port_name_format` (str): Formating for port name {0} will be replace by port + number + - `port_segment_size` (int): Size of the port segment + - `first_port_name` (str): Name of the first port + - `properties` (dict): Properties specific to an emulator + - `locked` (bool): Whether the element locked or not + - `label` (dict): TBC + - `console` (int): Console TCP port + - `console_host` (str): Console host + - `console_auto_start` (bool): Automatically start the console when the node has + started + - `command_line` (str): Command line use to start the node + - `custom_adapters` (list): TBC + - `height` (int): Height of the node, READ only + - `width` (int): Width of the node, READ only + - `symbol` (str): Symbol of the node + - `x` (int): X position of the node + - `y` (int): Y position of the node + - `z (int): Z position of the node + + **Returns:** + + `Node` instance + + **Example:** + + ```python + >>> alpine = Node(name="alpine1", node_type="docker", template="alpine", + project_id=, connector=) + >>> alpine.create() + >>> print(alpine.node_id) + 'SOME-UUID-GENERATED' + ``` + """ + + name: Optional[str] = None + project_id: Optional[str] = None + node_id: Optional[str] = None + compute_id: str = "local" + node_type: Optional[str] = None + node_directory: Optional[str] = None + status: Optional[str] = None + ports: Optional[List] = None + port_name_format: Optional[str] = None + port_segment_size: Optional[int] = None + first_port_name: Optional[str] = None + locked: Optional[bool] = None + label: Optional[Any] = None + console: Optional[str] = None + console_host: Optional[str] = None + console_type: Optional[str] = None + console_auto_start: Optional[bool] = None + command_line: Optional[str] = None + custom_adapters: Optional[List[Any]] = None + height: Optional[int] = None + width: Optional[int] = None + symbol: Optional[str] = None + x: Optional[int] = None + y: Optional[int] = None + z: Optional[int] = None + template_id: Optional[str] = None + properties: Dict = field(default_factory=dict) + + template: Optional[str] = None + links: List[Link] = field(default_factory=list, repr=False) + connector: Optional[Any] = field(default=None, repr=False) + + @validator("node_type") + def _valid_node_type(cls, value): + if value not in NODE_TYPES: + raise ValueError(f"Not a valid node_type - {value}") + return value + + @validator("console_type") + def _valid_console_type(cls, value): + if value not in CONSOLE_TYPES: + raise ValueError(f"Not a valid console_type - {value}") + return value + + @validator("status") + def _valid_status(cls, value): + if value not in ("stopped", "started", "suspended"): + raise ValueError(f"Not a valid status - {value}") + return value + + def _update(self, data_dict): + for k, v in data_dict.items(): + if k in self.__dict__.keys(): + self.__setattr__(k, v) + + def _verify_before_action(self): + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit project_id") + if not self.node_id: + if not self.name: + raise ValueError("Need to either submit node_id or name") + + # Try to retrieve the node_id + _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" + _response = self.connector.http_call("get", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + extracted = [node for node in _response.json() + if node["name"] == self.name] + if len(extracted) > 1: + raise ValueError( + "Multiple nodes found with same name. Need to submit node_id" + ) + self.node_id = extracted[0]["node_id"] + + def get(self, get_links=True): + """ + Retrieves the node information. When `get_links` is `True` it also retrieves the + links respective to the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes/{self.node_id}" + ) + _response = self.connector.http_call("get", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + self._update(_response.json()) + + if get_links: + self.get_links() + + def get_links(self): + """ + Retrieves the links of the respective node. They will be saved at the `links` + attribute + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes" + f"/{self.node_id}/links" + ) + _response = self.connector.http_call("get", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Create the Link array but cleanup cache if there is one + if self.links: + self.links = [] + for _link in _response.json(): + self.links.append(Link(connector=self.connector, **_link)) + + def start(self): + """ + Starts the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes" + f"/{self.node_id}/start" + ) + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object or perform get if change was not reflected + if _response.json().get("status") == "started": + self._update(_response.json()) + else: + self.get() + + def stop(self): + """ + Stops the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes" + f"/{self.node_id}/stop" + ) + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object or perform get if change was not reflected + if _response.json().get("status") == "stopped": + self._update(_response.json()) + else: + self.get() + + def reload(self): + """ + Reloads the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes" + f"/{self.node_id}/reload" + ) + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object or perform get if change was not reflected + if _response.json().get("status") == "started": + self._update(_response.json()) + else: + self.get() + + def suspend(self): + """ + Suspends the node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes" + f"/{self.node_id}/suspend" + ) + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object or perform get if change was not reflected + if _response.json().get("status") == "suspended": + self._update(_response.json()) + else: + self.get() + + def create(self, extra_properties={}): + """ + Creates a node. + + By default it will fetch the nodes properties for creation based on the + `template` or `template_id` attribute supplied. This can be overriden/updated + by sending a dictionary of the properties under `extra_properties`. + + **Required Attributes:** + + - `project_id` + - `connector` + - `compute_id`: Defaults to "local" + - `name` + - `node_type` + - `template` or `template_id` + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit 'project_id'") + if not self.compute_id: + raise ValueError("Need to submit 'compute_id'") + if not self.name: + raise ValueError("Need to submit 'name'") + if not self.node_type: + raise ValueError("Need to submit 'node_type'") + if self.node_id: + raise ValueError("Node already created") + + _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" + + data = { + k: v + for k, v in self.__dict__.items() + if k + not in ("project_id", "template", "links", "connector", "__initialised__") + if v is not None + } + + # Fetch template for properties + if self.template_id: + _properties = self.connector.get_template_by_id(self.template_id) + elif self.template: + _properties = self.connector.get_template_by_name(self.template) + else: + raise ValueError("You must provide `template` or `template_id`") + + # Delete not needed fields + for _field in ( + "compute_id", + "default_name_format", + "template_type", + "template_id", + "builtin", + "name", # Needs to be deleted because it overrides the name of host + ): + try: + _properties.pop(_field) + except KeyError: + continue + + # Override/Merge extra properties + if extra_properties: + _properties.update(**extra_properties) + data.update(properties=_properties) + + _response = self.connector.http_call("post", _url, json_data=data) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + self._update(_response.json()) + + def delete(self): + """ + Deletes the node from the project. It sets to `None` the attributes `node_id` + and `name` when executed successfully + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/nodes/{self.node_id}" + ) + + _response = self.connector.http_call("delete", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + self.project_id = None + self.node_id = None + self.name = None + + +@dataclass(config=Config) +class Project: + """ + GNS3 Project API object. For more information visit: [Project Endpoint API + information](http://api.gns3.net/en/2.2/api/v2/controller/project/projects.html) + + **Attributes:** + + - `name`: Project name (**required** when using `create` method) + - `project_id` (str): Project UUID (**required**) + - `connector` (object): `Gns3Connector` instance used for interaction (**required**) + - `status` (enum): Possible values: opened, closed + - `path` (str): Path of the project on the server + - `filename` (str): Project filename + - `auto_start` (bool): Project start when opened + - `auto_close` (bool): Project auto close when client cut off the notifications feed + - `auto_open` (bool): Project open when GNS3 start + - `drawing_grid_size` (int): Grid size for the drawing area for drawings + - `grid_size` (int): Grid size for the drawing area for nodes + - `scene_height` (int): Height of the drawing area + - `scene_width` (int): Width of the drawing area + - `show_grid` (bool): Show the grid on the drawing area + - `show_interface_labels` (bool): Show interface labels on the drawing area + - `show_layers` (bool): Show layers on the drawing area + - `snap_to_grid` (bool): Snap to grid on the drawing area + - `supplier` (dict): Supplier of the project + - `variables` (list): Variables required to run the project + - `zoom` (int): Zoom of the drawing area + - `stats` (dict): Project stats + - `nodes` (list): List of `Node` instances present on the project + - `links` (list): List of `Link` instances present on the project + + **Returns:** + + `Project` instance + + **Example:** + + ```python + >>> lab = Project(name="lab", connector=) + >>> lab.create() + >>> print(lab.status) + 'opened' + ``` + """ + + name: Optional[str] = None + project_id: Optional[str] = None + status: Optional[str] = None + path: Optional[str] = None + filename: Optional[str] = None + auto_start: Optional[bool] = None + auto_close: Optional[bool] = None + auto_open: Optional[bool] = None + drawing_grid_size: Optional[int] = None + grid_size: Optional[int] = None + scene_height: Optional[int] = None + scene_width: Optional[int] = None + show_grid: Optional[bool] = None + show_interface_labels: Optional[bool] = None + show_layers: Optional[bool] = None + snap_to_grid: Optional[bool] = None + supplier: Optional[Any] = None + variables: Optional[List] = None + zoom: Optional[int] = None + + stats: Optional[Dict[str, Any]] = None + nodes: List[Node] = field(default_factory=list, repr=False) + links: List[Link] = field(default_factory=list, repr=False) + connector: Optional[Any] = field(default=None, repr=False) + + @validator("status") + def _valid_status(cls, value): + if value != "opened" and value != "closed": + raise ValueError("status must be 'opened' or 'closed'") + return value + + def _update(self, data_dict): + for k, v in data_dict.items(): + if k in self.__dict__.keys(): + self.__setattr__(k, v) + + def _verify_before_action(self): + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + if not self.project_id: + raise ValueError("Need to submit project_id") + + def get(self, get_links=True, get_nodes=True, get_stats=True): + """ + Retrieves the projects information. + + - `get_links`: When true it also queries for the links inside the project + - `get_nodes`: When true it also queries for the nodes inside the project + - `get_stats`: When true it also queries for the stats inside the project + + **Required Attributes:** + + - `connector` + - `project_id` or `name` + """ + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + + # Get projects if no ID was provided by the name + if not self.project_id: + if not self.name: + raise ValueError("Need to submit either project_id or name") + _url = f"{self.connector.base_url}/projects" + # Get all projects and filter the respective project + _response = self.connector.http_call("get", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Filter the respective project + for _project in _response.json(): + if _project.get("name") == self.name: + self.project_id = _project.get("project_id") + + # Get project + _url = f"{self.connector.base_url}/projects/{self.project_id}" + _response = self.connector.http_call("get", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + self._update(_response.json()) + + if get_stats: + self.get_stats() + if get_nodes: + self.get_nodes() + if get_links: + self.get_links() + + def create(self): + """ + Creates the project. + + **Required Attributes:** + + - `name` + - `connector` + """ + if not self.name: + raise ValueError("Need to submit projects `name`") + if not self.connector: + raise ValueError("Gns3Connector not assigned under 'connector'") + + _url = f"{self.connector.base_url}/projects" + + data = { + k: v + for k, v in self.__dict__.items() + if k not in ("stats", "nodes", "links", "connector", "__initialised__") + if v is not None + } + + _response = self.connector.http_call("post", _url, json_data=data) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Now update it + self._update(_response.json()) + + def update(self, **kwargs): + """ + Updates the project instance by passing the keyword arguments of the attributes + you want to be updated + + Example: `lab.update(auto_close=True)` + + This will update the project `auto_close` attribute to `True` + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}" + + # TODO: Verify that the passed kwargs are supported ones + _response = self.connector.http_call("put", _url, json_data=kwargs) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + self._update(_response.json()) + + def delete(self): + """ + Deletes the project from the server. It sets to `None` the attributes + `project_id` and `name` when executed successfully + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}" + + _response = self.connector.http_call("delete", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + self.project_id = None + self.name = None + + def close(self): + """ + Closes the project on the server. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/close" + + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + self._update(_response.json()) + + def open(self): + """ + Opens the project on the server. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/open" + + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + self._update(_response.json()) + + def get_stats(self): + """ + Retrieve the stats of the project. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/stats" + + _response = self.connector.http_call("get", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + self.stats = _response.json() + + def get_nodes(self): + """ + Retrieve the nodes of the project. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" + + _response = self.connector.http_call("get", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Create the Nodes array but cleanup cache if there is one + if self.nodes: + self.nodes = [] + for _node in _response.json(): + _n = Node(connector=self.connector, **_node) + _n.project_id = self.project_id + self.nodes.append(_n) + + def get_links(self): + """ + Retrieve the links of the project. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/links" + + _response = self.connector.http_call("get", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Create the Nodes array but cleanup cache if there is one + if self.links: + self.links = [] + for _link in _response.json(): + _l = Link(connector=self.connector, **_link) + _l.project_id = self.project_id + self.links.append(_l) + + def start_nodes(self, poll_wait_time=5): + """ + Starts all the nodes inside the project. + + - `poll_wait_time` is used as a delay when performing the next query of the + nodes status. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/start" + + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + time.sleep(poll_wait_time) + self.get_nodes() + + def stop_nodes(self, poll_wait_time=5): + """ + Stops all the nodes inside the project. + + - `poll_wait_time` is used as a delay when performing the next query of the + nodes status. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/stop" + + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + time.sleep(poll_wait_time) + self.get_nodes() + + def reload_nodes(self, poll_wait_time=5): + """ + Reloads all the nodes inside the project. + + - `poll_wait_time` is used as a delay when performing the next query of the + nodes status. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/reload" + + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + time.sleep(poll_wait_time) + self.get_nodes() + + def suspend_nodes(self, poll_wait_time=5): + """ + Suspends all the nodes inside the project. + + - `poll_wait_time` is used as a delay when performing the next query of the + nodes status. + + **Required Attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/suspend" + + _response = self.connector.http_call("post", _url) + _err = Gns3Connector.error_checker(_response) + if _err: + raise ValueError(f"{_err}") + + # Update object + time.sleep(poll_wait_time) + self.get_nodes() + + def nodes_summary(self, is_print=True): + """ + Returns a summary of the nodes insode the project. If `is_print` is `False`, it + will return a list of tuples like: + + `[(node_name, node_status, node_console, node_id) ...]` + + **Required Attributes:** + + - `project_id` + - `connector` + """ + if not self.nodes: + self.get_nodes() + + _nodes_summary = [] + for _n in self.nodes: + if is_print: + print( + f"{_n.name}: {_n.status} -- Console: {_n.console} -- " + f"ID: {_n.node_id}" + ) + _nodes_summary.append((_n.name, _n.status, _n.console, _n.node_id)) + + return _nodes_summary if not is_print else None + + def nodes_inventory(self): + """ + Returns an inventory-style with the nodes of the project + + Example: + + `{ + "router01": { + "hostname": "127.0.0.1", + "name": "router01", + "console_port": 5077, + "type": "vEOS" + } + }` + + **Required Attributes:** + + - `project_id` + - `connector` + """ + + if not self.nodes: + self.get_nodes() + + _nodes_inventory = {} + _hostname = urlparse(self.connector.base_url).hostname + + for _n in self.nodes: + + _nodes_inventory.update({_n.name: {'hostname': _hostname, + 'name': _n.name, + 'console_port': _n.console, + 'type': _n.node_type, + }}) + + return _nodes_inventory + + def links_summary(self, is_print=True): + """ + Returns a summary of the links insode the project. If `is_print` is False, it + will return a list of tuples like: + + `[(node_a, port_a, node_b, port_b) ...]` + + **Required Attributes:** + + - `project_id` + - `connector` + """ + if not self.nodes: + self.get_nodes() + if not self.links: + self.get_links() + + _links_summary = [] + for _l in self.links: + if not _l.nodes: + continue + _side_a = _l.nodes[0] + _side_b = _l.nodes[1] + _node_a = [x for x in self.nodes if x.node_id == + _side_a["node_id"]][0] + _port_a = [ + x["name"] + for x in _node_a.ports + if x["port_number"] == _side_a["port_number"] + and x["adapter_number"] == _side_a["adapter_number"] + ][0] + _node_b = [x for x in self.nodes if x.node_id == + _side_b["node_id"]][0] + _port_b = [ + x["name"] + for x in _node_b.ports + if x["port_number"] == _side_b["port_number"] + and x["adapter_number"] == _side_b["adapter_number"] + ][0] + endpoint_a = f"{_node_a.name}: {_port_a}" + endpoint_b = f"{_node_b.name}: {_port_b}" + if is_print: + print(f"{endpoint_a} ---- {endpoint_b}") + _links_summary.append( + (_node_a.name, _port_a, _node_b.name, _port_b)) + + return _links_summary if not is_print else None + + def _search_node(self, key, value): + "Performs a search based on a key and value" + # Retrive nodes if neccesary + if not self.nodes: + self.get_nodes() + + try: + return [_p for _p in self.nodes if getattr(_p, key) == value][0] + except IndexError: + return None + + def get_node(self, name=None, node_id=None): + """ + Returns the Node object by searching for the `name` or the `node_id`. + + **Required Attributes:** + + - `project_id` + - `connector` + - `name` or `node_id` + + **NOTE:** Run method `get_nodes()` manually to refresh list of nodes if + necessary + """ + if node_id: + return self._search_node(key="node_id", value=node_id) + elif name: + return self._search_node(key="name", value=name) + else: + raise ValueError("name or node_ide must be provided") + + def _search_link(self, key, value): + "Performs a search based on a key and value" + # Retrive links if neccesary + if not self.links: + self.get_links() + + try: + return [_p for _p in self.links if getattr(_p, key) == value][0] + except IndexError: + return None + + def get_link(self, link_id): + """ + Returns the Link object by locating its ID + + **Required Attributes:** + + - `project_id` + - `connector` + - `link_id` + + **NOTE:** Run method `get_links()` manually to refresh list of links if + necessary + """ + if link_id: + return self._search_node(key="link_id", value=link_id) + else: + raise ValueError("name or node_ide must be provided") + + def create_node(self, name=None, **kwargs): + """ + Creates a node. + + **Required Attributes:** + + - `project_id` + - `connector` + - `name` + - `node_type` + - `compute_id`: Defaults to "local" + - `template` or `template_id` + """ + if not self.nodes: + self.get_nodes() + + # Even though GNS3 allow same name to be pushed because it automatically + # generates a new name if matches an exising node, here is verified beforehand + # and forces developer to create new name. + _matches = [(_n.name, _n.node_id) for _n in self.nodes if name == _n.name] + if _matches: + raise ValueError( + f"Node with equal name found: {_matches[0][0]} - ID: {_matches[0][-1]}" + ) + + _node = Node( + project_id=self.project_id, connector=self.connector, name=name, **kwargs + ) + _node.create() + self.nodes.append(_node) + print( + f"Created: {_node.name} -- Type: {_node.node_type} -- " + f"Console: {_node.console}" + ) + + def create_link(self, node_a, port_a, node_b, port_b): + """ + Creates a link. + + **Required Attributes:** + + - `project_id` + - `connector` + - `node_a`: Node name of the A side + - `port_a`: Port name of the A side (must match the `name` attribute of the + port) + - `node_b`: Node name of the B side + - `port_b`: Port name of the B side (must match the `name` attribute of the + port) + """ + if not self.nodes: + self.get_nodes() + if not self.links: + self.get_links() + + _node_a = self.get_node(name=node_a) + if not _node_a: + raise ValueError(f"node_a: {node_a} not found") + try: + _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] + except IndexError: + raise ValueError(f"port_a: {port_a} - not found") + + _node_b = self.get_node(name=node_b) + if not _node_b: + raise ValueError(f"node_b: {node_b} not found") + try: + _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] + except IndexError: + raise ValueError(f"port_b: {port_b} - not found") + + _matches = [] + for _l in self.links: + if ( + _l.nodes[0]["node_id"] == _node_a.node_id + and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] + and _l.nodes[0]["port_number"] == _port_a["port_number"] + ): + _matches.append(_l) + elif ( + _l.nodes[1]["node_id"] == _node_b.node_id + and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"] + and _l.nodes[1]["port_number"] == _port_b["port_number"] + ): + _matches.append(_l) + if _matches: + raise ValueError(f"At least one port is used, ID: {_matches[0].link_id}") + + # Now create the link! + _link = Link( + project_id=self.project_id, + connector=self.connector, + nodes=[ + dict( + node_id=_node_a.node_id, + adapter_number=_port_a["adapter_number"], + port_number=_port_a["port_number"], + ), + dict( + node_id=_node_b.node_id, + adapter_number=_port_b["adapter_number"], + port_number=_port_b["port_number"], + ), + ], + ) + + _link.create() + self.links.append(_link) + print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}") From 8c18cb4736f7354a2a9849a67f14b95f3df106c6 Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 17 Aug 2019 15:26:06 +0100 Subject: [PATCH 16/26] Fixing changelog link --- docs/content/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/index.md b/docs/content/index.md index 1fe4d31..58b4dc5 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -54,4 +54,4 @@ for node in lab.nodes: ## Release Notes -Please see the [Release Notes](changelog.md) for details +Please see the [Release Notes](about/changelog.md) for details From b18c76eeb72cb9038ca7c76dc2fab7ebb3a3f315 Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 17 Aug 2019 15:29:09 +0100 Subject: [PATCH 17/26] Adding documentation link to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d51fc48..54166bb 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Python wrapper around [GNS3 Server API](http://api.gns3.net/en/2.2/index.html). Its main objective is to interact with the GNS3 server in a programatic way, so it can be integrated with the likes of Ansible, docker and scripts. +Check out the [Documentation](https://davidban77.github.io/gns3fy/) for further details. + ## Install ``` From d19b5bd63db1979132f943b7e7ade2392fb6f151 Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 17 Aug 2019 15:50:19 +0100 Subject: [PATCH 18/26] Ignoring circleci build for gh-pages branch --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 864ba9e..32ddd38 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,6 +3,9 @@ orbs: codecov: codecov/codecov@1.0.5 jobs: build: + branches: + ignore: + - gh-pages docker: - image: circleci/python:3.7 steps: From e8a10351ccc6664f3117fd1b33b0b5f5a38efabe Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 17 Aug 2019 15:52:58 +0100 Subject: [PATCH 19/26] Adding initial workflow circleci --- .circleci/config.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 32ddd38..40d20a1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,6 +1,12 @@ version: 2.1 +workflows: + main: + jobs: + - build + orbs: codecov: codecov/codecov@1.0.5 + jobs: build: branches: From 3af66ac2e0e598be25e26a5dcfe03cf800cdcbb0 Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 17 Aug 2019 15:56:55 +0100 Subject: [PATCH 20/26] Fixing workflow for circleci --- .circleci/config.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 40d20a1..2737823 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,16 +2,18 @@ version: 2.1 workflows: main: jobs: - - build + - build: + filters: + branches: + ignore: + - gh-pages + orbs: codecov: codecov/codecov@1.0.5 jobs: build: - branches: - ignore: - - gh-pages docker: - image: circleci/python:3.7 steps: From e52ae6253b673eb23528a4fdc8858eef1940c1a3 Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 17 Aug 2019 16:01:25 +0100 Subject: [PATCH 21/26] Fixing workflow for circleci --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2737823..bcc05d1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,7 +6,7 @@ workflows: filters: branches: ignore: - - gh-pages + - "gh-pages" orbs: From 85338fd025c9cda01981972fb7b5a7e0168efced Mon Sep 17 00:00:00 2001 From: David Flores Date: Sat, 17 Aug 2019 21:32:34 +0100 Subject: [PATCH 22/26] Added black into the circleci build phase and performed some formatting --- .circleci/config.yml | 4 ++++ README.md | 4 +++- docs/Makefile | 2 ++ docs/content/about/changelog.md | 4 ++++ gns3fy/gns3fy.py | 29 +++++++++++++++-------------- tests/test_api.py | 6 +----- 6 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 docs/Makefile diff --git a/.circleci/config.yml b/.circleci/config.yml index bcc05d1..ac3cca5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,6 +33,10 @@ jobs: name: Run flake8 command: | poetry run flake8 . + - run: + name: Run black formatting check + command: | + poetry run black --diff --check . - run: name: Running tests command: | diff --git a/README.md b/README.md index 54166bb..6362583 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ Python wrapper around [GNS3 Server API](http://api.gns3.net/en/2.2/index.html). Its main objective is to interact with the GNS3 server in a programatic way, so it can be integrated with the likes of Ansible, docker and scripts. -Check out the [Documentation](https://davidban77.github.io/gns3fy/) for further details. +## Documentation + +Check out the [Documentation](https://davidban77.github.io/gns3fy/) to explore use cases, and the API Reference ## Install diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..31902ba --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,2 @@ +docs-publish: + mkdocs gh-deploy -m "[ci skip]" diff --git a/docs/content/about/changelog.md b/docs/content/about/changelog.md index 70a1376..d1b38aa 100644 --- a/docs/content/about/changelog.md +++ b/docs/content/about/changelog.md @@ -9,6 +9,10 @@ - Added some commodity methods like `nodes_summary` - Created the `docs` - Improved the tests and coverage +- Added CircleCI with the following checks: + - flake8 + - black formatting + - pytest ## 0.1.1 diff --git a/gns3fy/gns3fy.py b/gns3fy/gns3fy.py index 9aea1fb..d85a500 100644 --- a/gns3fy/gns3fy.py +++ b/gns3fy/gns3fy.py @@ -519,8 +519,7 @@ def _verify_before_action(self): if _err: raise ValueError(f"{_err}") - extracted = [node for node in _response.json() - if node["name"] == self.name] + extracted = [node for node in _response.json() if node["name"] == self.name] if len(extracted) > 1: raise ValueError( "Multiple nodes found with same name. Need to submit node_id" @@ -1251,7 +1250,7 @@ def nodes_summary(self, is_print=True): def nodes_inventory(self): """ - Returns an inventory-style with the nodes of the project + Returns an inventory-style dictionary of the nodes Example: @@ -1278,11 +1277,16 @@ def nodes_inventory(self): for _n in self.nodes: - _nodes_inventory.update({_n.name: {'hostname': _hostname, - 'name': _n.name, - 'console_port': _n.console, - 'type': _n.node_type, - }}) + _nodes_inventory.update( + { + _n.name: { + "hostname": _hostname, + "name": _n.name, + "console_port": _n.console, + "type": _n.node_type, + } + } + ) return _nodes_inventory @@ -1309,16 +1313,14 @@ def links_summary(self, is_print=True): continue _side_a = _l.nodes[0] _side_b = _l.nodes[1] - _node_a = [x for x in self.nodes if x.node_id == - _side_a["node_id"]][0] + _node_a = [x for x in self.nodes if x.node_id == _side_a["node_id"]][0] _port_a = [ x["name"] for x in _node_a.ports if x["port_number"] == _side_a["port_number"] and x["adapter_number"] == _side_a["adapter_number"] ][0] - _node_b = [x for x in self.nodes if x.node_id == - _side_b["node_id"]][0] + _node_b = [x for x in self.nodes if x.node_id == _side_b["node_id"]][0] _port_b = [ x["name"] for x in _node_b.ports @@ -1329,8 +1331,7 @@ def links_summary(self, is_print=True): endpoint_b = f"{_node_b.name}: {_port_b}" if is_print: print(f"{endpoint_a} ---- {endpoint_b}") - _links_summary.append( - (_node_a.name, _port_a, _node_b.name, _port_b)) + _links_summary.append((_node_a.name, _port_a, _node_b.name, _port_b)) return _links_summary if not is_print else None diff --git a/tests/test_api.py b/tests/test_api.py index e61520d..4c2e8a8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -548,11 +548,7 @@ def test_create_with_invalid_parameter_type(self, gns3_server): ) def test_create_with_incomplete_parameters(self, gns3_server): - node = Node( - name="alpine-1", - connector=gns3_server, - project_id=CPROJECT["id"], - ) + node = Node(name="alpine-1", connector=gns3_server, project_id=CPROJECT["id"]) with pytest.raises(ValueError, match="Need to submit 'node_type'"): node.create() From ff6ace3c3e01ea1526887ef414c1f0fbc8954861 Mon Sep 17 00:00:00 2001 From: David Flores Date: Sun, 18 Aug 2019 21:33:24 +0100 Subject: [PATCH 23/26] Improving test coverage --- gns3fy/gns3fy.py | 169 +++++++++---- tests/data/nodes.json | 3 +- tests/data/nodes.py | 12 +- tests/test_api.py | 556 ++++++++++++++++++++++++++++++++++-------- 4 files changed, 578 insertions(+), 162 deletions(-) diff --git a/gns3fy/gns3fy.py b/gns3fy/gns3fy.py index d85a500..0a6ace0 100644 --- a/gns3fy/gns3fy.py +++ b/gns3fy/gns3fy.py @@ -163,63 +163,121 @@ def error_checker(response_obj): return err if 400 <= response_obj.status_code <= 599 else False def get_version(self): - "Returns the version information of GNS3 server" + """ + Returns the version information of GNS3 server + """ return self.http_call("get", url=f"{self.base_url}/version").json() def get_projects(self): - "Returns the list of the projects on the server" + """ + Returns the list of the projects on the server + """ return self.http_call("get", url=f"{self.base_url}/projects").json() - def get_project_by_name(self, name): - "Retrives a specific project" - _projects = self.http_call("get", url=f"{self.base_url}/projects").json() - try: - return [p for p in _projects if p["name"] == name][0] - except IndexError: - return None + def get_project(self, name=None, project_id=None): + """ + Retrieves a project from either a name or ID - def get_project_by_id(self, id): - "Retrives a specific project by id" - return self.http_call("get", url=f"{self.base_url}/projects/{id}").json() + **Required Attributes:** + + - `name` or `project_id` + """ + if project_id: + return self.http_call( + "get", url=f"{self.base_url}/projects/{project_id}" + ).json() + elif name: + try: + return next(p for p in self.get_projects() if p["name"] == name) + except StopIteration: + # Project not found + return None + else: + raise ValueError("Must provide either a name or project_id") + + # def get_project_by_name(self, name): + # "Retrives a specific project" + # _projects = self.http_call("get", url=f"{self.base_url}/projects").json() + # try: + # return [p for p in _projects if p["name"] == name][0] + # except IndexError: + # return None + + # def get_project_by_id(self, id): + # "Retrives a specific project by id" + # return self.http_call("get", url=f"{self.base_url}/projects/{id}").json() def get_templates(self): - "Returns the templates defined on the server" + """ + Returns the templates defined on the server. + """ return self.http_call("get", url=f"{self.base_url}/templates").json() - def get_template_by_name(self, name): - "Retrives a specific template searching by name" - _templates = self.http_call("get", url=f"{self.base_url}/templates").json() - try: - return [t for t in _templates if t["name"] == name][0] - except IndexError: - return None + def get_template(self, name=None, template_id=None): + """ + Retrieves a template from either a name or ID - def get_template_by_id(self, id): - "Retrives a specific template by id" - return self.http_call("get", url=f"{self.base_url}/templates/{id}").json() + **Required Attributes:** + + - `name` or `template_id` + """ + if template_id: + return self.http_call( + "get", url=f"{self.base_url}/templates/{template_id}" + ).json() + elif name: + try: + return next(t for t in self.get_templates() if t["name"] == name) + except StopIteration: + # Template name not found + return None + else: + raise ValueError("Must provide either a name or template_id") def get_nodes(self, project_id): - "Retieves the nodes defined on the project" + """ + Retieves the nodes defined on the project + + **Required Attributes:** + + - `project_id` + """ return self.http_call( "get", url=f"{self.base_url}/projects/{project_id}/nodes" ).json() - def get_node_by_id(self, project_id, node_id): + def get_node(self, project_id, node_id): """ - Returns the node by locating its ID + Returns the node by locating its ID. + + **Required Attributes:** + + - `project_id` + - `node_id` """ _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}" return self.http_call("get", _url).json() def get_links(self, project_id): - "Retrieves the links defined in the project" + """ + Retrieves the links defined in the project. + + **Required Attributes:** + + - `project_id` + """ return self.http_call( "get", url=f"{self.base_url}/projects/{project_id}/links" ).json() - def get_link_by_id(self, project_id, link_id): + def get_link(self, project_id, link_id): """ - Returns the link by locating its ID + Returns the link by locating its ID. + + **Required Attributes:** + + - `project_id` + - `link_id` """ _url = f"{self.base_url}/projects/{project_id}/links/{link_id}" return self.http_call("get", _url).json() @@ -227,7 +285,14 @@ def get_link_by_id(self, project_id, link_id): def create_project(self, **kwargs): """ Pass a dictionary type object with the project parameters to be created. - Parameter `name` is mandatory. Returns project + + **Required Attributes:** + + - `name` + + **Returns** + + JSON project information """ _url = f"{self.base_url}/projects" if "name" not in kwargs: @@ -236,7 +301,11 @@ def create_project(self, **kwargs): def delete_project(self, project_id): """ - Deletes a project from server + Deletes a project from server. + + **Required Attributes:** + + - `project_id` """ _url = f"{self.base_url}/projects/{project_id}" self.http_call("delete", _url) @@ -461,7 +530,7 @@ class Node: first_port_name: Optional[str] = None locked: Optional[bool] = None label: Optional[Any] = None - console: Optional[str] = None + console: Optional[int] = None console_host: Optional[str] = None console_type: Optional[str] = None console_auto_start: Optional[bool] = None @@ -709,13 +778,11 @@ def create(self, extra_properties={}): if not self.connector: raise ValueError("Gns3Connector not assigned under 'connector'") if not self.project_id: - raise ValueError("Need to submit 'project_id'") - if not self.compute_id: - raise ValueError("Need to submit 'compute_id'") + raise ValueError("Need to submit project_id") if not self.name: - raise ValueError("Need to submit 'name'") + raise ValueError("Need to submit name") if not self.node_type: - raise ValueError("Need to submit 'node_type'") + raise ValueError("Need to submit node_type") if self.node_id: raise ValueError("Node already created") @@ -731,11 +798,11 @@ def create(self, extra_properties={}): # Fetch template for properties if self.template_id: - _properties = self.connector.get_template_by_id(self.template_id) + _properties = self.connector.get_template(template_id=self.template_id) elif self.template: - _properties = self.connector.get_template_by_name(self.template) + _properties = self.connector.get_template(name=self.template) else: - raise ValueError("You must provide `template` or `template_id`") + raise ValueError("You must provide template or template_id") # Delete not needed fields for _field in ( @@ -864,7 +931,7 @@ class Project: @validator("status") def _valid_status(cls, value): if value != "opened" and value != "closed": - raise ValueError("status must be 'opened' or 'closed'") + raise ValueError("status must be opened or closed") return value def _update(self, data_dict): @@ -937,7 +1004,7 @@ def create(self): - `connector` """ if not self.name: - raise ValueError("Need to submit projects `name`") + raise ValueError("Need to submit project name") if not self.connector: raise ValueError("Gns3Connector not assigned under 'connector'") @@ -1373,8 +1440,8 @@ def _search_link(self, key, value): self.get_links() try: - return [_p for _p in self.links if getattr(_p, key) == value][0] - except IndexError: + return next(_p for _p in self.links if getattr(_p, key) == value) + except StopIteration: return None def get_link(self, link_id): @@ -1390,10 +1457,7 @@ def get_link(self, link_id): **NOTE:** Run method `get_links()` manually to refresh list of links if necessary """ - if link_id: - return self._search_node(key="link_id", value=link_id) - else: - raise ValueError("name or node_ide must be provided") + return self._search_link(key="link_id", value=link_id) def create_node(self, name=None, **kwargs): """ @@ -1403,6 +1467,9 @@ def create_node(self, name=None, **kwargs): - `project_id` - `connector` + + **Required Keyword attributes:** + - `name` - `node_type` - `compute_id`: Defaults to "local" @@ -1456,7 +1523,7 @@ def create_link(self, node_a, port_a, node_b, port_b): try: _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] except IndexError: - raise ValueError(f"port_a: {port_a} - not found") + raise ValueError(f"port_a: {port_a} not found") _node_b = self.get_node(name=node_b) if not _node_b: @@ -1464,10 +1531,12 @@ def create_link(self, node_a, port_a, node_b, port_b): try: _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] except IndexError: - raise ValueError(f"port_b: {port_b} - not found") + raise ValueError(f"port_b: {port_b} not found") _matches = [] for _l in self.links: + if not _l.nodes: + continue if ( _l.nodes[0]["node_id"] == _node_a.node_id and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] diff --git a/tests/data/nodes.json b/tests/data/nodes.json index c3b9bd7..5d9cfd8 100644 --- a/tests/data/nodes.json +++ b/tests/data/nodes.json @@ -912,8 +912,7 @@ "width": 60, "x": 169, "y": -10, - "z": 1, - "template": null + "z": 1 }, { "command_line": null, diff --git a/tests/data/nodes.py b/tests/data/nodes.py index 7bc89a8..45643a6 100644 --- a/tests/data/nodes.py +++ b/tests/data/nodes.py @@ -22,7 +22,7 @@ "port_name_format='Ethernet{0}', port_segment_size=0, first_port_name=None, " "locked=False, label={'rotation': 0, 'style': 'font-family: TypeWriter;font-" "size: 10.0;font-weight: bold;fill: #000000;fill-opacity: 1.0;', 'text': '" - "Ethernetswitch-1', 'x': -13, 'y': -25}, console='5000', console_host='0.0.0." + "Ethernetswitch-1', 'x': -13, 'y': -25}, console=5000, console_host='0.0.0." "0', console_type='none', console_auto_start=False, command_line=None, " "custom_adapters=[], height=32, width=72, symbol=':/symbols/ethernet_switch." "svg', x=-11, y=-114, z=1, template_id='1966b864-93e7-32d5-965f-001384eec461', " @@ -83,7 +83,7 @@ "port_name_format='Ethernet{segment0}/{port0}', port_segment_size=4, " "first_port_name=None, locked=False, label={'rotation': 0, 'style': 'font-" "family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-" - "opacity: 1.0;', 'text': 'IOU1', 'x': 13, 'y': -25}, console='5001', " + "opacity: 1.0;', 'text': 'IOU1', 'x': 13, 'y': -25}, console=5001, " "console_host='0.0.0.0', console_type='telnet', console_auto_start=False, " "command_line='/opt/gns3/images/IOU/L3-ADVENTERPRISEK9-M-15.4-2T.bin 1', " "custom_adapters=[], height=60, width=60, symbol=':/symbols/affinity/circle/" @@ -141,7 +141,7 @@ "/3'}], port_name_format='Ethernet{segment0}/{port0}', port_segment_size=4, " "first_port_name=None, locked=False, label={'rotation': 0, 'style': 'font-" "family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-" - "opacity: 1.0;', 'text': 'IOU2', 'x': 13, 'y': -25}, console='5002', " + "opacity: 1.0;', 'text': 'IOU2', 'x': 13, 'y': -25}, console=5002, " "console_host='0.0.0.0', console_type='telnet', console_auto_start=False, " "command_line='/opt/gns3/images/IOU/L3-ADVENTERPRISEK9-M-15.4-2T.bin 2', " "custom_adapters=[], height=60, width=60, symbol=':/symbols/affinity/circle/" @@ -198,8 +198,8 @@ "short_name': 'e11'}], port_name_format='Ethernet{0}', port_segment_size=0, " "first_port_name='Management1', locked=False, label={'rotation': 0, 'style': '" "font-family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-" - "opacity: 1.0;', 'text': 'vEOS', 'x': -15, 'y': -25}, console='5003" - "', console_host='0.0.0.0', console_type='telnet', console_auto_start=False, " + "opacity: 1.0;', 'text': 'vEOS', 'x': -15, 'y': -25}, console=5003" + ", console_host='0.0.0.0', console_type='telnet', console_auto_start=False, " "command_line='/usr/bin/qemu-system-x86_64 -name vEOS -m 2048M -smp " "cpus=2 -enable-kvm -machine smm=off -boot order=c -drive file=/opt/gns3/" "projects/4b21dfb3-675a-4efa-8613-2f7fb32e76fe/project-files/qemu/" @@ -256,7 +256,7 @@ "short_name': 'eth1'}], port_name_format='Ethernet{0}', port_segment_size=0, " "first_port_name=None, locked=False, label={'rotation': 0, 'style': 'font-" "family: TypeWriter;font-size: 10.0;font-weight: bold;fill: #000000;fill-" - "opacity: 1.0;', 'text': 'alpine-1', 'x': 3, 'y': -25}, console='5005', " + "opacity: 1.0;', 'text': 'alpine-1', 'x': 3, 'y': -25}, console=5005, " "console_host='0.0.0.0', console_type='telnet', console_auto_start=False, " "command_line=None, custom_adapters=[], height=60, width=60, symbol=':/symbols" "/affinity/circle/gray/docker.svg', x=169, y=-10, z=1, " diff --git a/tests/test_api.py b/tests/test_api.py index 4c2e8a8..76c5150 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -67,6 +67,7 @@ def json_api_test_link(): def post_put_matcher(request): + "Creates the Responses for POST and PUT requests" resp = requests.Response() if request.method == "POST": if request.path_url.endswith("/projects"): @@ -96,12 +97,38 @@ def post_put_matcher(request): return resp elif request.path_url.endswith(f"/{CPROJECT['id']}/nodes"): _data = request.json() - if not any(x in _data for x in ("compute_id", "name", "node_id")): + if not any(x in _data for x in ("compute_id", "name", "node_type")): resp.status_code == 400 resp.json = lambda: dict(message="Invalid request", status=400) return resp resp.status_code = 201 - resp.json = json_api_test_node + _returned = json_api_test_node() + _returned.update( + name=_data["name"], + compute_id=_data["compute_id"], + node_type=_data["node_type"], + console=_data.get("console") + if _data["name"] == CNODE["name"] + else 5077, + node_id=CNODE["id"] + if _data["name"] == CNODE["name"] + else "NEW_NODE_ID", + ) + # For the case when properties have been overriden + if _data["properties"].get("console_http_port") == 8080: + _returned.update(properties=_data["properties"]) + resp.json = lambda: _returned + return resp + elif request.path_url.endswith( + f"/{CPROJECT['id']}/nodes/start" + ) or request.path_url.endswith(f"/{CPROJECT['id']}/nodes/reload"): + resp.status_code = 204 + return resp + elif request.path_url.endswith(f"/{CPROJECT['id']}/nodes/stop"): + resp.status_code = 204 + return resp + elif request.path_url.endswith(f"/{CPROJECT['id']}/nodes/suspend"): + resp.status_code = 204 return resp elif request.path_url.endswith( f"/{CPROJECT['id']}/nodes/{CNODE['id']}/start" @@ -138,7 +165,12 @@ def post_put_matcher(request): return resp _returned = json_api_test_link() resp.status_code = 201 - resp.json = lambda: _returned + if any(x for x in nodes if x["node_id"] == CNODE["id"]): + resp.json = lambda: _returned + else: + _returned.update(**_data) + _returned.update(link_id="NEW_LINK_ID") + resp.json = lambda: _returned return resp elif request.method == "PUT": if request.path_url.endswith(f"/{CPROJECT['id']}"): @@ -185,7 +217,9 @@ def _apply_responses(self): json={"message": "Template ID 7777-4444-0000 doesn't exist", "status": 404}, status_code=404, ) - # Projects + ############ + # Projects # + ############ self.adapter.register_uri( "GET", f"{self.base_url}/projects", json=projects_data() ) @@ -218,26 +252,34 @@ def _apply_responses(self): self.adapter.register_uri( "DELETE", f"{self.base_url}/projects/{CPROJECT['id']}" ) - self.adapter.add_matcher(post_put_matcher) - # Nodes + ######### + # Nodes # + ######### self.adapter.register_uri( "GET", f"{self.base_url}/projects/{CPROJECT['id']}/nodes", json=nodes_data() ) - self.adapter.register_uri( - "GET", - f"{self.base_url}/projects/{CPROJECT['id']}/nodes/{CNODE['id']}", - json=next((_n for _n in nodes_data() if _n["node_id"] == CNODE["id"])), - ) - self.adapter.register_uri( - "GET", - f"{self.base_url}/projects/{CPROJECT['id']}/nodes/{CNODE['id']}/links", - json=[ - _link - for _link in links_data() - for _node in _link["nodes"] - if _node["node_id"] == CNODE["id"] - ], - ) + # Register all nodes data to the respective endpoint project + _nodes_links = {} + for _n in nodes_data(): + if _n["project_id"] == CPROJECT["id"]: + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/nodes/{_n['node_id']}", + json=_n, + ) + # Save all the links respective to that node + _nodes_links[_n["node_id"]] = [] + for _l in links_data(): + for _ln in _l["nodes"]: + if _n["node_id"] == _ln["node_id"]: + _nodes_links[_n["node_id"]].append(_l) + # Now register all links to the respective node endpoint + for _n, _sl in _nodes_links.items(): + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/nodes/{_n}/links", + json=_sl, + ) self.adapter.register_uri( "GET", f"{self.base_url}/projects/{CPROJECT['id']}/nodes/" "7777-4444-0000", @@ -249,15 +291,19 @@ def _apply_responses(self): f"{self.base_url}/projects/{CPROJECT['id']}/nodes/{CNODE['id']}", status_code=204, ) - # Links + ######### + # Links # + ######### self.adapter.register_uri( "GET", f"{self.base_url}/projects/{CPROJECT['id']}/links", json=links_data() ) - self.adapter.register_uri( - "GET", - f"{self.base_url}/projects/{CPROJECT['id']}/links/{CLINK['id']}", - json=next((_l for _l in links_data() if _l["link_id"] == CLINK["id"])), - ) + # Register all links data to the respective endpoint + for _l in links_data(): + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/links/{_l['link_id']}", + json=_l, + ) self.adapter.register_uri( "GET", f"{self.base_url}/projects/{CPROJECT['id']}/links/" "7777-4444-0000", @@ -269,6 +315,38 @@ def _apply_responses(self): f"{self.base_url}/projects/{CPROJECT['id']}/links/{CLINK['id']}", status_code=204, ) + ################################## + # POST and PUT matcher endpoints # + ################################## + self.adapter.add_matcher(post_put_matcher) + + +# NOTE: Needed to register a different response for nodes endpoint +class Gns3ConnectorMockStopped(Gns3ConnectorMock): + def _apply_responses(self): + # Retrieve same responses + super()._apply_responses() + _nodes = nodes_data() + # Now update nodes status data and save to the endpoint + for n in _nodes: + n.update(status="stopped") + self.adapter.register_uri( + "GET", f"{self.base_url}/projects/{CPROJECT['id']}/nodes", json=_nodes + ) + + +# NOTE: Needed to register a different response for nodes endpoint +class Gns3ConnectorMockSuspended(Gns3ConnectorMock): + def _apply_responses(self): + # Retrieve same responses + super()._apply_responses() + _nodes = nodes_data() + # Now update nodes status data and save to the endpoint + for n in _nodes: + n.update(status="suspended") + self.adapter.register_uri( + "GET", f"{self.base_url}/projects/{CPROJECT['id']}/nodes", json=_nodes + ) @pytest.fixture(scope="class") @@ -276,19 +354,11 @@ def gns3_server(): return Gns3ConnectorMock(url=BASE_URL) -def test_Gns3Connector_wrong_server_url(gns3_server): - # NOTE: Outside of class beacuse it changes the base_url - gns3_server.base_url = "WRONG URL" - with pytest.raises(requests.exceptions.InvalidURL): - gns3_server.get_version() - - class TestGns3Connector: def test_get_version(self, gns3_server): assert dict(local=True, version="2.2.0") == gns3_server.get_version() def test_get_templates(self, gns3_server): - # gns3_server = Gns3ConnectorMock(url="mock://gns3server:3080") response = gns3_server.get_templates() for index, n in enumerate( [ @@ -310,21 +380,32 @@ def test_get_templates(self, gns3_server): assert n[2] == response[index]["category"] def test_get_template_by_name(self, gns3_server): - response = gns3_server.get_template_by_name(name="alpine") + response = gns3_server.get_template(name="alpine") assert "alpine" == response["name"] assert "docker" == response["template_type"] assert "guest" == response["category"] def test_get_template_by_id(self, gns3_server): - response = gns3_server.get_template_by_id(CTEMPLATE["id"]) + response = gns3_server.get_template(template_id=CTEMPLATE["id"]) assert "alpine" == response["name"] assert "docker" == response["template_type"] assert "guest" == response["category"] - def test_template_not_found(self, gns3_server): - response = gns3_server.get_template_by_id("7777-4444-0000") - assert "Template ID 7777-4444-0000 doesn't exist" == response["message"] - assert 404 == response["status"] + def test_error_get_template_no_params(self, gns3_server): + with pytest.raises( + ValueError, match="Must provide either a name or template_id" + ): + gns3_server.get_template() + + def test_error_template_id_not_found(self, gns3_server): + response = gns3_server.get_template(template_id="7777-4444-0000") + assert response["message"] == "Template ID 7777-4444-0000 doesn't exist" + assert response["status"] == 404 + + def test_error_template_name_not_found(self, gns3_server): + # NOTE: Should it give the same output as the one above? + response = gns3_server.get_template(name="NOTE_FOUND") + assert response is None def test_get_projects(self, gns3_server): response = gns3_server.get_projects() @@ -339,21 +420,32 @@ def test_get_projects(self, gns3_server): assert n[2] == response[index]["status"] def test_get_project_by_name(self, gns3_server): - response = gns3_server.get_project_by_name(name="API_TEST") + response = gns3_server.get_project(name="API_TEST") assert "API_TEST" == response["name"] assert "test_api1.gns3" == response["filename"] assert "opened" == response["status"] def test_get_project_by_id(self, gns3_server): - response = gns3_server.get_project_by_id(CPROJECT["id"]) + response = gns3_server.get_project(project_id=CPROJECT["id"]) assert "API_TEST" == response["name"] assert "test_api1.gns3" == response["filename"] assert "opened" == response["status"] - def test_project_not_found(self, gns3_server): - response = gns3_server.get_project_by_id("7777-4444-0000") - assert "Project ID 7777-4444-0000 doesn't exist" == response["message"] - assert 404 == response["status"] + def test_error_get_project_no_params(self, gns3_server): + with pytest.raises( + ValueError, match="Must provide either a name or project_id" + ): + gns3_server.get_project() + + def test_error_project_id_not_found(self, gns3_server): + response = gns3_server.get_project(project_id="7777-4444-0000") + assert response["message"] == "Project ID 7777-4444-0000 doesn't exist" + assert response["status"] == 404 + + def test_error_project_name_not_found(self, gns3_server): + # NOTE: Should it give the same output as the one above? + response = gns3_server.get_project(name="NOTE_FOUND") + assert response is None def test_get_nodes(self, gns3_server): response = gns3_server.get_nodes(project_id=CPROJECT["id"]) @@ -371,34 +463,30 @@ def test_get_nodes(self, gns3_server): assert n[1] == response[index]["node_type"] def test_get_node_by_id(self, gns3_server): - response = gns3_server.get_node_by_id( - project_id=CPROJECT["id"], node_id=CNODE["id"] - ) - assert "alpine-1" == response["name"] - assert "docker" == response["node_type"] - assert 5005 == response["console"] + response = gns3_server.get_node(project_id=CPROJECT["id"], node_id=CNODE["id"]) + assert response["name"] == "alpine-1" + assert response["node_type"] == "docker" + assert response["console"] == 5005 - def test_node_not_found(self, gns3_server): - response = gns3_server.get_node_by_id( + def test_error_node_not_found(self, gns3_server): + response = gns3_server.get_node( project_id=CPROJECT["id"], node_id="7777-4444-0000" ) - assert "Node ID 7777-4444-0000 doesn't exist" == response["message"] - assert 404 == response["status"] + assert response["message"] == "Node ID 7777-4444-0000 doesn't exist" + assert response["status"] == 404 def test_get_links(self, gns3_server): response = gns3_server.get_links(project_id=CPROJECT["id"]) - assert "ethernet" == response[0]["link_type"] + assert response[0]["link_type"] == "ethernet" def test_get_link_by_id(self, gns3_server): - response = gns3_server.get_link_by_id( - project_id=CPROJECT["id"], link_id=CLINK["id"] - ) - assert "ethernet" == response["link_type"] - assert CPROJECT["id"] == response["project_id"] + response = gns3_server.get_link(project_id=CPROJECT["id"], link_id=CLINK["id"]) + assert response["link_type"] == "ethernet" + assert response["project_id"] == CPROJECT["id"] assert response["suspend"] is False - def test_link_not_found(self, gns3_server): - response = gns3_server.get_link_by_id( + def test_error_link_not_found(self, gns3_server): + response = gns3_server.get_link( project_id=CPROJECT["id"], link_id="7777-4444-0000" ) assert "Link ID 7777-4444-0000 doesn't exist" == response["message"] @@ -409,15 +497,24 @@ def test_create_project(self, gns3_server): assert "API_TEST" == response["name"] assert "opened" == response["status"] - def test_create_duplicate_project(self, gns3_server): + def test_error_create_duplicate_project(self, gns3_server): response = gns3_server.create_project(name="DUPLICATE") assert "Project 'DUPLICATE' already exists" == response["message"] assert 409 == response["status"] + def test_error_create_project_with_no_name(self, gns3_server): + with pytest.raises(ValueError, match="Parameter 'name' is mandatory"): + gns3_server.create_project(dummy="DUMMY") + def test_delete_project(self, gns3_server): response = gns3_server.delete_project(project_id=CPROJECT["id"]) assert response is None + def test_wrong_server_url(self, gns3_server): + gns3_server.base_url = "WRONG URL" + with pytest.raises(requests.exceptions.InvalidURL): + gns3_server.get_version() + @pytest.fixture(scope="class") def api_test_link(gns3_server): @@ -431,6 +528,32 @@ def test_instatiation(self): for index, link_data in enumerate(links_data()): assert links.LINKS_REPR[index] == repr(Link(**link_data)) + def test_error_instatiation_bad_link_type(self): + with pytest.raises(ValueError, match="Not a valid link_type - dummy"): + Link(link_type="dummy") + + @pytest.mark.parametrize( + "params,expected", + [ + ( + {"link_id": "SOME_ID", "project_id": "SOME_ID"}, + "Gns3Connector not assigned under 'connector'", + ), + ( + {"link_id": "SOME_ID", "connector": "SOME_CONN"}, + "Need to submit project_id", + ), + ( + {"project_id": "SOME_ID", "connector": "SOME_CONN"}, + "Need to submit link_id", + ), + ], + ) + def test_error_get_with_no_required_param(self, params, expected): + link = Link(**params) + with pytest.raises(ValueError, match=expected): + link.get() + def test_get(self, api_test_link): assert api_test_link.link_type == "ethernet" assert api_test_link.filters == {} @@ -440,6 +563,18 @@ def test_get(self, api_test_link): assert api_test_link.nodes[-1]["adapter_number"] == 0 assert api_test_link.nodes[-1]["port_number"] == 0 + @pytest.mark.parametrize( + "params,expected", + [ + ({"project_id": "SOME_ID"}, "Gns3Connector not assigned under 'connector'"), + ({"connector": "SOME_CONN"}, "Need to submit project_id"), + ], + ) + def test_error_create_with_no_required_param(self, params, expected): + link = Link(**params) + with pytest.raises(ValueError, match=expected): + link.create() + def test_create(self, gns3_server): _link_data = [ { @@ -457,13 +592,13 @@ def test_create(self, gns3_server): assert link.suspend is False assert link.nodes[-1]["node_id"] == CNODE["id"] - def test_create_with_incomplete_node_data(self, gns3_server): + def test_error_create_with_incomplete_node_data(self, gns3_server): _link_data = [{"adapter_number": 0, "port_number": 0, "node_id": CNODE["id"]}] link = Link(connector=gns3_server, project_id=CPROJECT["id"], nodes=_link_data) with pytest.raises(ValueError, match="400"): link.create() - def test_create_with_invalid_nodes_id(self, gns3_server): + def test_error_create_with_invalid_nodes_id(self, gns3_server): _link_data = [ {"adapter_number": 2, "port_number": 0, "node_id": CNODE["id"]}, {"adapter_number": 0, "port_number": 0, "node_id": CNODE["id"]}, @@ -481,7 +616,7 @@ def test_delete(self, api_test_link): @pytest.fixture(scope="class") def api_test_node(gns3_server): node = Node(name="alpine-1", connector=gns3_server, project_id=CPROJECT["id"]) - node.get() + # node.get() return node @@ -490,7 +625,42 @@ def test_instatiation(self): for index, node_data in enumerate(nodes_data()): assert nodes.NODES_REPR[index] == repr(Node(**node_data)) + @pytest.mark.parametrize( + "param,expected", + [ + ({"node_type": "dummy"}, "Not a valid node_type - dummy"), + ({"console_type": "dummy"}, "Not a valid console_type - dummy"), + ({"status": "dummy"}, "Not a valid status - dummy"), + ], + ) + def test_error_link_instatiation_bad_param(self, param, expected): + with pytest.raises(ValueError, match=expected): + Node(**param) + + @pytest.mark.parametrize( + "params,expected", + [ + ( + {"node_id": "SOME_ID", "project_id": "SOME_ID"}, + "Gns3Connector not assigned under 'connector'", + ), + ( + {"node_id": "SOME_ID", "connector": "SOME_CONN"}, + "Need to submit project_id", + ), + ( + {"project_id": "SOME_ID", "connector": "SOME_CONN"}, + "Need to either submit node_id or name", + ), + ], + ) + def test_error_get_with_no_required_param(self, params, expected): + node = Node(**params) + with pytest.raises(ValueError, match=expected): + node.get() + def test_get(self, api_test_node): + api_test_node.get() assert "alpine-1" == api_test_node.name assert "started" == api_test_node.status assert "docker" == api_test_node.node_type @@ -522,21 +692,41 @@ def test_reload(self, api_test_node): assert "alpine-1" == api_test_node.name assert "started" == api_test_node.status - def test_create(self, gns3_server): + @pytest.mark.parametrize( + "param", [{"template": CTEMPLATE["name"]}, {"template_id": CTEMPLATE["id"]}] + ) + def test_create(self, param, gns3_server): node = Node( name="alpine-1", node_type="docker", - template=CTEMPLATE["name"], connector=gns3_server, project_id=CPROJECT["id"], + **param, ) node.create() assert "alpine-1" == node.name assert "started" == node.status assert "docker" == node.node_type assert "alpine:latest" == node.properties["image"] + assert node.properties["console_http_port"] == 80 - def test_create_with_invalid_parameter_type(self, gns3_server): + def test_create_override_properties(self, gns3_server): + node = Node( + name="alpine-1", + node_type="docker", + connector=gns3_server, + project_id=CPROJECT["id"], + template=CTEMPLATE["name"], + ) + node.create(extra_properties={"console_http_port": 8080}) + assert "alpine-1" == node.name + # NOTE: The image name of alpine in teh template is different than the one + # defined on the node properties, which has the version alpine:latest. + # Need to keep an eye + assert "alpine" == node.properties["image"] + assert node.properties["console_http_port"] == 8080 + + def test_error_create_with_invalid_parameter_type(self, gns3_server): with pytest.raises(ValidationError): Node( name="alpine-1", @@ -547,9 +737,56 @@ def test_create_with_invalid_parameter_type(self, gns3_server): compute_id=None, ) - def test_create_with_incomplete_parameters(self, gns3_server): - node = Node(name="alpine-1", connector=gns3_server, project_id=CPROJECT["id"]) - with pytest.raises(ValueError, match="Need to submit 'node_type'"): + @pytest.mark.parametrize( + "params,expected", + [ + ({"project_id": "SOME_ID"}, "Gns3Connector not assigned under 'connector'"), + ({"connector": "SOME_CONN"}, "Need to submit project_id"), + ( + { + "connector": "SOME_CONN", + "project_id": "SOME_ID", + "compute_id": "SOME_ID", + }, + "Need to submit name", + ), + ( + { + "connector": "SOME_CONN", + "project_id": "SOME_ID", + "compute_id": "SOME_ID", + "name": "SOME_NAME", + }, + "Need to submit node_type", + ), + ( + { + "connector": "SOME_CONN", + "project_id": "SOME_ID", + "compute_id": "SOME_ID", + "name": "SOME_NAME", + "node_type": "docker", + "node_id": "SOME_ID", + }, + "Node already created", + ), + ( + { + "connector": "CHANGE_TO_FIXTURE", + "project_id": "SOME_ID", + "compute_id": "SOME_ID", + "name": "SOME_NAME", + "node_type": "docker", + }, + "You must provide template or template_id", + ), + ], + ) + def test_error_create_with_no_required_param(self, params, expected, gns3_server): + node = Node(**params) + if node.connector == "CHANGE_TO_FIXTURE": + node.connector = gns3_server + with pytest.raises(ValueError, match=expected): node.create() def test_delete(self, api_test_node): @@ -571,6 +808,10 @@ def test_instatiation(self): for index, project_data in enumerate(projects_data()): assert projects.PROJECTS_REPR[index] == repr(Project(**project_data)) + def test_error_instatiation_bad_status(self): + with pytest.raises(ValueError, match="status must be opened or closed"): + Project(status="dummy") + def test_create(self, gns3_server): api_test_project = Project(name="API_TEST", connector=gns3_server) api_test_project.create() @@ -578,6 +819,18 @@ def test_create(self, gns3_server): assert "opened" == api_test_project.status assert False is api_test_project.auto_close + @pytest.mark.parametrize( + "params,expected", + [ + ({"name": "SOME_NAME"}, "Gns3Connector not assigned under 'connector'"), + ({"connector": "SOME_CONN"}, "Need to submit project name"), + ], + ) + def test_error_create_with_no_required_param(self, params, expected): + project = Project(**params) + with pytest.raises(ValueError, match=expected): + project.create() + def test_delete(self, gns3_server): api_test_project = Project(name="API_TEST", connector=gns3_server) api_test_project.create() @@ -594,12 +847,36 @@ def test_get(self, api_test_project): "snapshots": 0, } == api_test_project.stats + @pytest.mark.parametrize( + "params,expected", + [ + ({"project_id": "SOME_ID"}, "Gns3Connector not assigned under 'connector'"), + ({"connector": "SOME_CONN"}, "Need to submit either project_id or name"), + ], + ) + def test_error_get_with_no_required_param(self, params, expected): + project = Project(**params) + with pytest.raises(ValueError, match=expected): + project.get() + def test_update(self, api_test_project): api_test_project.update(filename="file_updated.gns3") assert "API_TEST" == api_test_project.name assert "opened" == api_test_project.status assert "file_updated.gns3" == api_test_project.filename + @pytest.mark.parametrize( + "params,expected", + [ + ({"project_id": "SOME_ID"}, "Gns3Connector not assigned under 'connector'"), + ({"connector": "SOME_CONN"}, "Need to submit project_id"), + ], + ) + def test_error_update_with_no_required_param(self, params, expected): + project = Project(**params) + with pytest.raises(ValueError, match=expected): + project.update() + def test_open(self, api_test_project): api_test_project.open() assert "API_TEST" == api_test_project.name @@ -634,48 +911,70 @@ def test_get_nodes(self, api_test_project): assert n[0] == api_test_project.nodes[index].name assert n[1] == api_test_project.nodes[index].node_type + def test_error_get_node_no_required_params(self, api_test_project): + with pytest.raises(ValueError, match="name or node_ide must be provided"): + api_test_project.get_node() + def test_get_links(self, api_test_project): api_test_project.get_links() assert "ethernet" == api_test_project.links[0].link_type + def test_error_get_link_not_found(self, api_test_project): + assert api_test_project.get_link(link_id="DUMMY_ID") is None + # TODO: Need to make a way to dynamically change the status of the nodes to started # when the inner method `get_nodes` hits again the server REST endpoint - @pytest.mark.skip def test_start_nodes(self, api_test_project): - api_test_project.start_nodes() + api_test_project.start_nodes(poll_wait_time=0) for node in api_test_project.nodes: - assert "started" == node.status + assert node.status == "started" - @pytest.mark.skip - def test_stop_nodes(self, api_test_project): - api_test_project.stop_nodes() - for node in api_test_project.nodes: - assert "stopped" == node.status + def test_stop_nodes(self): + project = Project( + name="API_TEST", + connector=Gns3ConnectorMockStopped(url=BASE_URL), + project_id=CPROJECT["id"], + ) + project.stop_nodes(poll_wait_time=0) + for node in project.nodes: + assert node.status == "stopped" - @pytest.mark.skip def test_reload_nodes(self, api_test_project): - api_test_project.reload_nodes() + api_test_project.reload_nodes(poll_wait_time=0) for node in api_test_project.nodes: - assert "started" == node.status + assert node.status == "started" - @pytest.mark.skip - def test_suspend_nodes(self, api_test_project): - api_test_project.suspend_nodes() - for node in api_test_project.nodes: - assert "suspended" == node.status + def test_suspend_nodes(self): + project = Project( + name="API_TEST", + connector=Gns3ConnectorMockSuspended(url=BASE_URL), + project_id=CPROJECT["id"], + ) + project.suspend_nodes(poll_wait_time=0) + for node in project.nodes: + assert node.status == "suspended" def test_nodes_summary(self, api_test_project): nodes_summary = api_test_project.nodes_summary(is_print=False) assert str(nodes_summary) == ( - "[('Ethernetswitch-1', 'started', '5000', " - "'da28e1c0-9465-4f7c-b42c-49b2f4e1c64d'), ('IOU1', 'started', '5001', " - "'de23a89a-aa1f-446a-a950-31d4bf98653c'), ('IOU2', 'started', '5002', " - "'0d10d697-ef8d-40af-a4f3-fafe71f5458b'), ('vEOS', 'started', '5003', " - "'8283b923-df0e-4bc1-8199-be6fea40f500'), ('alpine-1', 'started', '5005', " + "[('Ethernetswitch-1', 'started', 5000, " + "'da28e1c0-9465-4f7c-b42c-49b2f4e1c64d'), ('IOU1', 'started', 5001, " + "'de23a89a-aa1f-446a-a950-31d4bf98653c'), ('IOU2', 'started', 5002, " + "'0d10d697-ef8d-40af-a4f3-fafe71f5458b'), ('vEOS', 'started', 5003, " + "'8283b923-df0e-4bc1-8199-be6fea40f500'), ('alpine-1', 'started', 5005, " "'ef503c45-e998-499d-88fc-2765614b313e'), ('Cloud-1', 'started', None, " "'cde85a31-c97f-4551-9596-a3ed12c08498')]" ) + def test_nodes_inventory(self, api_test_project): + nodes_inventory = api_test_project.nodes_inventory() + assert { + "hostname": "gns3server", + "name": "alpine-1", + "console_port": 5005, + "type": "docker", + } == nodes_inventory["alpine-1"] + def test_links_summary(self, api_test_project): api_test_project.get_links() links_summary = api_test_project.links_summary(is_print=False) @@ -688,18 +987,67 @@ def test_links_summary(self, api_test_project): def test_get_node_by_name(self, api_test_project): switch = api_test_project.get_node(name="IOU1") - assert "IOU1" == switch.name - assert "started" == switch.status - assert "5001" == switch.console + assert switch.name == "IOU1" + assert switch.status == "started" + assert switch.console == 5001 def test_get_node_by_id(self, api_test_project): host = api_test_project.get_node(node_id=CNODE["id"]) - assert "alpine-1" == host.name - assert "started" == host.status - assert "5005" == host.console + assert host.name == "alpine-1" + assert host.status == "started" + assert host.console == 5005 - # TODO: `get_link` is dependent on the nodes information of the links - @pytest.mark.skip def test_get_link_by_id(self, api_test_project): link = api_test_project.get_link(link_id=CLINK["id"]) assert "ethernet" == link.link_type + + def test_create_node(self, api_test_project): + api_test_project.create_node( + name="alpine-2", node_type="docker", template=CTEMPLATE["name"] + ) + alpine2 = api_test_project.get_node(name="alpine-2") + assert alpine2.console == 5077 + assert alpine2.name == "alpine-2" + assert alpine2.node_type == "docker" + assert alpine2.node_id == "NEW_NODE_ID" + + def test_error_create_node_with_equal_name(self, api_test_project): + with pytest.raises(ValueError, match="Node with equal name found"): + api_test_project.create_node( + name="alpine-1", + node_type="docker", + template=CTEMPLATE["name"], + connector=gns3_server, + project_id=CPROJECT["id"], + ) + + def test_create_link(self, api_test_project): + api_test_project.create_link("IOU1", "Ethernet1/1", "vEOS", "Ethernet2") + link = api_test_project.get_link(link_id="NEW_LINK_ID") + assert link.link_id == "NEW_LINK_ID" + assert link.link_type == "ethernet" + + @pytest.mark.parametrize( + "link,expected", + [ + ( + ("IOU1", "Ethernet77/1", "vEOS", "Ethernet2"), + "port_a: Ethernet77/1 not found", + ), + ( + ("IOU1", "Ethernet1/1", "vEOS", "Ethernet77"), + "port_b: Ethernet77 not found", + ), + (("IOU77", "Ethernet1/1", "vEOS", "Ethernet2"), "node_a: IOU77 not found"), + ( + ("IOU1", "Ethernet1/1", "vEOS77", "Ethernet2"), + "node_b: vEOS77 not found", + ), + (("IOU1", "Ethernet1/0", "vEOS", "Ethernet2"), "At least one port is used"), + ], + ) + def test_error_create_link_with_invalid_param( + self, api_test_project, link, expected + ): + with pytest.raises(ValueError, match=expected): + api_test_project.create_link(*link) From 306770d73a6e6ca7547c5d40e354d101655aba1b Mon Sep 17 00:00:00 2001 From: David Flores Date: Sun, 18 Aug 2019 22:46:42 +0100 Subject: [PATCH 24/26] Fixed error handling problem and improved error testing --- gns3fy/gns3fy.py | 143 ++++++++++------------------------------------ tests/test_api.py | 48 ++++++++-------- 2 files changed, 56 insertions(+), 135 deletions(-) diff --git a/gns3fy/gns3fy.py b/gns3fy/gns3fy.py index 0a6ace0..048c1ac 100644 --- a/gns3fy/gns3fy.py +++ b/gns3fy/gns3fy.py @@ -1,7 +1,7 @@ import time import requests from urllib.parse import urlencode, urlparse -from requests import ConnectionError, ConnectTimeout, HTTPError +from requests import HTTPError from dataclasses import field from typing import Optional, Any, Dict, List from pydantic import validator @@ -117,42 +117,33 @@ def http_call( - `verify`: SSL Verification - `params`: Dictionary or bytes to be sent in the query string for the Request """ - try: - if data: - _response = getattr(self.session, method.lower())( - url, - data=urlencode(data), - # data=data, - headers=headers, - params=params, - verify=verify, - ) - - elif json_data: - _response = getattr(self.session, method.lower())( - url, json=json_data, headers=headers, params=params, verify=verify - ) - - else: - _response = getattr(self.session, method.lower())( - url, headers=headers, params=params, verify=verify - ) + if data: + _response = getattr(self.session, method.lower())( + url, + data=urlencode(data), + # data=data, + headers=headers, + params=params, + verify=verify, + ) - time.sleep(0.5) - self.api_calls += 1 + elif json_data: + _response = getattr(self.session, method.lower())( + url, json=json_data, headers=headers, params=params, verify=verify + ) - except (ConnectTimeout, ConnectionError) as err: - print( - f"[ERROR] Connection Error, could not perform {method}" - f" operation: {err}" + else: + _response = getattr(self.session, method.lower())( + url, headers=headers, params=params, verify=verify ) - return False - except HTTPError as err: - print( - f"[ERROR] An unknown error has been encountered: {err} -" - f" {_response.text}" + self.api_calls += 1 + + try: + _response.raise_for_status() + except HTTPError: + raise HTTPError( + f"{_response.json()['status']}: {_response.json()['message']}" ) - return False return _response @@ -397,9 +388,6 @@ def get(self): f"{self.connector.base_url}/projects/{self.project_id}/links/{self.link_id}" ) _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object self._update(_response.json()) @@ -421,10 +409,7 @@ def delete(self): f"{self.connector.base_url}/projects/{self.project_id}/links/{self.link_id}" ) - _response = self.connector.http_call("delete", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") + self.connector.http_call("delete", _url) self.project_id = None self.link_id = None @@ -454,9 +439,6 @@ def create(self): } _response = self.connector.http_call("post", _url, json_data=data) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Now update it self._update(_response.json()) @@ -612,9 +594,6 @@ def get(self, get_links=True): f"{self.connector.base_url}/projects/{self.project_id}/nodes/{self.node_id}" ) _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object self._update(_response.json()) @@ -640,9 +619,6 @@ def get_links(self): f"/{self.node_id}/links" ) _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Create the Link array but cleanup cache if there is one if self.links: @@ -667,9 +643,6 @@ def start(self): f"/{self.node_id}/start" ) _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object or perform get if change was not reflected if _response.json().get("status") == "started": @@ -694,9 +667,6 @@ def stop(self): f"/{self.node_id}/stop" ) _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object or perform get if change was not reflected if _response.json().get("status") == "stopped": @@ -721,9 +691,6 @@ def reload(self): f"/{self.node_id}/reload" ) _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object or perform get if change was not reflected if _response.json().get("status") == "started": @@ -748,9 +715,6 @@ def suspend(self): f"/{self.node_id}/suspend" ) _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object or perform get if change was not reflected if _response.json().get("status") == "suspended": @@ -824,9 +788,6 @@ def create(self, extra_properties={}): data.update(properties=_properties) _response = self.connector.http_call("post", _url, json_data=data) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") self._update(_response.json()) @@ -847,10 +808,7 @@ def delete(self): f"{self.connector.base_url}/projects/{self.project_id}/nodes/{self.node_id}" ) - _response = self.connector.http_call("delete", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") + self.connector.http_call("delete", _url) self.project_id = None self.node_id = None @@ -980,9 +938,6 @@ def get(self, get_links=True, get_nodes=True, get_stats=True): # Get project _url = f"{self.connector.base_url}/projects/{self.project_id}" _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object self._update(_response.json()) @@ -1018,9 +973,6 @@ def create(self): } _response = self.connector.http_call("post", _url, json_data=data) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Now update it self._update(_response.json()) @@ -1045,9 +997,6 @@ def update(self, **kwargs): # TODO: Verify that the passed kwargs are supported ones _response = self.connector.http_call("put", _url, json_data=kwargs) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object self._update(_response.json()) @@ -1066,10 +1015,7 @@ def delete(self): _url = f"{self.connector.base_url}/projects/{self.project_id}" - _response = self.connector.http_call("delete", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") + self.connector.http_call("delete", _url) self.project_id = None self.name = None @@ -1088,9 +1034,6 @@ def close(self): _url = f"{self.connector.base_url}/projects/{self.project_id}/close" _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object self._update(_response.json()) @@ -1109,9 +1052,6 @@ def open(self): _url = f"{self.connector.base_url}/projects/{self.project_id}/open" _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object self._update(_response.json()) @@ -1130,9 +1070,6 @@ def get_stats(self): _url = f"{self.connector.base_url}/projects/{self.project_id}/stats" _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Update object self.stats = _response.json() @@ -1151,9 +1088,6 @@ def get_nodes(self): _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Create the Nodes array but cleanup cache if there is one if self.nodes: @@ -1177,9 +1111,6 @@ def get_links(self): _url = f"{self.connector.base_url}/projects/{self.project_id}/links" _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Create the Nodes array but cleanup cache if there is one if self.links: @@ -1205,10 +1136,7 @@ def start_nodes(self, poll_wait_time=5): _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/start" - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") + self.connector.http_call("post", _url) # Update object time.sleep(poll_wait_time) @@ -1230,10 +1158,7 @@ def stop_nodes(self, poll_wait_time=5): _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/stop" - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") + self.connector.http_call("post", _url) # Update object time.sleep(poll_wait_time) @@ -1255,10 +1180,7 @@ def reload_nodes(self, poll_wait_time=5): _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/reload" - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") + self.connector.http_call("post", _url) # Update object time.sleep(poll_wait_time) @@ -1280,10 +1202,7 @@ def suspend_nodes(self, poll_wait_time=5): _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes/suspend" - _response = self.connector.http_call("post", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") + self.connector.http_call("post", _url) # Update object time.sleep(poll_wait_time) diff --git a/tests/test_api.py b/tests/test_api.py index 76c5150..e392b07 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -4,6 +4,7 @@ import requests_mock from pathlib import Path from pydantic.error_wrappers import ValidationError +from requests.exceptions import HTTPError from gns3fy import Link, Node, Project, Gns3Connector from .data import links, nodes, projects @@ -157,10 +158,12 @@ def post_put_matcher(request): nodes = _data.get("nodes") if len(nodes) != 2: resp.status_code = 400 - resp.json = lambda: dict(message="Invalid request", status=400) + resp.url = request.path_url + resp.json = lambda: dict(message="Bad Request", status=400) return resp elif nodes[0]["node_id"] == nodes[1]["node_id"]: resp.status_code = 409 + resp.url = request.path_url resp.json = lambda: dict(message="Cannot connect to itself", status=409) return resp _returned = json_api_test_link() @@ -398,9 +401,10 @@ def test_error_get_template_no_params(self, gns3_server): gns3_server.get_template() def test_error_template_id_not_found(self, gns3_server): - response = gns3_server.get_template(template_id="7777-4444-0000") - assert response["message"] == "Template ID 7777-4444-0000 doesn't exist" - assert response["status"] == 404 + with pytest.raises( + HTTPError, match="404: Template ID 7777-4444-0000 doesn't exist" + ): + gns3_server.get_template(template_id="7777-4444-0000") def test_error_template_name_not_found(self, gns3_server): # NOTE: Should it give the same output as the one above? @@ -438,9 +442,10 @@ def test_error_get_project_no_params(self, gns3_server): gns3_server.get_project() def test_error_project_id_not_found(self, gns3_server): - response = gns3_server.get_project(project_id="7777-4444-0000") - assert response["message"] == "Project ID 7777-4444-0000 doesn't exist" - assert response["status"] == 404 + with pytest.raises( + HTTPError, match="404: Project ID 7777-4444-0000 doesn't exist" + ): + gns3_server.get_project(project_id="7777-4444-0000") def test_error_project_name_not_found(self, gns3_server): # NOTE: Should it give the same output as the one above? @@ -469,11 +474,10 @@ def test_get_node_by_id(self, gns3_server): assert response["console"] == 5005 def test_error_node_not_found(self, gns3_server): - response = gns3_server.get_node( - project_id=CPROJECT["id"], node_id="7777-4444-0000" - ) - assert response["message"] == "Node ID 7777-4444-0000 doesn't exist" - assert response["status"] == 404 + with pytest.raises( + HTTPError, match="404: Node ID 7777-4444-0000 doesn't exist" + ): + gns3_server.get_node(project_id=CPROJECT["id"], node_id="7777-4444-0000") def test_get_links(self, gns3_server): response = gns3_server.get_links(project_id=CPROJECT["id"]) @@ -486,11 +490,10 @@ def test_get_link_by_id(self, gns3_server): assert response["suspend"] is False def test_error_link_not_found(self, gns3_server): - response = gns3_server.get_link( - project_id=CPROJECT["id"], link_id="7777-4444-0000" - ) - assert "Link ID 7777-4444-0000 doesn't exist" == response["message"] - assert 404 == response["status"] + with pytest.raises( + HTTPError, match="404: Link ID 7777-4444-0000 doesn't exist" + ): + gns3_server.get_link(project_id=CPROJECT["id"], link_id="7777-4444-0000") def test_create_project(self, gns3_server): response = gns3_server.create_project(name="API_TEST") @@ -498,9 +501,8 @@ def test_create_project(self, gns3_server): assert "opened" == response["status"] def test_error_create_duplicate_project(self, gns3_server): - response = gns3_server.create_project(name="DUPLICATE") - assert "Project 'DUPLICATE' already exists" == response["message"] - assert 409 == response["status"] + with pytest.raises(HTTPError, match="409: Project 'DUPLICATE' already exists"): + gns3_server.create_project(name="DUPLICATE") def test_error_create_project_with_no_name(self, gns3_server): with pytest.raises(ValueError, match="Parameter 'name' is mandatory"): @@ -595,16 +597,16 @@ def test_create(self, gns3_server): def test_error_create_with_incomplete_node_data(self, gns3_server): _link_data = [{"adapter_number": 0, "port_number": 0, "node_id": CNODE["id"]}] link = Link(connector=gns3_server, project_id=CPROJECT["id"], nodes=_link_data) - with pytest.raises(ValueError, match="400"): + with pytest.raises(HTTPError, match="400"): link.create() - def test_error_create_with_invalid_nodes_id(self, gns3_server): + def test_error_create_connecting_to_itself(self, gns3_server): _link_data = [ {"adapter_number": 2, "port_number": 0, "node_id": CNODE["id"]}, {"adapter_number": 0, "port_number": 0, "node_id": CNODE["id"]}, ] link = Link(connector=gns3_server, project_id=CPROJECT["id"], nodes=_link_data) - with pytest.raises(ValueError, match="409"): + with pytest.raises(HTTPError, match="409: Cannot connect to itself"): link.create() def test_delete(self, api_test_link): From 00a99ef05e7154fa3ca980869b84a1db195a07e2 Mon Sep 17 00:00:00 2001 From: David Flores Date: Sun, 18 Aug 2019 23:26:59 +0100 Subject: [PATCH 25/26] Disabling TOC for pydoc and imrpoving Makefile --- Makefile | 8 +++ docs/Makefile | 2 - docs/content/api_reference.md | 102 +++++++++++++++++++--------------- docs/pydoc-markdown.yml | 3 +- gns3fy/gns3fy.py | 28 +--------- tests/test_api.py | 2 +- 6 files changed, 68 insertions(+), 77 deletions(-) create mode 100644 Makefile delete mode 100644 docs/Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..046f436 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +docs-publish: + cd docs; mkdocs gh-deploy -m "[ci skip]" + +docs-generate: + cd docs; pydoc-markdown > content/api_reference.md + +docs-show: + cd docs; mkdocs serve diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 31902ba..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,2 +0,0 @@ -docs-publish: - mkdocs gh-deploy -m "[ci skip]" diff --git a/docs/content/api_reference.md b/docs/content/api_reference.md index 91c30d0..f9f9e11 100644 --- a/docs/content/api_reference.md +++ b/docs/content/api_reference.md @@ -1,3 +1,6 @@ +# `gns3fy` + + ## `Gns3Connector` Objects ```python @@ -36,14 +39,6 @@ def __init__(self, url=None, user=None, cred=None, verify=False, api_version=2) ``` -### `Gns3Connector.create_session()` - -```python -def create_session(self) -``` - -Creates the requests.Session object and applies the necessary parameters - ### `Gns3Connector.http_call()` ```python @@ -63,15 +58,6 @@ patch (**required**) - `verify`: SSL Verification - `params`: Dictionary or bytes to be sent in the query string for the Request -### `Gns3Connector.error_checker()` - -```python -@staticmethod -def error_checker(response_obj) -``` - -Returns the error if found - ### `Gns3Connector.get_version()` ```python @@ -88,21 +74,17 @@ def get_projects(self) Returns the list of the projects on the server -### `Gns3Connector.get_project_by_name()` +### `Gns3Connector.get_project()` ```python -def get_project_by_name(self, name) +def get_project(self, name=None, project_id=None) ``` -Retrives a specific project - -### `Gns3Connector.get_project_by_id()` +Retrieves a project from either a name or ID -```python -def get_project_by_id(self, id) -``` +**Required Attributes:** -Retrives a specific project by id +- `name` or `project_id` ### `Gns3Connector.get_templates()` @@ -110,23 +92,19 @@ Retrives a specific project by id def get_templates(self) ``` -Returns the templates defined on the server +Returns the templates defined on the server. -### `Gns3Connector.get_template_by_name()` +### `Gns3Connector.get_template()` ```python -def get_template_by_name(self, name) +def get_template(self, name=None, template_id=None) ``` -Retrives a specific template searching by name +Retrieves a template from either a name or ID -### `Gns3Connector.get_template_by_id()` - -```python -def get_template_by_id(self, id) -``` +**Required Attributes:** -Retrives a specific template by id +- `name` or `template_id` ### `Gns3Connector.get_nodes()` @@ -136,13 +114,22 @@ def get_nodes(self, project_id) Retieves the nodes defined on the project -### `Gns3Connector.get_node_by_id()` +**Required Attributes:** + +- `project_id` + +### `Gns3Connector.get_node()` ```python -def get_node_by_id(self, project_id, node_id) +def get_node(self, project_id, node_id) ``` -Returns the node by locating its ID +Returns the node by locating its ID. + +**Required Attributes:** + +- `project_id` +- `node_id` ### `Gns3Connector.get_links()` @@ -150,15 +137,24 @@ Returns the node by locating its ID def get_links(self, project_id) ``` -Retrieves the links defined in the project +Retrieves the links defined in the project. -### `Gns3Connector.get_link_by_id()` +**Required Attributes:** + +- `project_id` + +### `Gns3Connector.get_link()` ```python -def get_link_by_id(self, project_id, link_id) +def get_link(self, project_id, link_id) ``` -Returns the link by locating its ID +Returns the link by locating its ID. + +**Required Attributes:** + +- `project_id` +- `link_id` ### `Gns3Connector.create_project()` @@ -167,7 +163,14 @@ def create_project(self, kwargs) ``` Pass a dictionary type object with the project parameters to be created. -Parameter `name` is mandatory. Returns project + +**Required Attributes:** + +- `name` + +**Returns** + +JSON project information ### `Gns3Connector.delete_project()` @@ -175,7 +178,11 @@ Parameter `name` is mandatory. Returns project def delete_project(self, project_id) ``` -Deletes a project from server +Deletes a project from server. + +**Required Attributes:** + +- `project_id` ## `Link` Objects @@ -687,7 +694,7 @@ will return a list of tuples like: def nodes_inventory(self) ``` -Returns an inventory-style with the nodes of the project +Returns an inventory-style dictionary of the nodes Example: @@ -767,6 +774,9 @@ Creates a node. - `project_id` - `connector` + +**Required Keyword attributes:** + - `name` - `node_type` - `compute_id`: Defaults to "local" diff --git a/docs/pydoc-markdown.yml b/docs/pydoc-markdown.yml index 7e07ad7..cd98827 100644 --- a/docs/pydoc-markdown.yml +++ b/docs/pydoc-markdown.yml @@ -10,7 +10,6 @@ processors: expression: not name.endswith("_TYPES") - type: filter expression: not name.startswith("Config") - # - type: filter - # expression: not name.startswith("valid_") renderer: type: markdown + render_toc: false diff --git a/gns3fy/gns3fy.py b/gns3fy/gns3fy.py index 048c1ac..971fecb 100644 --- a/gns3fy/gns3fy.py +++ b/gns3fy/gns3fy.py @@ -82,9 +82,9 @@ def __init__(self, url=None, user=None, cred=None, verify=False, api_version=2): self.api_calls = 0 # Create session object - self.create_session() + self._create_session() - def create_session(self): + def _create_session(self): """ Creates the requests.Session object and applies the necessary parameters """ @@ -147,12 +147,6 @@ def http_call( return _response - @staticmethod - def error_checker(response_obj): - "Returns the error if found" - err = f"[ERROR][{response_obj.status_code}]: {response_obj.text}" - return err if 400 <= response_obj.status_code <= 599 else False - def get_version(self): """ Returns the version information of GNS3 server @@ -186,18 +180,6 @@ def get_project(self, name=None, project_id=None): else: raise ValueError("Must provide either a name or project_id") - # def get_project_by_name(self, name): - # "Retrives a specific project" - # _projects = self.http_call("get", url=f"{self.base_url}/projects").json() - # try: - # return [p for p in _projects if p["name"] == name][0] - # except IndexError: - # return None - - # def get_project_by_id(self, id): - # "Retrives a specific project by id" - # return self.http_call("get", url=f"{self.base_url}/projects/{id}").json() - def get_templates(self): """ Returns the templates defined on the server. @@ -566,9 +548,6 @@ def _verify_before_action(self): # Try to retrieve the node_id _url = f"{self.connector.base_url}/projects/{self.project_id}/nodes" _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") extracted = [node for node in _response.json() if node["name"] == self.name] if len(extracted) > 1: @@ -926,9 +905,6 @@ def get(self, get_links=True, get_nodes=True, get_stats=True): _url = f"{self.connector.base_url}/projects" # Get all projects and filter the respective project _response = self.connector.http_call("get", _url) - _err = Gns3Connector.error_checker(_response) - if _err: - raise ValueError(f"{_err}") # Filter the respective project for _project in _response.json(): diff --git a/tests/test_api.py b/tests/test_api.py index e392b07..4b4cf43 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -186,7 +186,7 @@ def post_put_matcher(request): class Gns3ConnectorMock(Gns3Connector): - def create_session(self): + def _create_session(self): self.session = requests.Session() self.adapter = requests_mock.Adapter() self.session.mount("mock", self.adapter) From e5a55ff2fb6d87dd6f70eb0b1f43a9b536e6093e Mon Sep 17 00:00:00 2001 From: David Flores Date: Sun, 18 Aug 2019 23:39:39 +0100 Subject: [PATCH 26/26] Bumping version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e80d469..9adf164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "gns3fy" -version = "0.1.1" +version = "0.2.0" description = "Python wrapper around GNS3 Server API" authors = ["David Flores "] license = "MIT"