diff --git a/gns3server/compute/__init__.py b/gns3server/compute/__init__.py index 5d454a86..174a35e2 100644 --- a/gns3server/compute/__init__.py +++ b/gns3server/compute/__init__.py @@ -32,7 +32,12 @@ if sys.platform.startswith("linux") or hasattr(sys, "_called_from_test") or os.e from .docker import Docker MODULES.append(Docker) - # IOU runs only on Linux but testsuite work on UNIX platform + # IOU only runs on Linux but test suite works on UNIX platform if not sys.platform.startswith("win"): from .iou import IOU MODULES.append(IOU) + + # TODO: TraceNG only runs on Windows but test suite works on UNIX platform + #if sys.platform.startswith("win"): + from .traceng import TraceNG + MODULES.append(TraceNG) diff --git a/gns3server/compute/traceng/__init__.py b/gns3server/compute/traceng/__init__.py new file mode 100644 index 00000000..14b3b1fe --- /dev/null +++ b/gns3server/compute/traceng/__init__.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +TraceNG server module. +""" + +import asyncio + +from ..base_manager import BaseManager +from .traceng_error import TraceNGError +from .traceng_vm import TraceNGVM + + +class TraceNG(BaseManager): + + _NODE_CLASS = TraceNGVM + + def __init__(self): + + super().__init__() + + @asyncio.coroutine + def create_node(self, *args, **kwargs): + """ + Creates a new TraceNG VM. + + :returns: TraceNGVM instance + """ + + return (yield from super().create_node(*args, **kwargs)) diff --git a/gns3server/compute/traceng/traceng_error.py b/gns3server/compute/traceng/traceng_error.py new file mode 100644 index 00000000..623f5f59 --- /dev/null +++ b/gns3server/compute/traceng/traceng_error.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Custom exceptions for the TraceNG module. +""" + +from ..error import NodeError + + +class TraceNGError(NodeError): + + pass diff --git a/gns3server/compute/traceng/traceng_vm.py b/gns3server/compute/traceng/traceng_vm.py new file mode 100644 index 00000000..54fac0e4 --- /dev/null +++ b/gns3server/compute/traceng/traceng_vm.py @@ -0,0 +1,410 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +TraceNG VM management in order to run a TraceNG VM. +""" + +import os +import sys +import socket +import subprocess +import signal +import asyncio +import shutil + +from gns3server.utils.asyncio import wait_for_process_termination +from gns3server.utils.asyncio import monitor_process +from gns3server.utils import parse_version + +from .traceng_error import TraceNGError +from ..adapters.ethernet_adapter import EthernetAdapter +from ..nios.nio_udp import NIOUDP +from ..nios.nio_tap import NIOTAP +from ..base_node import BaseNode + + +import logging +log = logging.getLogger(__name__) + + +class TraceNGVM(BaseNode): + module_name = 'traceng' + + """ + TraceNG VM implementation. + + :param name: TraceNG VM name + :param node_id: Node identifier + :param project: Project instance + :param manager: Manager instance + :param console: TCP console port + """ + + def __init__(self, name, node_id, project, manager, console=None): + + super().__init__(name, node_id, project, manager, console=console, wrap_console=True) + self._process = None + self._started = False + self._traceng_stdout_file = "" + self._local_udp_tunnel = None + self._ethernet_adapter = EthernetAdapter() # one adapter with 1 Ethernet interface + + @property + def ethernet_adapter(self): + return self._ethernet_adapter + + @asyncio.coroutine + def close(self): + """ + Closes this TraceNG VM. + """ + + if not (yield from super().close()): + return False + + nio = self._ethernet_adapter.get_nio(0) + if isinstance(nio, NIOUDP): + self.manager.port_manager.release_udp_port(nio.lport, self._project) + + if self._local_udp_tunnel: + self.manager.port_manager.release_udp_port(self._local_udp_tunnel[0].lport, self._project) + self.manager.port_manager.release_udp_port(self._local_udp_tunnel[1].lport, self._project) + self._local_udp_tunnel = None + + yield from self._stop_ubridge() + + if self.is_running(): + self._terminate_process() + + return True + + @asyncio.coroutine + def _check_requirements(self): + """ + Check if TraceNG is available. + """ + + path = self._traceng_path() + if not path: + raise TraceNGError("No path to a TraceNG executable has been set") + + # This raise an error if ubridge is not available + self.ubridge_path + + if not os.path.isfile(path): + raise TraceNGError("TraceNG program '{}' is not accessible".format(path)) + + if not os.access(path, os.X_OK): + raise TraceNGError("TraceNG program '{}' is not executable".format(path)) + + def __json__(self): + + return {"name": self.name, + "node_id": self.id, + "node_directory": self.working_path, + "status": self.status, + "console": self._console, + "console_type": "telnet", + "project_id": self.project.id, + "command_line": self.command_line} + + def _traceng_path(self): + """ + Returns the TraceNG executable path. + + :returns: path to TraceNG + """ + + search_path = self._manager.config.get_section_config("TraceNG").get("traceng_path", "traceng") + path = shutil.which(search_path) + # shutil.which return None if the path doesn't exists + if not path: + return search_path + return path + + @asyncio.coroutine + def start(self): + """ + Starts the TraceNG process. + """ + + yield from self._check_requirements() + if not self.is_running(): + nio = self._ethernet_adapter.get_nio(0) + command = self._build_command() + try: + log.info("Starting TraceNG: {}".format(command)) + self._traceng_stdout_file = os.path.join(self.working_dir, "traceng.log") + log.info("Logging to {}".format(self._traceng_stdout_file)) + flags = 0 + #if sys.platform.startswith("win32"): + # flags = subprocess.CREATE_NEW_PROCESS_GROUP + with open(self._traceng_stdout_file, "w", encoding="utf-8") as fd: + self.command_line = ' '.join(command) + self._process = yield from asyncio.create_subprocess_exec(*command, + stdout=fd, + stderr=subprocess.STDOUT, + cwd=self.working_dir, + creationflags=flags) + monitor_process(self._process, self._termination_callback) + + yield from self._start_ubridge() + if nio: + yield from self.add_ubridge_udp_connection("TraceNG-{}".format(self._id), self._local_udp_tunnel[1], nio) + + yield from self.start_wrap_console() + + log.info("TraceNG instance {} started PID={}".format(self.name, self._process.pid)) + self._started = True + self.status = "started" + except (OSError, subprocess.SubprocessError) as e: + traceng_stdout = self.read_traceng_stdout() + log.error("Could not start TraceNG {}: {}\n{}".format(self._traceng_path(), e, traceng_stdout)) + raise TraceNGError("Could not start TraceNG {}: {}\n{}".format(self._traceng_path(), e, traceng_stdout)) + + def _termination_callback(self, returncode): + """ + Called when the process has stopped. + + :param returncode: Process returncode + """ + + if self._started: + log.info("TraceNG process has stopped, return code: %d", returncode) + self._started = False + self.status = "stopped" + self._process = None + if returncode != 0: + self.project.emit("log.error", {"message": "TraceNG process has stopped, return code: {}\n{}".format(returncode, self.read_traceng_stdout())}) + + @asyncio.coroutine + def stop(self): + """ + Stops the TraceNG process. + """ + + yield from self._stop_ubridge() + if self.is_running(): + self._terminate_process() + if self._process.returncode is None: + try: + yield from wait_for_process_termination(self._process, timeout=3) + except asyncio.TimeoutError: + if self._process.returncode is None: + try: + self._process.kill() + except OSError as e: + log.error("Cannot stop the TraceNG process: {}".format(e)) + if self._process.returncode is None: + log.warning('TraceNG VM "{}" with PID={} is still running'.format(self._name, self._process.pid)) + + self._process = None + self._started = False + yield from super().stop() + + @asyncio.coroutine + def reload(self): + """ + Reloads the TraceNG process (stop & start). + """ + + yield from self.stop() + yield from self.start() + + def _terminate_process(self): + """ + Terminate the process if running + """ + + log.info("Stopping TraceNG instance {} PID={}".format(self.name, self._process.pid)) + #if sys.platform.startswith("win32"): + # self._process.send_signal(signal.CTRL_BREAK_EVENT) + #else: + try: + self._process.terminate() + # Sometime the process may already be dead when we garbage collect + except ProcessLookupError: + pass + + def read_traceng_stdout(self): + """ + Reads the standard output of the TraceNG process. + Only use when the process has been stopped or has crashed. + """ + + output = "" + if self._traceng_stdout_file: + try: + with open(self._traceng_stdout_file, "rb") as file: + output = file.read().decode("utf-8", errors="replace") + except OSError as e: + log.warning("Could not read {}: {}".format(self._traceng_stdout_file, e)) + return output + + def is_running(self): + """ + Checks if the TraceNG process is running + + :returns: True or False + """ + + if self._process and self._process.returncode is None: + return True + return False + + @asyncio.coroutine + def port_add_nio_binding(self, port_number, nio): + """ + Adds a port NIO binding. + + :param port_number: port number + :param nio: NIO instance to add to the slot/port + """ + + if not self._ethernet_adapter.port_exists(port_number): + raise TraceNGError("Port {port_number} doesn't exist in adapter {adapter}".format(adapter=self._ethernet_adapter, + port_number=port_number)) + + if self.is_running(): + yield from self.add_ubridge_udp_connection("TraceNG-{}".format(self._id), self._local_udp_tunnel[1], nio) + + self._ethernet_adapter.add_nio(port_number, nio) + log.info('TraceNG "{name}" [{id}]: {nio} added to port {port_number}'.format(name=self._name, + id=self.id, + nio=nio, + port_number=port_number)) + + return nio + + @asyncio.coroutine + def port_update_nio_binding(self, port_number, nio): + if not self._ethernet_adapter.port_exists(port_number): + raise TraceNGError("Port {port_number} doesn't exist in adapter {adapter}".format(adapter=self._ethernet_adapter, + port_number=port_number)) + if self.is_running(): + yield from self.update_ubridge_udp_connection("TraceNG-{}".format(self._id), self._local_udp_tunnel[1], nio) + + @asyncio.coroutine + def port_remove_nio_binding(self, port_number): + """ + Removes a port NIO binding. + + :param port_number: port number + + :returns: NIO instance + """ + + if not self._ethernet_adapter.port_exists(port_number): + raise TraceNGError("Port {port_number} doesn't exist in adapter {adapter}".format(adapter=self._ethernet_adapter, + port_number=port_number)) + + if self.is_running(): + yield from self._ubridge_send("bridge delete {name}".format(name="TraceNG-{}".format(self._id))) + + nio = self._ethernet_adapter.get_nio(port_number) + if isinstance(nio, NIOUDP): + self.manager.port_manager.release_udp_port(nio.lport, self._project) + self._ethernet_adapter.remove_nio(port_number) + + log.info('TraceNG "{name}" [{id}]: {nio} removed from port {port_number}'.format(name=self._name, + id=self.id, + nio=nio, + port_number=port_number)) + return nio + + @asyncio.coroutine + def start_capture(self, port_number, output_file): + """ + Starts a packet capture. + + :param port_number: port number + :param output_file: PCAP destination file for the capture + """ + + if not self._ethernet_adapter.port_exists(port_number): + raise TraceNGError("Port {port_number} doesn't exist in adapter {adapter}".format(adapter=self._ethernet_adapter, + port_number=port_number)) + + nio = self._ethernet_adapter.get_nio(0) + + if not nio: + raise TraceNGError("Port {} is not connected".format(port_number)) + + if nio.capturing: + raise TraceNGError("Packet capture is already activated on port {port_number}".format(port_number=port_number)) + + nio.startPacketCapture(output_file) + + if self.ubridge: + yield from self._ubridge_send('bridge start_capture {name} "{output_file}"'.format(name="TraceNG-{}".format(self._id), + output_file=output_file)) + + log.info("TraceNG '{name}' [{id}]: starting packet capture on port {port_number}".format(name=self.name, + id=self.id, + port_number=port_number)) + + @asyncio.coroutine + def stop_capture(self, port_number): + """ + Stops a packet capture. + + :param port_number: port number + """ + + if not self._ethernet_adapter.port_exists(port_number): + raise TraceNGError("Port {port_number} doesn't exist in adapter {adapter}".format(adapter=self._ethernet_adapter, + port_number=port_number)) + + nio = self._ethernet_adapter.get_nio(0) + + if not nio: + raise TraceNGError("Port {} is not connected".format(port_number)) + + nio.stopPacketCapture() + + if self.ubridge: + yield from self._ubridge_send('bridge stop_capture {name}'.format(name="TraceNG-{}".format(self._id))) + + log.info("TraceNG '{name}' [{id}]: stopping packet capture on port {port_number}".format(name=self.name, + id=self.id, + port_number=port_number)) + + def _build_command(self): + """ + Command to start the TraceNG process. + (to be passed to subprocess.Popen()) + """ + + command = [self._traceng_path()] + + #command.extend(["-p", str(self._internal_console_port)]) # listen to console port + + # use the local UDP tunnel to uBridge instead + if not self._local_udp_tunnel: + self._local_udp_tunnel = self._create_local_udp_tunnel() + nio = self._local_udp_tunnel[0] + if nio and isinstance(nio, NIOUDP): + # UDP tunnel + command.extend(["-s", str(nio.lport)]) # source UDP port + command.extend(["-c", str(nio.rport)]) # destination UDP port + try: + command.extend(["-t", socket.gethostbyname(nio.rhost)]) # destination host, we need to resolve the hostname because TraceNG doesn't support it + except socket.gaierror as e: + raise TraceNGError("Can't resolve hostname {}: {}".format(nio.rhost, e)) + + return command diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 5c914f57..60683e00 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -117,6 +117,9 @@ class Controller: for vm in self._settings.get("VPCS", {}).get("nodes", []): vm["node_type"] = "vpcs" vms.append(vm) + for vm in self._settings.get("TraceNG", {}).get("nodes", []): + vm["node_type"] = "traceng" + vms.append(vm) for vm in vms: # remove deprecated properties @@ -151,6 +154,7 @@ class Controller: builtins.append(Appliance(uuid.uuid3(uuid.NAMESPACE_DNS, "cloud"), {"node_type": "cloud", "name": "Cloud", "category": 2, "symbol": ":/symbols/cloud.svg"}, builtin=True)) builtins.append(Appliance(uuid.uuid3(uuid.NAMESPACE_DNS, "nat"), {"node_type": "nat", "name": "NAT", "category": 2, "symbol": ":/symbols/cloud.svg"}, builtin=True)) builtins.append(Appliance(uuid.uuid3(uuid.NAMESPACE_DNS, "vpcs"), {"node_type": "vpcs", "name": "VPCS", "default_name_format": "PC-{0}", "category": 2, "symbol": ":/symbols/vpcs_guest.svg", "properties": {"base_script_file": "vpcs_base_config.txt"}}, builtin=True)) + builtins.append(Appliance(uuid.uuid3(uuid.NAMESPACE_DNS, "traceng"), {"node_type": "traceng", "name": "TraceNG", "default_name_format": "TraceNG-{0}", "category": 2, "symbol": ":/symbols/vpcs_guest.svg", "properties": {}}, builtin=True)) # TODO: change default symbol builtins.append(Appliance(uuid.uuid3(uuid.NAMESPACE_DNS, "ethernet_switch"), {"node_type": "ethernet_switch", "name": "Ethernet switch", "category": 1, "symbol": ":/symbols/ethernet_switch.svg"}, builtin=True)) builtins.append(Appliance(uuid.uuid3(uuid.NAMESPACE_DNS, "ethernet_hub"), {"node_type": "ethernet_hub", "name": "Ethernet hub", "category": 1, "symbol": ":/symbols/hub.svg"}, builtin=True)) builtins.append(Appliance(uuid.uuid3(uuid.NAMESPACE_DNS, "frame_relay_switch"), {"node_type": "frame_relay_switch", "name": "Frame Relay switch", "category": 1, "symbol": ":/symbols/frame_relay_switch.svg"}, builtin=True)) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index fc3febb4..9f7ac83a 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -414,6 +414,7 @@ class Link: """ for node in self._nodes: if node["node"].node_type in ('vpcs', + 'traceng', 'vmware', 'dynamips', 'qemu', diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index f960a8cf..92dfe316 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -121,7 +121,7 @@ class Node: return self.node_type not in ( "qemu", "docker", "dynamips", "vpcs", "vmware", "virtualbox", - "iou") + "iou", "traceng") @property def id(self): @@ -622,7 +622,7 @@ class Node: for port in self._properties["ports_mapping"]: self._ports.append(PortFactory(port["name"], 0, 0, port_number, "ethernet", short_name="e{}".format(port_number))) port_number += 1 - elif self._node_type in ("vpcs"): + elif self._node_type in ("vpcs", "traceng"): self._ports.append(PortFactory("Ethernet0", 0, 0, 0, "ethernet", short_name="e0")) elif self._node_type in ("cloud", "nat"): port_number = 0 diff --git a/gns3server/handlers/api/compute/__init__.py b/gns3server/handlers/api/compute/__init__.py index e255769a..e8a1f4ba 100644 --- a/gns3server/handlers/api/compute/__init__.py +++ b/gns3server/handlers/api/compute/__init__.py @@ -36,7 +36,11 @@ from .frame_relay_switch_handler import FrameRelaySwitchHandler from .atm_switch_handler import ATMSwitchHandler if sys.platform.startswith("linux") or hasattr(sys, "_called_from_test") or os.environ.get("PYTEST_BUILD_DOCUMENTATION") == "1": - # IOU runs only on Linux but test suite works on UNIX platform + # IOU only runs on Linux but test suite works on UNIX platform if not sys.platform.startswith("win"): from .iou_handler import IOUHandler from .docker_handler import DockerHandler + + # TODO: TraceNG only runs on Windows but test suite works on UNIX platform + #if sys.platform.startswith("win"): + from .traceng_handler import TraceNGHandler diff --git a/gns3server/handlers/api/compute/traceng_handler.py b/gns3server/handlers/api/compute/traceng_handler.py new file mode 100644 index 00000000..44ab8809 --- /dev/null +++ b/gns3server/handlers/api/compute/traceng_handler.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +from aiohttp.web import HTTPConflict +from gns3server.web.route import Route +from gns3server.schemas.nio import NIO_SCHEMA +from gns3server.schemas.node import NODE_CAPTURE_SCHEMA +from gns3server.compute.traceng import TraceNG + +from gns3server.schemas.traceng import ( + TRACENG_CREATE_SCHEMA, + TRACENG_UPDATE_SCHEMA, + TRACENG_OBJECT_SCHEMA +) + + +class TraceNGHandler: + """ + API entry points for TraceNG. + """ + + @Route.post( + r"/projects/{project_id}/traceng/nodes", + parameters={ + "project_id": "Project UUID" + }, + status_codes={ + 201: "Instance created", + 400: "Invalid request", + 409: "Conflict" + }, + description="Create a new TraceNG instance", + input=TRACENG_CREATE_SCHEMA, + output=TRACENG_OBJECT_SCHEMA) + def create(request, response): + + traceng = TraceNG.instance() + vm = yield from traceng.create_node(request.json["name"], + request.match_info["project_id"], + request.json.get("node_id"), + console=request.json.get("console")) + response.set_status(201) + response.json(vm) + + @Route.get( + r"/projects/{project_id}/traceng/nodes/{node_id}", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID" + }, + status_codes={ + 200: "Success", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Get a TraceNG instance", + output=TRACENG_OBJECT_SCHEMA) + def show(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + response.json(vm) + + @Route.put( + r"/projects/{project_id}/traceng/nodes/{node_id}", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID" + }, + status_codes={ + 200: "Instance updated", + 400: "Invalid request", + 404: "Instance doesn't exist", + 409: "Conflict" + }, + description="Update a TraceNG instance", + input=TRACENG_UPDATE_SCHEMA, + output=TRACENG_OBJECT_SCHEMA) + def update(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + vm.name = request.json.get("name", vm.name) + vm.console = request.json.get("console", vm.console) + vm.updated() + response.json(vm) + + @Route.delete( + r"/projects/{project_id}/traceng/nodes/{node_id}", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID" + }, + status_codes={ + 204: "Instance deleted", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Delete a TraceNG instance") + def delete(request, response): + + yield from TraceNG.instance().delete_node(request.match_info["node_id"]) + response.set_status(204) + + @Route.post( + r"/projects/{project_id}/traceng/nodes/{node_id}/duplicate", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID" + }, + status_codes={ + 201: "Instance duplicated", + 404: "Instance doesn't exist" + }, + description="Duplicate a TraceNG instance") + def duplicate(request, response): + + new_node = yield from TraceNG.instance().duplicate_node( + request.match_info["node_id"], + request.json["destination_node_id"] + ) + response.set_status(201) + response.json(new_node) + + @Route.post( + r"/projects/{project_id}/traceng/nodes/{node_id}/start", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID" + }, + status_codes={ + 204: "Instance started", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Start a TraceNG instance", + output=TRACENG_OBJECT_SCHEMA) + def start(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + yield from vm.start() + response.json(vm) + + @Route.post( + r"/projects/{project_id}/traceng/nodes/{node_id}/stop", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID" + }, + status_codes={ + 204: "Instance stopped", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Stop a TraceNG instance") + def stop(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + yield from vm.stop() + response.set_status(204) + + @Route.post( + r"/projects/{project_id}/traceng/nodes/{node_id}/suspend", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID" + }, + status_codes={ + 204: "Instance suspended", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Suspend a TraceNG instance (does nothing)") + def suspend(request, response): + + traceng_manager = TraceNG.instance() + traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + response.set_status(204) + + @Route.post( + r"/projects/{project_id}/traceng/nodes/{node_id}/reload", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + }, + status_codes={ + 204: "Instance reloaded", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Reload a TraceNG instance") + def reload(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + yield from vm.reload() + response.set_status(204) + + @Route.post( + r"/projects/{project_id}/traceng/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + "adapter_number": "Network adapter where the nio is located", + "port_number": "Port where the nio should be added" + }, + status_codes={ + 201: "NIO created", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Add a NIO to a TraceNG instance", + input=NIO_SCHEMA, + output=NIO_SCHEMA) + def create_nio(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + nio_type = request.json["type"] + if nio_type not in ("nio_udp"): + raise HTTPConflict(text="NIO of type {} is not supported".format(nio_type)) + nio = traceng_manager.create_nio(request.json) + yield from vm.port_add_nio_binding(int(request.match_info["port_number"]), nio) + response.set_status(201) + response.json(nio) + + @Route.put( + r"/projects/{project_id}/traceng/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + "adapter_number": "Network adapter where the nio is located", + "port_number": "Port from where the nio should be updated" + }, + status_codes={ + 201: "NIO updated", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + input=NIO_SCHEMA, + output=NIO_SCHEMA, + description="Update a NIO from a TraceNG instance") + def update_nio(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + nio = vm.ethernet_adapter.get_nio(int(request.match_info["port_number"])) + if "filters" in request.json and nio: + nio.filters = request.json["filters"] + yield from vm.port_update_nio_binding(int(request.match_info["port_number"]), nio) + response.set_status(201) + response.json(request.json) + + @Route.delete( + r"/projects/{project_id}/traceng/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + "adapter_number": "Network adapter where the nio is located", + "port_number": "Port from where the nio should be removed" + }, + status_codes={ + 204: "NIO deleted", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Remove a NIO from a TraceNG instance") + def delete_nio(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + yield from vm.port_remove_nio_binding(int(request.match_info["port_number"])) + response.set_status(204) + + @Route.post( + r"/projects/{project_id}/traceng/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/start_capture", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + "adapter_number": "Adapter to start a packet capture", + "port_number": "Port on the adapter" + }, + status_codes={ + 200: "Capture started", + 400: "Invalid request", + 404: "Instance doesn't exist", + }, + description="Start a packet capture on a TraceNG instance", + input=NODE_CAPTURE_SCHEMA) + def start_capture(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + port_number = int(request.match_info["port_number"]) + pcap_file_path = os.path.join(vm.project.capture_working_directory(), request.json["capture_file_name"]) + yield from vm.start_capture(port_number, pcap_file_path) + response.json({"pcap_file_path": pcap_file_path}) + + @Route.post( + r"/projects/{project_id}/traceng/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/stop_capture", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + "adapter_number": "Adapter to stop a packet capture", + "port_number": "Port on the adapter" + }, + status_codes={ + 204: "Capture stopped", + 400: "Invalid request", + 404: "Instance doesn't exist", + }, + description="Stop a packet capture on a TraceNG instance") + def stop_capture(request, response): + + traceng_manager = TraceNG.instance() + vm = traceng_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + port_number = int(request.match_info["port_number"]) + yield from vm.stop_capture(port_number) + response.set_status(204) diff --git a/gns3server/run.py b/gns3server/run.py index 0d23b124..c3b4bc27 100644 --- a/gns3server/run.py +++ b/gns3server/run.py @@ -183,7 +183,7 @@ def kill_ghosts(): """ Kill process from previous GNS3 session """ - detect_process = ["vpcs", "ubridge", "dynamips"] + detect_process = ["vpcs", "traceng", "ubridge", "dynamips"] for proc in psutil.process_iter(): try: name = proc.name().lower().split(".")[0] diff --git a/gns3server/schemas/node.py b/gns3server/schemas/node.py index d39ba747..2b26192d 100644 --- a/gns3server/schemas/node.py +++ b/gns3server/schemas/node.py @@ -30,6 +30,7 @@ NODE_TYPE_SCHEMA = { "docker", "dynamips", "vpcs", + "traceng", "virtualbox", "vmware", "iou", diff --git a/gns3server/schemas/traceng.py b/gns3server/schemas/traceng.py new file mode 100644 index 00000000..38a27de3 --- /dev/null +++ b/gns3server/schemas/traceng.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +TRACENG_CREATE_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to create a new TraceNG instance", + "type": "object", + "properties": { + "name": { + "description": "TraceNG VM name", + "type": "string", + "minLength": 1, + }, + "node_id": { + "description": "Node UUID", + "oneOf": [ + {"type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$"} + ] + }, + "console": { + "description": "Console TCP port", + "minimum": 1, + "maximum": 65535, + "type": ["integer", "null"] + }, + "console_type": { + "description": "Console type", + "enum": ["telnet"] + }, + }, + "additionalProperties": False, + "required": ["name"] +} + +TRACENG_UPDATE_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to update a TraceNG instance", + "type": "object", + "properties": { + "name": { + "description": "TraceNG VM name", + "type": ["string", "null"], + "minLength": 1, + }, + "console": { + "description": "Console TCP port", + "minimum": 1, + "maximum": 65535, + "type": ["integer", "null"] + }, + "console_type": { + "description": "Console type", + "enum": ["telnet"] + }, + }, + "additionalProperties": False, +} + +TRACENG_OBJECT_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "TraceNG instance", + "type": "object", + "properties": { + "name": { + "description": "TraceNG VM name", + "type": "string", + "minLength": 1, + }, + "node_id": { + "description": "Node UUID", + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "node_directory": { + "description": "Path to the VM working directory", + "type": "string" + }, + "status": { + "description": "VM status", + "enum": ["started", "stopped", "suspended"] + }, + "console": { + "description": "Console TCP port", + "minimum": 1, + "maximum": 65535, + "type": "integer" + }, + "console_type": { + "description": "Console type", + "enum": ["telnet"] + }, + "project_id": { + "description": "Project UUID", + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "command_line": { + "description": "Last command line used by GNS3 to start TraceNG", + "type": "string" + } + }, + "additionalProperties": False, + "required": ["name", "node_id", "status", "console", "console_type", "project_id", "command_line"] +} diff --git a/tests/compute/traceng/test_traceng_vm.py b/tests/compute/traceng/test_traceng_vm.py new file mode 100644 index 00000000..f7870368 --- /dev/null +++ b/tests/compute/traceng/test_traceng_vm.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +import asyncio +import os +import sys + +from tests.utils import asyncio_patch, AsyncioMagicMock +from gns3server.utils import parse_version +from unittest.mock import patch, MagicMock, ANY + +from gns3server.compute.traceng.traceng_vm import TraceNGVM +from gns3server.compute.traceng.traceng_error import TraceNGError +from gns3server.compute.traceng import TraceNG +from gns3server.compute.notification_manager import NotificationManager + + +@pytest.fixture +def manager(port_manager): + m = TraceNG.instance() + m.port_manager = port_manager + return m + + +@pytest.fixture(scope="function") +def vm(project, manager, ubridge_path): + vm = TraceNGVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", project, manager) + vm._start_ubridge = AsyncioMagicMock() + vm._ubridge_hypervisor = MagicMock() + vm._ubridge_hypervisor.is_running.return_value = True + return vm + + +def test_vm(project, manager): + vm = TraceNGVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", project, manager) + assert vm.name == "test" + assert vm.id == "00010203-0405-0607-0809-0a0b0c0d0e0f" + + +def test_vm_invalid_traceng_path(vm, manager, loop): + with patch("gns3server.compute.traceng.traceng_vm.TraceNGVM._traceng_path", return_value="/tmp/fake/path/traceng"): + with pytest.raises(TraceNGError): + nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1"}) + vm.port_add_nio_binding(0, nio) + loop.run_until_complete(asyncio.async(vm.start())) + assert vm.name == "test" + assert vm.id == "00010203-0405-0607-0809-0a0b0c0d0e0e" + + +def test_start(loop, vm, async_run): + process = MagicMock() + process.returncode = None + + with NotificationManager.instance().queue() as queue: + async_run(queue.get(0)) # Ping + + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM._check_requirements", return_value=True): + with asyncio_patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM.start_wrap_console"): + loop.run_until_complete(asyncio.async(vm.start())) + assert mock_exec.call_args[0] == (vm._traceng_path(), + '-p', + str(vm._internal_console_port), + '-s', + ANY, + '-c', + ANY, + '-t', + '127.0.0.1') + assert vm.is_running() + assert vm.command_line == ' '.join(mock_exec.call_args[0]) + (action, event, kwargs) = async_run(queue.get(0)) + assert action == "node.updated" + assert event == vm + + +def test_stop(loop, vm, async_run): + process = MagicMock() + + # Wait process kill success + future = asyncio.Future() + future.set_result(True) + process.wait.return_value = future + process.returncode = None + + with NotificationManager.instance().queue() as queue: + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM._check_requirements", return_value=True): + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM.start_wrap_console"): + with asyncio_patch("asyncio.create_subprocess_exec", return_value=process): + nio = TraceNG.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + async_run(vm.port_add_nio_binding(0, nio)) + + async_run(vm.start()) + assert vm.is_running() + + with asyncio_patch("gns3server.utils.asyncio.wait_for_process_termination"): + loop.run_until_complete(asyncio.async(vm.stop())) + assert vm.is_running() is False + + if sys.platform.startswith("win"): + process.send_signal.assert_called_with(1) + else: + process.terminate.assert_called_with() + + async_run(queue.get(0)) #  Ping + async_run(queue.get(0)) #  Started + + (action, event, kwargs) = async_run(queue.get(0)) + assert action == "node.updated" + assert event == vm + + +def test_reload(loop, vm, async_run): + process = MagicMock() + + # Wait process kill success + future = asyncio.Future() + future.set_result(True) + process.wait.return_value = future + process.returncode = None + + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM._check_requirements", return_value=True): + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM.start_wrap_console"): + with asyncio_patch("asyncio.create_subprocess_exec", return_value=process): + nio = TraceNG.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + async_run(vm.port_add_nio_binding(0, nio)) + async_run(vm.start()) + assert vm.is_running() + + vm._ubridge_send = AsyncioMagicMock() + with asyncio_patch("gns3server.utils.asyncio.wait_for_process_termination"): + async_run(vm.reload()) + assert vm.is_running() is True + + #if sys.platform.startswith("win"): + # process.send_signal.assert_called_with(1) + #else: + process.terminate.assert_called_with() + + +def test_add_nio_binding_udp(vm, async_run): + nio = TraceNG.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}) + async_run(vm.port_add_nio_binding(0, nio)) + assert nio.lport == 4242 + + +def test_port_remove_nio_binding(vm): + nio = TraceNG.instance().create_nio({"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1"}) + vm.port_add_nio_binding(0, nio) + vm.port_remove_nio_binding(0) + assert vm._ethernet_adapter.ports[0] is None + + +def test_close(vm, port_manager, loop): + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM._check_requirements", return_value=True): + with asyncio_patch("asyncio.create_subprocess_exec", return_value=MagicMock()): + vm.start() + loop.run_until_complete(asyncio.async(vm.close())) + assert vm.is_running() is False diff --git a/tests/handlers/api/compute/test_traceng.py b/tests/handlers/api/compute/test_traceng.py new file mode 100644 index 00000000..380e8be4 --- /dev/null +++ b/tests/handlers/api/compute/test_traceng.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2018 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +import uuid +import sys +import os +from tests.utils import asyncio_patch +from unittest.mock import patch + + +@pytest.fixture(scope="function") +def vm(http_compute, project): + response = http_compute.post("/projects/{project_id}/traceng/nodes".format(project_id=project.id), {"name": "TraceNG TEST 1"}) + assert response.status == 201 + return response.json + + +def test_traceng_create(http_compute, project): + response = http_compute.post("/projects/{project_id}/traceng/nodes".format(project_id=project.id), {"name": "TraceNG TEST 1"}, example=True) + assert response.status == 201 + assert response.route == "/projects/{project_id}/traceng/nodes" + assert response.json["name"] == "TraceNG TEST 1" + assert response.json["project_id"] == project.id + + +def test_traceng_get(http_compute, project, vm): + response = http_compute.get("/projects/{project_id}/traceng/nodes/{node_id}".format(project_id=vm["project_id"], node_id=vm["node_id"]), example=True) + assert response.status == 200 + assert response.route == "/projects/{project_id}/traceng/nodes/{node_id}" + assert response.json["name"] == "TraceNG TEST 1" + assert response.json["project_id"] == project.id + assert response.json["status"] == "stopped" + + +def test_traceng_create_startup_script(http_compute, project): + response = http_compute.post("/projects/{project_id}/traceng/nodes".format(project_id=project.id), {"name": "TraceNG TEST 1", "startup_script": "ip 192.168.1.2\necho TEST"}) + assert response.status == 201 + assert response.route == "/projects/{project_id}/traceng/nodes" + assert response.json["name"] == "TraceNG TEST 1" + assert response.json["project_id"] == project.id + + +def test_traceng_create_port(http_compute, project, free_console_port): + response = http_compute.post("/projects/{project_id}/traceng/nodes".format(project_id=project.id), {"name": "TraceNG TEST 1", "console": free_console_port}) + assert response.status == 201 + assert response.route == "/projects/{project_id}/traceng/nodes" + assert response.json["name"] == "TraceNG TEST 1" + assert response.json["project_id"] == project.id + assert response.json["console"] == free_console_port + + +def test_traceng_nio_create_udp(http_compute, vm): + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM.add_ubridge_udp_connection"): + response = http_compute.post("/projects/{project_id}/traceng/nodes/{node_id}/adapters/0/ports/0/nio".format(project_id=vm["project_id"], node_id=vm["node_id"]), {"type": "nio_udp", + "lport": 4242, + "rport": 4343, + "rhost": "127.0.0.1"}, + example=True) + assert response.status == 201 + assert response.route == "/projects/{project_id}/traceng/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio" + assert response.json["type"] == "nio_udp" + + +def test_traceng_nio_update_udp(http_compute, vm): + response = http_compute.put("/projects/{project_id}/traceng/nodes/{node_id}/adapters/0/ports/0/nio".format(project_id=vm["project_id"], node_id=vm["node_id"]), + { + "type": "nio_udp", + "lport": 4242, + "rport": 4343, + "rhost": "127.0.0.1", + "filters": {}}, + example=True) + assert response.status == 201, response.body.decode("utf-8") + assert response.route == "/projects/{project_id}/traceng/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio" + assert response.json["type"] == "nio_udp" + + +def test_traceng_delete_nio(http_compute, vm): + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM._ubridge_send"): + http_compute.post("/projects/{project_id}/traceng/nodes/{node_id}/adapters/0/ports/0/nio".format(project_id=vm["project_id"], node_id=vm["node_id"]), {"type": "nio_udp", + "lport": 4242, + "rport": 4343, + "rhost": "127.0.0.1"}) + response = http_compute.delete("/projects/{project_id}/traceng/nodes/{node_id}/adapters/0/ports/0/nio".format(project_id=vm["project_id"], node_id=vm["node_id"]), example=True) + assert response.status == 204, response.body.decode() + assert response.route == "/projects/{project_id}/traceng/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio" + + +def test_traceng_start(http_compute, vm): + + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM.start", return_value=True) as mock: + response = http_compute.post("/projects/{project_id}/traceng/nodes/{node_id}/start".format(project_id=vm["project_id"], node_id=vm["node_id"]), example=True) + assert mock.called + assert response.status == 200 + assert response.json["name"] == "TraceNG TEST 1" + + +def test_traceng_stop(http_compute, vm): + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM.stop", return_value=True) as mock: + response = http_compute.post("/projects/{project_id}/traceng/nodes/{node_id}/stop".format(project_id=vm["project_id"], node_id=vm["node_id"]), example=True) + assert mock.called + assert response.status == 204 + + +def test_traceng_reload(http_compute, vm): + with asyncio_patch("gns3server.compute.traceng.traceng_vm.TraceNGVM.reload", return_value=True) as mock: + response = http_compute.post("/projects/{project_id}/traceng/nodes/{node_id}/reload".format(project_id=vm["project_id"], node_id=vm["node_id"]), example=True) + assert mock.called + assert response.status == 204 + + +def test_traceng_delete(http_compute, vm): + with asyncio_patch("gns3server.compute.traceng.TraceNG.delete_node", return_value=True) as mock: + response = http_compute.delete("/projects/{project_id}/traceng/nodes/{node_id}".format(project_id=vm["project_id"], node_id=vm["node_id"]), example=True) + assert mock.called + assert response.status == 204 + + +def test_traceng_duplicate(http_compute, vm): + with asyncio_patch("gns3server.compute.traceng.TraceNG.duplicate_node", return_value=True) as mock: + response = http_compute.post( + "/projects/{project_id}/traceng/nodes/{node_id}/duplicate".format( + project_id=vm["project_id"], + node_id=vm["node_id"]), + body={ + "destination_node_id": str(uuid.uuid4()) + }, + example=True) + assert mock.called + assert response.status == 201 + + +def test_traceng_update(http_compute, vm, tmpdir, free_console_port): + response = http_compute.put("/projects/{project_id}/traceng/nodes/{node_id}".format(project_id=vm["project_id"], node_id=vm["node_id"]), {"name": "test", + "console": free_console_port, + }, + example=True) + assert response.status == 200 + assert response.json["name"] == "test" + assert response.json["console"] == free_console_port