diff --git a/README.md b/README.md index 0289ce9..6b1bac8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ Here are some examples where gns3fy is used in a programmatic way: - [Ansible-collection-gns3](https://galaxy.ansible.com/davidban77/gns3): Useful for CI/CD pipelines to interact with GNS3 server using Ansible. It can create/delete projects, nodes and links in an ansible playbook. - Terraform: Soming soon... (although it might be a Go version of it) +- [Migrate templates between GNS3 servers](https://davidban77.github.io/gns3fy/user-guide/#migrate-templates-between-gns3-servers) +- [Check server usage](https://davidban77.github.io/gns3fy/user-guide/#check-server-cpu-and-memory-usage) before turning up resource-hungry nodes +- [Manipulate project snapshots](https://davidban77.github.io/gns3fy/user-guide/#create-and-list-project-snapshots) like create, delete or list the snapshots configured for the project. ## Install diff --git a/docs/content/about/changelog.md b/docs/content/about/changelog.md index af0e30f..e609767 100644 --- a/docs/content/about/changelog.md +++ b/docs/content/about/changelog.md @@ -6,6 +6,22 @@ pip install -U gns3fy # Releases +## 0.5.0 + +**New features:** + +- Extended templates functionality with `create_template`, `update_template` and `delete_template`. Which can be used for migrating templates between GNS3 servers + +- Added compute endpoint get method from the REST API. [Compute endpoint](http://api.gns3.net/en/2.2/api/v2/controller/compute.html) + - `get_computes`: Retrieves attributes and characteristics of the GNS3 server compute that will run the emulations. + - `get_compute_images`: Lists images configured for a specific emulator on a compute. + - `get_compute_ports`: Lists configured and used console ports and UDP ports on a compute. + +- Added projects snapshots attribute and methods. [Snapshots endpoint](http://api.gns3.net/en/2.2/api/v2/controller/snapshot.html) + - `snapshots`: Attribute that stores snapshots names, IDs and created times of a project. Updated by the `get_snapshots` method. + - `get_snapshot`: Retrieves an specific snapshot information. + - `create_snapshot` and `delete_snapshot`: Creates/Delete an specific snapshot + ## 0.4.1 **Fix:** diff --git a/docs/content/api_reference.md b/docs/content/api_reference.md index 2754459..5166c7d 100644 --- a/docs/content/api_reference.md +++ b/docs/content/api_reference.md @@ -128,6 +128,50 @@ Retrieves a template from either a name or ID - `name` or `template_id` +### `Gns3Connector.update_template()` + +```python +def update_template(self, name=None, template_id=None, kwargs) +``` + +Updates a template by giving its name or UUID. For more information [API INFO] +(http://api.gns3.net/en/2.2/api/v2/controller/template/ +templatestemplateid.html#put-v2-templates-template-id) + +**Required Attributes:** + +- `name` or `template_id` + +### `Gns3Connector.create_template()` + +```python +def create_template(self, kwargs) +``` + +Creates a template by giving its attributes. For more information [API INFO] +(http://api.gns3.net/en/2.2/api/v2/controller/template/ +templates.html#post-v2-templates) + +**Required Attributes:** + +- `name` +- `compute_id` by default is 'local' +- `template_type` + +### `Gns3Connector.delete_template()` + +```python +def delete_template(self, name=None, template_id=None) +``` + +Deletes a template by giving its attributes. For more information [API INFO] +(http://api.gns3.net/en/2.2/api/v2/controller/template/ +templatestemplateid.html#id16) + +**Required Attributes:** + +- `name` or `template_id` + ### `Gns3Connector.get_nodes()` ```python @@ -206,6 +250,64 @@ Deletes a project from server. - `project_id` +### `Gns3Connector.get_computes()` + +```python +def get_computes(self) +``` + +Returns a list of computes. + +**Returns:** + +List of dictionaries of the computes attributes like cpu/memory usage + +### `Gns3Connector.get_compute()` + +```python +def get_compute(self, compute_id="local") +``` + +Returns a compute. + +**Returns:** + +Dictionary of the compute attributes like cpu/memory usage + +### `Gns3Connector.get_compute_images()` + +```python +def get_compute_images(self, emulator, compute_id="local") +``` + +Returns a list of images available for a compute. + +**Required Attributes:** + +- `emulator`: the likes of 'qemu', 'iou', 'docker' ... +- `compute_id` By default is 'local' + +**Returns:** + +List of dictionaries with images available for the compute for the specified +emulator + +### `Gns3Connector.get_compute_ports()` + +```python +def get_compute_ports(self, compute_id="local") +``` + +Returns ports used and configured by a compute. + +**Required Attributes:** + +- `compute_id` By default is 'local' + +**Returns:** + +Dictionary of `console_ports` used and range, as well as the `udp_ports` + ## `Link` Objects GNS3 Link API object. For more information visit: [Links Endpoint API information]( @@ -868,6 +970,8 @@ Returns the Node object by searching for the `name` or the `node_id`. - `project_id` - `connector` + +**Required keyword arguments:** - `name` or `node_id` **NOTE:** Run method `get_nodes()` manually to refresh list of nodes if @@ -931,3 +1035,66 @@ port) - `port_b`: Port name of the B side (must match the `name` attribute of the port) +### `Project.get_snapshots()` + +```python +def get_snapshots(self) +``` + +Retrieves list of snapshots of the project + +**Required Project instance attributes:** + +- `project_id` +- `connector` + +### `Project.get_snapshot()` + +```python +def get_snapshot(self, name=None, snapshot_id=None) +``` + +Returns the Snapshot by searching for the `name` or the `snapshot_id`. + +**Required Attributes:** + +- `project_id` +- `connector` + +**Required keyword arguments:** +- `name` or `snapshot_id` + +### `Project.create_snapshot()` + +```python +def create_snapshot(self, name) +``` + +Creates a snapshot of the project + +**Required Project instance attributes:** + +- `project_id` +- `connector` + +**Required keyword aguments:** + +- `name` + +### `Project.delete_snapshot()` + +```python +def delete_snapshot(self, snapshot_id) +``` + +Deletes a snapshot of the project + +**Required Project instance attributes:** + +- `project_id` +- `connector` + +**Required keyword aguments:** + +- `snapshot_id` + diff --git a/docs/content/index.md b/docs/content/index.md index 59cdf6c..ad3298a 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -9,6 +9,9 @@ Its main objective is to interact with the GNS3 server in a programatic way, so Here are some examples where gns3fy is used in a programmatic way: - [Ansible-collection-gns3](https://galaxy.ansible.com/davidban77/gns3): Useful for CI/CD pipelines to interact with GNS3 server using Ansible. It can create/delete projects, nodes and links in an ansible playbook. +- [Migrate templates between GNS3 servers](user-guide.md#migrate-templates-between-gns3-servers) +- [Check server usage](user-guide.md#check-server-cpu-and-memory-usage) before turning up resource-hungry nodes +- [Manipulate project snapshots](user-guide.md#create-and-list-project-snapshots) like create, delete or list the snapshots configured for the project. ## Install diff --git a/docs/content/user-guide.md b/docs/content/user-guide.md index 2b71ac6..5371e35 100644 --- a/docs/content/user-guide.md +++ b/docs/content/user-guide.md @@ -525,3 +525,146 @@ Frame Relay switch dd0f6f3a-ba58-3249-81cb-a1dd88407a47 frame_relay_switch Tr ATM switch aaa764e2-b383-300f-8a0e-3493bbfdb7d2 atm_switch True N/A switch """ ``` + +### Migrate templates between GNS3 servers + +You may need to backup your current templates configuration of one server to another, or just want to standardize the templates used across your GNS3 server. + +Here is a simple script that shows an example of how to achive it with `Gns3Connector`. + +`migrate_gns3_templates.py` + +```python +from gns3fy import Gns3Connector + +OLD_URL = "http://gns3server01:3080" +NEW_URL = "http://gns3server02:3080" + +def main() + # Define the server objects + old_server = Gns3Connector(url=OLD_URL) + new_server = Gns3Connector(url=NEW_URL) + + # Retrive their current templates + old_server_templates = old_server.get_templates() + new_server_templates = new_server.get_templates() + + # Now pass the templates + for template in old_server_templates: + # Bypass templates already configured on new server + if any(template["name"] == x["name"] for x in new_server_templates): + print(f"Template: {template['name']} already present. Skipping...") + continue + + # Pass template + new_server.create_template(**template) + print(f"Template: {template['name']} passed!") + +if __name__ == '__main__': + main() +``` + +It can produce an output similar to this: + +`python migrate_gns3_templates.py` + +``` +Template: vEOS-4.21.5F passed! +Template: Junos vMX passed! +Template: alpine passed! +Template: Cloud already present. Skipping... +Template: NAT already present. Skipping... +Template: VPCS already present. Skipping... +Template: Ethernet switch already present. Skipping... +Template: Ethernet hub already present. Skipping... +Template: Frame Relay switch already present. Skipping... +Template: ATM switch already present. Skipping... +Template: netautomator passed! +Template: Cisco IOSv 15.7(3)M3 passed! +Template: Cisco IOSvL2 15.2.1 passed! +Template: Cisco IOSvL2 15.2(20170321:233949) passed! +Template: Cisco IOS XRv 9000 6.5.1 passed! +Template: Cisco IOS XRv 6.1.3 passed! +Template: Cisco NX-OSv 7.3.0 passed! +Template: Cisco ASAv 9.9.2 passed! +Template: Cisco CSR1000v 16.9.4 passed! +Template: Cumulus VX 3.7.8 passed! +Template: Cisco NX-OSv 9000 7.0.3.I7.4 passed! +Template: Arista vEOS 4.21.5F passed! +``` + +### Check server CPU and Memory usage + +You can use the `get_compute` method to retrieve information about the GNS3 server that is running the emulations. + +Here is an example of getting the CPU and Memory average usage for a period of time and use that information to determine if a hungre service router can be turned on. + +```python +import time +from gns3fy import Gns3Connector, Project + +server = Gns3Connector(url="http://gns3server") + +lab = Project(name="test_lab", connector=server) + +lab.get() + +hungry_router = lab.get_node(name="hungry_router") + +# Get the CPU and Memory usage and calculate its average +cpu_usage = [] +memory_usage = [] +for index in range(5): + compute_attrs = server.get_compute(compute_id="local") + cpu_usage.append(compute_attrs.get("cpu_usage_percent")) + memory_usage.append(compute_attrs.get("memory_usage_percent")) + time.sleep(1) + +cpu_avg = round(sum(cpu_usage) / len(cpu_usage), 2) +mem_avg = round(sum(memory_usage) / len(memory_usage), 2) + +# If CPU is less than 40% and Memory is less than 50% turnup the nodes +if cpu_avg <= 40.0 and mem_avg <= 50.0: + hungry_router.start() + print("All good! starting hungry router") +else: + print( + f"Hungry router does not have enough resources. CPU avg: {cpu_avg}%" + f" Memory avg: {mem_avg}%" + ) +``` + +### Create and list project snapshots + +There is an attribute called `snapshots` under the `Project` instance, which stores snapshots information about that project. + +You can create, delete and also search for specific snapshots of a project. See the [API reference](api_reference.md#projectget_snapshots) + +Here is a snippet that creates and shows information about the snapshots configured on a project. + +```python +from datetime import datetime +from gns3fy import Gns3Connector, Project + +lab = Project(name="test3", connector=Gns3Connector(url="http://gns3server01:3080")) + +lab.get() + +# Create snapshot +lab.create_snapshot(name="snap3") + +# Show configured snapshots +for snapshot in lab.snapshots: + _time = datetime.utcfromtimestamp(snapshot['created_at']).strftime('%Y-%m-%d %H:%M:%S') + print(f"Snapshot: {snapshot['name']}, created at: {_time}") +``` + +It prints something similar to this: + +``` +Created snapshot: snap3 + +Snapshot: snap1, created at: 2019-09-28 20:59:50 +Snapshot: snap2, created at: 2019-09-28 20:59:54 +Snapshot: snap3, created at: 2019-09-29 08:44:28 +``` diff --git a/gns3fy/gns3fy.py b/gns3fy/gns3fy.py index 88c6a5d..8ced79b 100644 --- a/gns3fy/gns3fy.py +++ b/gns3fy/gns3fy.py @@ -262,6 +262,68 @@ def get_template(self, name=None, template_id=None): else: raise ValueError("Must provide either a name or template_id") + def update_template(self, name=None, template_id=None, **kwargs): + """ + Updates a template by giving its name or UUID. For more information [API INFO] + (http://api.gns3.net/en/2.2/api/v2/controller/template/ + templatestemplateid.html#put-v2-templates-template-id) + + **Required Attributes:** + + - `name` or `template_id` + """ + _template = self.get_template(name=name, template_id=template_id) + _template.update(**kwargs) + + response = self.http_call( + "put", + url=f"{self.base_url}/templates/{_template['template_id']}", + json_data=_template, + ) + + return response.json() + + def create_template(self, **kwargs): + """ + Creates a template by giving its attributes. For more information [API INFO] + (http://api.gns3.net/en/2.2/api/v2/controller/template/ + templates.html#post-v2-templates) + + **Required Attributes:** + + - `name` + - `compute_id` by default is 'local' + - `template_type` + """ + _template = self.get_template(name=kwargs["name"]) + if _template: + raise ValueError(f"Template already used: {kwargs['name']}") + + if "compute_id" not in kwargs: + kwargs["compute_id"] = "local" + + response = self.http_call( + "post", url=f"{self.base_url}/templates", json_data=kwargs + ) + + return response.json() + + def delete_template(self, name=None, template_id=None): + """ + Deletes a template by giving its attributes. For more information [API INFO] + (http://api.gns3.net/en/2.2/api/v2/controller/template/ + templatestemplateid.html#id16) + + **Required Attributes:** + + - `name` or `template_id` + """ + if name and not template_id: + _template = self.get_template(name=name) + template_id = _template["template_id"] + + self.http_call("delete", url=f"{self.base_url}/templates/{template_id}") + def get_nodes(self, project_id): """ Retieves the nodes defined on the project @@ -339,6 +401,60 @@ def delete_project(self, project_id): self.http_call("delete", _url) return + def get_computes(self): + """ + Returns a list of computes. + + **Returns:** + + List of dictionaries of the computes attributes like cpu/memory usage + """ + _url = f"{self.base_url}/computes" + return self.http_call("get", _url).json() + + def get_compute(self, compute_id="local"): + """ + Returns a compute. + + **Returns:** + + Dictionary of the compute attributes like cpu/memory usage + """ + _url = f"{self.base_url}/computes/{compute_id}" + return self.http_call("get", _url).json() + + def get_compute_images(self, emulator, compute_id="local"): + """ + Returns a list of images available for a compute. + + **Required Attributes:** + + - `emulator`: the likes of 'qemu', 'iou', 'docker' ... + - `compute_id` By default is 'local' + + **Returns:** + + List of dictionaries with images available for the compute for the specified + emulator + """ + _url = f"{self.base_url}/computes/{compute_id}/{emulator}/images" + return self.http_call("get", _url).json() + + def get_compute_ports(self, compute_id="local"): + """ + Returns ports used and configured by a compute. + + **Required Attributes:** + + - `compute_id` By default is 'local' + + **Returns:** + + Dictionary of `console_ports` used and range, as well as the `udp_ports` + """ + _url = f"{self.base_url}/computes/{compute_id}/ports" + return self.http_call("get", _url).json() + @dataclass(config=Config) class Link: @@ -989,6 +1105,7 @@ class Project: zoom: Optional[int] = None stats: Optional[Dict[str, Any]] = None + snapshots: Optional[List[Dict]] = 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) @@ -1048,6 +1165,8 @@ def get(self, get_links=True, get_nodes=True, get_stats=True): if get_stats: self.get_stats() + if self.stats.get("snapshots", 0) > 0: + self.get_snapshots() if get_nodes: self.get_nodes() if get_links: @@ -1495,6 +1614,8 @@ def get_node(self, name=None, node_id=None): - `project_id` - `connector` + + **Required keyword arguments:** - `name` or `node_id` **NOTE:** Run method `get_nodes()` manually to refresh list of nodes if @@ -1639,3 +1760,99 @@ def create_link(self, node_a, port_a, node_b, port_b): _link.create() self.links.append(_link) print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}") + + def get_snapshots(self): + """ + Retrieves list of snapshots of the project + + **Required Project instance attributes:** + + - `project_id` + - `connector` + """ + self._verify_before_action() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/snapshots" + + response = self.connector.http_call("get", _url) + self.snapshots = response.json() + + def _search_snapshot(self, key, value): + "Performs a search based on a key and value" + if not self.snapshots: + self.get_snapshots() + + try: + return next(_p for _p in self.snapshots if _p[key] == value) + except StopIteration: + return None + + def get_snapshot(self, name=None, snapshot_id=None): + """ + Returns the Snapshot by searching for the `name` or the `snapshot_id`. + + **Required Attributes:** + + - `project_id` + - `connector` + + **Required keyword arguments:** + - `name` or `snapshot_id` + """ + if snapshot_id: + return self._search_snapshot(key="snapshot_id", value=snapshot_id) + elif name: + return self._search_snapshot(key="name", value=name) + else: + raise ValueError("name or snapshot_id must be provided") + + def create_snapshot(self, name): + """ + Creates a snapshot of the project + + **Required Project instance attributes:** + + - `project_id` + - `connector` + + **Required keyword aguments:** + + - `name` + """ + self._verify_before_action() + + if not self.snapshots: + self.get_snapshots() + + _url = f"{self.connector.base_url}/projects/{self.project_id}/snapshots" + + response = self.connector.http_call("post", _url, json_data=dict(name=name)) + + _snapshot = response.json() + + self.snapshots.append(_snapshot) + print(f"Created snapshot: {_snapshot['name']}") + + def delete_snapshot(self, snapshot_id): + """ + Deletes a snapshot of the project + + **Required Project instance attributes:** + + - `project_id` + - `connector` + + **Required keyword aguments:** + + - `snapshot_id` + """ + self._verify_before_action() + + _url = ( + f"{self.connector.base_url}/projects/{self.project_id}/snapshots/" + f"{snapshot_id}" + ) + + self.connector.http_call("delete", _url) + + self.get_snapshots() diff --git a/poetry.lock b/poetry.lock index 60af7fc..1c5df88 100644 --- a/poetry.lock +++ b/poetry.lock @@ -289,7 +289,7 @@ description = "Toolbox with useful Python classes and type magic." name = "nr.types" optional = false python-versions = "*" -version = "3.0.1" +version = "3.1.0" [package.dependencies] six = "*" @@ -416,6 +416,7 @@ six = ">=0.11.0" reference = "468cace9378a64a267848c07c21a5aedbfab2cf3" type = "git" url = "https://github.com/NiklasRosenstein/pydoc-markdown.git" + [[package]] category = "dev" description = "passive checker of Python programs" @@ -644,7 +645,7 @@ mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "d mkdocs = ["17d34329aad75d5de604b9ed4e31df3a4d235afefdc46ce7b1964fddb2e1e939", "8cc8b38325456b9e942c981a209eaeb1e9f3f77b493ad755bfef889b9c8d356a"] mkdocs-cinder = ["0623b3b8c3a70fb735e966da805ff6f33e6c547ae9b26e6146d4995053e182f5", "984ba6df20916240647e5386d8444e6dbf3a8200e4d6db2d58cb145f40f99ff2"] more-itertools = ["409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"] -"nr.types" = ["18e083324d77d74f889fd8ca6b57586a85157d8eb2b3930d04a67ab524e86c9a"] +"nr.types" = ["0a01cc2e29bc7df3925bbc7bcd8bddd171ff77d39fcd479a314ff751ca5989c8"] packaging = ["a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9", "c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe"] parso = ["63854233e1fadb5da97f2744b6b24346d2750b85965e7e399bec1620232797dc", "666b0ee4a7a1220f65d367617f2cd3ffddff3e205f3f16a0284df30e774c2a9c"] pexpect = ["2094eefdfcf37a1fdbfb9aa090862c1a4878e5c7e0e7e7088bdb511c558e5cd1", "9e2c1fd0e6ee3a49b28f95d4b33bc389c89b20af6a1255906e90ff1262ce62eb"] diff --git a/pyproject.toml b/pyproject.toml index 1f710a8..0b5553b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "gns3fy" -version = "0.4.1" +version = "0.5.0" description = "Python wrapper around GNS3 Server API" authors = ["David Flores "] license = "MIT" diff --git a/tests/data/compute_ports.json b/tests/data/compute_ports.json new file mode 100644 index 0000000..f88975a --- /dev/null +++ b/tests/data/compute_ports.json @@ -0,0 +1,98 @@ +{ + "console_port_range": [ + 5000, + 10000 + ], + "console_ports": [ + 5000, + 5001, + 5002, + 5003, + 5004, + 5005, + 5006, + 5007, + 5008, + 5009, + 5010, + 5011, + 5012, + 5013, + 5014, + 5015, + 5016, + 5017, + 5018, + 5019, + 5020, + 5021, + 5022, + 5023, + 5024, + 5025, + 5026, + 5027, + 5028, + 5029, + 5056, + 5058 + ], + "udp_port_range": [ + 10000, + 20000 + ], + "udp_ports": [ + 10000, + 10001, + 10002, + 10003, + 10004, + 10005, + 10006, + 10007, + 10008, + 10009, + 10010, + 10011, + 10012, + 10013, + 10014, + 10015, + 10016, + 10017, + 10018, + 10019, + 10020, + 10021, + 10022, + 10023, + 10024, + 10025, + 10026, + 10027, + 10028, + 10029, + 10030, + 10031, + 10032, + 10033, + 10034, + 10035, + 10039, + 10040, + 10041, + 10042, + 10043, + 10044, + 10045, + 10046, + 10047, + 10048, + 10049, + 10050, + 10051, + 10052, + 10053, + 10054 + ] +} diff --git a/tests/data/compute_qemu_images.json b/tests/data/compute_qemu_images.json new file mode 100644 index 0000000..922935e --- /dev/null +++ b/tests/data/compute_qemu_images.json @@ -0,0 +1,26 @@ +[ + { + "filename": "cumulus-linux-3.7.8-vx-amd64-qemu.qcow2", + "filesize": 619249664, + "md5sum": "20d59d1f28321908fcb80f123456789f", + "path": "cumulus-linux-3.7.8-vx-amd64-qemu.qcow2" + }, + { + "filename": "iosxrv-k9-demo-6.1.3.qcow2", + "filesize": 428588544, + "md5sum": "1693b5d22a398587dd0fed123456789f", + "path": "iosxrv-k9-demo-6.1.3.qcow2" + }, + { + "filename": "nxosv.9.3.1.qcow2", + "filesize": 1435041792, + "md5sum": "148fd38cb1ff78df2883f8123456789f", + "path": "nxosv.9.3.1.qcow2" + }, + { + "filename": "vEOS-lab-4.21.5F.vmdk", + "filesize": 383778816, + "md5sum": "5bfd3f1b7b994c73084a38123456789f", + "path": "vEOS-lab-4.21.5F.vmdk" + } +] diff --git a/tests/data/computes.json b/tests/data/computes.json new file mode 100644 index 0000000..77ccecd --- /dev/null +++ b/tests/data/computes.json @@ -0,0 +1,34 @@ +[ + { + "capabilities": { + "node_types": [ + "cloud", + "ethernet_hub", + "ethernet_switch", + "nat", + "vpcs", + "virtualbox", + "dynamips", + "frame_relay_switch", + "atm_switch", + "qemu", + "vmware", + "traceng", + "docker", + "iou" + ], + "platform": "linux", + "version": "2.2.0b4" + }, + "compute_id": "local", + "connected": true, + "cpu_usage_percent": 0.3, + "host": "127.0.0.1", + "last_error": null, + "memory_usage_percent": 3.0, + "name": "Main server", + "port": 3080, + "protocol": "http", + "user": null + } +] diff --git a/tests/data/project_snapshots.json b/tests/data/project_snapshots.json new file mode 100644 index 0000000..d7cc2e6 --- /dev/null +++ b/tests/data/project_snapshots.json @@ -0,0 +1,14 @@ +[ + { + "created_at": 1569707990, + "name": "snap1", + "project_id": "4b21dfb3-675a-4efa-8613-2f7fb32e76fe", + "snapshot_id": "7fb725fd-efbf-4e90-a259-95f12addf5a2" + }, + { + "created_at": 1569707994, + "name": "snap2", + "project_id": "4b21dfb3-675a-4efa-8613-2f7fb32e76fe", + "snapshot_id": "44e08d78-0ee4-4b8f-bad4-117aa67cb759" + } +] diff --git a/tests/data/projects.py b/tests/data/projects.py index 1f9919d..d78e07d 100644 --- a/tests/data/projects.py +++ b/tests/data/projects.py @@ -6,7 +6,7 @@ "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=" "False, snap_to_grid=False, supplier=None, variables=None, zoom=100, stats=" - "None)" + "None, snapshots=None)" ), ( "Project(name='API_TEST', project_id='4b21dfb3-675a-4efa-8613-2f7fb32e76fe', " @@ -15,6 +15,6 @@ "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=None)" + " stats=None, snapshots=None)" ), ] diff --git a/tests/test_api.py b/tests/test_api.py index 38f4019..403ce32 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -15,6 +15,8 @@ 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"} +CCOMPUTE = {"id": "local"} +CIMAGE = {"filename": "vEOS-lab-4.21.5F.vmdk"} def links_data(): @@ -35,6 +37,12 @@ def projects_data(): return data +def projects_snaphot_data(): + with open(DATA_FILES / "project_snapshots.json") as fdata: + data = json.load(fdata) + return data + + def templates_data(): with open(DATA_FILES / "templates.json") as fdata: data = json.load(fdata) @@ -47,6 +55,24 @@ def version_data(): return data +def computes_data(): + with open(DATA_FILES / "computes.json") as fdata: + data = json.load(fdata) + return data + + +def compute_qemu_images_data(): + with open(DATA_FILES / "compute_qemu_images.json") as fdata: + data = json.load(fdata) + return data + + +def compute_ports_data(): + with open(DATA_FILES / "compute_ports.json") as fdata: + data = json.load(fdata) + return data + + def files_data(): with open(DATA_FILES / "files.txt") as fdata: data = fdata.read() @@ -68,6 +94,11 @@ def json_api_test_template(): return next((_t for _t in templates_data() if _t["template_id"] == CTEMPLATE["id"])) +def json_api_test_compute(): + "Fetches the alpine template response" + return next((_t for _t in computes_data() if _t["compute_id"] == CCOMPUTE["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"])) @@ -112,6 +143,14 @@ def post_put_matcher(request): elif request.path_url.endswith(f"/{CPROJECT['id']}/files/README.txt"): resp.status_code = 200 return resp + elif request.path_url.endswith(f"/{CPROJECT['id']}/snapshots"): + _data = request.json() + if _data.get("name") == "snap2": + _returned = projects_snaphot_data()[-1] + resp.json = lambda: _returned + resp.status_code = 201 + return resp + 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_type")): @@ -196,6 +235,12 @@ def post_put_matcher(request): _returned.update(link_id="NEW_LINK_ID") resp.json = lambda: _returned return resp + elif request.path_url.endswith("/templates"): + _data = request.json() + if _data["name"] == "alpinev2": + resp.status_code = 201 + resp.json = lambda: _data + return resp elif request.method == "PUT": if request.path_url.endswith(f"/{CPROJECT['id']}"): _data = request.json() @@ -222,6 +267,12 @@ def post_put_matcher(request): resp.status_code = 200 resp.json = lambda: _returned return resp + elif request.path_url.endswith(f"/templates/{CTEMPLATE['id']}"): + _data = request.json() + if _data["category"] == "switch": + resp.status_code = 200 + resp.json = lambda: _data + return resp return None @@ -243,16 +294,33 @@ def _apply_responses(self): self.adapter.register_uri( "GET", f"{self.base_url}/version", json=version_data() ) - # Templates + ############ + # Computes # + ############ + self.adapter.register_uri( + "GET", f"{self.base_url}/computes", json=computes_data() + ) + self.adapter.register_uri( + "GET", f"{self.base_url}/computes/local", json=json_api_test_compute() + ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/computes/local/qemu/images", + json=compute_qemu_images_data(), + ) + self.adapter.register_uri( + "GET", f"{self.base_url}/computes/local/ports", json=compute_ports_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"]) - ), + json=json_api_test_template(), ) self.adapter.register_uri( "GET", @@ -260,6 +328,9 @@ def _apply_responses(self): json={"message": "Template ID 7777-4444-0000 doesn't exist", "status": 404}, status_code=404, ) + self.adapter.register_uri( + "DELETE", f"{self.base_url}/templates/{CTEMPLATE['id']}", status_code=204 + ) ############ # Projects # ############ @@ -274,7 +345,7 @@ def _apply_responses(self): self.adapter.register_uri( "GET", f"{self.base_url}/projects/{CPROJECT['id']}/stats", - json={"drawings": 0, "links": 4, "nodes": 6, "snapshots": 0}, + json={"drawings": 0, "links": 4, "nodes": 6, "snapshots": 2}, ) # Get a project README file info self.adapter.register_uri( @@ -289,11 +360,29 @@ def _apply_responses(self): json={"message": f"404: Not found", "status": 404}, status_code=404, ) + self.adapter.register_uri( + "GET", + f"{self.base_url}/projects/{CPROJECT['id']}/snapshots", + json=projects_snaphot_data(), + status_code=200, + ) + self.adapter.register_uri( + "DELETE", + f"{self.base_url}/projects/{CPROJECT['id']}/snapshots/" + "44e08d78-0ee4-4b8f-bad4-117aa67cb759", + status_code=204, + ) + self.adapter.register_uri( + "DELETE", + f"{self.base_url}/projects/{CPROJECT['id']}/snapshots/dummmy", + json={"message": "Snapshot ID dummy doesn't exist", "status": 404}, + status_code=404, + ) # Extra project self.adapter.register_uri( "GET", f"{self.base_url}/projects/c9dc56bf-37b9-453b-8f95-2845ce8908e3/stats", - json={"drawings": 0, "links": 9, "nodes": 10, "snapshots": 0}, + json={"drawings": 0, "links": 9, "nodes": 10, "snapshots": 2}, ) self.adapter.register_uri( "POST", @@ -490,6 +579,36 @@ def test_error_template_name_not_found(self, gns3_server): response = gns3_server.get_template(name="NOTE_FOUND") assert response is None + def test_create_template(self, gns3_server): + new_template = json_api_test_template() + assert new_template["name"] == "alpine" + # Create a new template from previous one + new_template["name"] = "alpinev2" + new_template.pop("compute_id") + # Create template + response = gns3_server.create_template(**new_template) + assert response["name"] == "alpinev2" + assert response["template_type"] == "docker" + assert response["category"] == "guest" + + def test_error_create_template_already_used(self, gns3_server): + with pytest.raises(ValueError, match="Template already used: alpine"): + gns3_server.create_template(**json_api_test_template()) + + def test_update_template(self, gns3_server): + template = json_api_test_template() + assert template["category"] == "guest" + # Change an attribute + template["category"] = "switch" + # Update + response = gns3_server.update_template(**template) + assert response["name"] == "alpine" + assert response["category"] == "switch" + + def test_delete_template(self, gns3_server): + response = gns3_server.delete_template(name=CTEMPLATE["name"]) + assert response is None + def test_get_projects(self, gns3_server): response = gns3_server.get_projects() for index, n in enumerate( @@ -655,6 +774,37 @@ def test_templates_summary_print(self, capsys, gns3_server): "-- Console: N/A -- Category: switch\n" ) + def test_get_computes(self, gns3_server): + response = gns3_server.get_computes() + assert isinstance(response, list) + assert response[0]["compute_id"] == "local" + assert response[0]["host"] == "127.0.0.1" + assert response[0]["name"] == "Main server" + + def test_get_compute(self, gns3_server): + response = gns3_server.get_compute(compute_id="local") + assert response["compute_id"] == "local" + assert response["host"] == "127.0.0.1" + assert response["name"] == "Main server" + + def test_get_compute_qemu_images(self, gns3_server): + response = gns3_server.get_compute_images(emulator="qemu", compute_id="local") + for index, n in enumerate( + [ + ("cumulus-linux-3.7.8-vx-amd64-qemu.qcow2", 619_249_664), + ("iosxrv-k9-demo-6.1.3.qcow2", 428_588_544), + ("nxosv.9.3.1.qcow2", 1_435_041_792), + ("vEOS-lab-4.21.5F.vmdk", 383_778_816), + ] + ): + assert n[0] == response[index]["filename"] + assert n[1] == response[index]["filesize"] + + def test_get_compute_ports(self, gns3_server): + response = gns3_server.get_compute_ports(compute_id="local") + assert response["console_port_range"] == [5000, 10000] + assert response["udp_port_range"] == [10000, 20000] + def test_wrong_server_url(self, gns3_server): gns3_server.base_url = "WRONG URL" with pytest.raises(requests.exceptions.InvalidURL): @@ -996,7 +1146,7 @@ def test_get(self, api_test_project): "drawings": 0, "links": 4, "nodes": 6, - "snapshots": 0, + "snapshots": 2, } == api_test_project.stats @pytest.mark.parametrize( @@ -1045,7 +1195,7 @@ def test_get_stats(self, api_test_project): "drawings": 0, "links": 4, "nodes": 6, - "snapshots": 0, + "snapshots": 2, } == api_test_project.stats def test_get_nodes(self, api_test_project): @@ -1119,6 +1269,7 @@ def test_nodes_summary(self, api_test_project): ) def test_nodes_summary_print(self, capsys, api_test_project): + api_test_project.nodes = [] api_test_project.nodes_summary(is_print=True) captured = capsys.readouterr() assert captured.out == ( @@ -1132,6 +1283,7 @@ def test_nodes_summary_print(self, capsys, api_test_project): ) def test_nodes_inventory(self, api_test_project): + api_test_project.nodes = [] nodes_inventory = api_test_project.nodes_inventory() assert { "server": "gns3server", @@ -1153,6 +1305,8 @@ def test_links_summary(self, api_test_project): ) def test_links_summary_print(self, capsys, api_test_project): + api_test_project.nodes = [] + api_test_project.links = [] api_test_project.links_summary(is_print=True) captured = capsys.readouterr() assert captured.out == ( @@ -1163,6 +1317,7 @@ def test_links_summary_print(self, capsys, api_test_project): ) def test_get_node_by_name(self, api_test_project): + api_test_project.nodes = [] switch = api_test_project.get_node(name="IOU1") assert switch.name == "IOU1" assert switch.status == "started" @@ -1175,21 +1330,24 @@ def test_get_node_by_id(self, api_test_project): assert host.console == 5005 def test_get_link_by_id(self, api_test_project): + api_test_project.links = [] 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.nodes = [] api_test_project.create_node( name="alpine-2", console=5077, template=CTEMPLATE["name"] ) alpine2 = api_test_project.get_node(name="alpine-2") - print(api_test_project.nodes_summary()) assert alpine2.console == 5077 assert alpine2.name == "alpine-2" assert alpine2.node_type == "docker" assert alpine2.node_id == "NEW_NODE_ID" def test_create_link(self, api_test_project): + api_test_project.nodes = [] + api_test_project.links = [] 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" @@ -1233,3 +1391,46 @@ def test_write_file(self, api_test_project): data = "NEW README INFO!\n" r = api_test_project.write_file(path="README.txt", data=data) assert r is None + + def test_get_snapshots(self, api_test_project): + api_test_project.get_snapshots() + assert isinstance(api_test_project.snapshots, list) + assert api_test_project.snapshots[0]["name"] == "snap1" + assert ( + api_test_project.snapshots[0]["snapshot_id"] + == "7fb725fd-efbf-4e90-a259-95f12addf5a2" + ) + + def test_get_snapshot(self, api_test_project): + api_test_project.snapshots = None + snap1 = api_test_project.get_snapshot( + snapshot_id="7fb725fd-efbf-4e90-a259-95f12addf5a2" + ) + assert snap1["name"] == "snap1" + assert snap1["created_at"] == 1_569_707_990 + + def test_error_get_snapshot_not_provided(self, api_test_project): + with pytest.raises(ValueError, match="name or snapshot_id must be provided"): + api_test_project.get_snapshot() + + def test_error_get_snapshot_not_found(self, api_test_project): + dummy = api_test_project.get_snapshot(name="dummy") + assert dummy is None + + def test_create_snapshot(self, api_test_project): + api_test_project.snapshots = None + api_test_project.create_snapshot(name="snap2") + snap2 = api_test_project.get_snapshot(name="snap2") + assert snap2["name"] == "snap2" + assert snap2["snapshot_id"] == "44e08d78-0ee4-4b8f-bad4-117aa67cb759" + assert snap2["created_at"] == 1_569_707_994 + + def test_delete_snapshot(self, api_test_project): + response = api_test_project.delete_snapshot( + "44e08d78-0ee4-4b8f-bad4-117aa67cb759" + ) + assert response is None + + def test_error_delete_snapshot_not_found(self, api_test_project): + with pytest.raises(HTTPError, match="Snapshot ID dummy doesn't exist"): + api_test_project.delete_snapshot(snapshot_id="dummmy")