diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index b8ca9ee4..15a996b3 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -25,18 +25,18 @@ import os from .compute import ComputeConflict from ..utils.images import images_directories -class Node: - def __init__(self, project, compute, node_id=None, node_type=None, name=None, console=None, console_type=None, properties={}): +class Node: + # This properties are used only on controller and are not forwarded to the compute + CONTROLLER_ONLY_PROPERTIES = ["x", "y", "z", "symbol", "label", "console_host"] + + def __init__(self, project, compute, node_id=None, node_type=None, **kwargs): """ :param project: Project of the node :param compute: Compute server where the server will run :param node_id: UUID of the node (integer) :param node_type: Type of emulator - :param name: Name of the node - :param console: TCP port of the console - :param console_type: Type of the console (telnet, vnc, serial..) - :param properties: Emulator specific properties of the node + :param kwargs: Node properties """ if node_id is None: @@ -44,16 +44,31 @@ class Node: else: self._id = node_id - self._name = name self._project = project self._compute = compute self._node_type = node_type - self._console = console - self._console_type = console_type - self._properties = properties + + self._name = None + self._console = None + self._console_type = None + self._properties = {} self._command_line = None self._node_directory = None self._status = "stopped" + self._x = 0 + self._y = 0 + self._z = 0 + self._symbol = ":/symbols/computer.svg" + self._label = { + "color": "#ff000000", + "y": -25.0, + "text": "", + "font": "TypeWriter,10,-1,5,75,0,0,0,0,0", + "x": -17.0234375 + } + # Update node properties with additional elements + for prop in kwargs: + setattr(self, prop, kwargs[prop]) @property def id(self): @@ -67,6 +82,12 @@ class Node: def name(self): return self._name + @name.setter + def name(self, val): + self._name = val + # The text in label need to be always the node name + self._label["text"] = val + @property def node_type(self): return self._node_type @@ -75,14 +96,26 @@ class Node: def console(self): return self._console + @console.setter + def console(self, val): + self._console = val + @property def console_type(self): return self._console_type + @console_type.setter + def console_type(self, val): + self._console_type = val + @property def properties(self): return self._properties + @properties.setter + def properties(self, val): + self._properties = val + @property def project(self): return self._project @@ -98,6 +131,48 @@ class Node: """ return self._compute.host + @property + def x(self): + return self._x + + @x.setter + def x(self, val): + self._x = val + + @property + def y(self): + return self._y + + @y.setter + def y(self, val): + self._y = val + + @property + def z(self): + return self._z + + @z.setter + def z(self, val): + self._z = val + + @property + def symbol(self): + return self._symbol + + @symbol.setter + def symbol(self, val): + self._symbol = val + + @property + def label(self): + return self._label + + @label.setter + def label(self, val): + # The text in label need to be always the node name + val["text"] = self._name + self._label = val + @asyncio.coroutine def create(self): """ @@ -110,7 +185,7 @@ class Node: try: response = yield from self._compute.post("/projects/{}/{}/nodes".format(self._project.id, self._node_type), data=data) except ComputeConflict as e: - if e.response.get("exception") == "ImageMissingError": + if e.response.get("exception") == "ImageMissingError": res = yield from self._upload_missing_image(self._node_type, e.response["image"]) if not res: raise e @@ -120,30 +195,28 @@ class Node: trial += 1 @asyncio.coroutine - def update(self, name=None, console=None, console_type=None, properties={}): + def update(self, **kwargs): """ Update the node on the compute server - :param node_id: UUID of the node - :param node_type: Type of emulator - :param name: Name of the node - :param console: TCP port of the console - :param console_type: Type of the console (telnet, vnc, serial..) - :param properties: Emulator specific properties of the node - + :param kwargs: Node properties """ - if name: - self._name = name - if console: - self._console = console - if console_type: - self._console_type = console_type - if properties != {}: - self._properties = properties - data = self._node_data() - response = yield from self.put(None, data=data) - self.parse_node_response(response.json) + # When updating properties used only on controller we don't need to call the compute + update_compute = False + + # Update node properties with additional elements + for prop in kwargs: + if getattr(self, prop) != kwargs[prop]: + if prop not in self.CONTROLLER_ONLY_PROPERTIES: + update_compute = True + setattr(self, prop, kwargs[prop]) + + self.project.controller.notification.emit("node.updated", self.__json__()) + if update_compute: + data = self._node_data() + response = yield from self.put(None, data=data) + self.parse_node_response(response.json) def parse_node_response(self, response): """ @@ -181,7 +254,7 @@ class Node: # None properties are not be send. Because it can mean the emulator doesn't support it for key in list(data.keys()): - if data[key] is None or key in ["console_host"]: + if data[key] is None or data[key] is {} or key in self.CONTROLLER_ONLY_PROPERTIES: del data[key] return data @@ -297,5 +370,10 @@ class Node: "console_type": self._console_type, "command_line": self._command_line, "properties": self._properties, - "status": self._status + "status": self._status, + "label": self._label, + "x": self._x, + "y": self._y, + "z": self._z, + "symbol": self._symbol } diff --git a/gns3server/schemas/label.py b/gns3server/schemas/label.py new file mode 100644 index 00000000..84719dd2 --- /dev/null +++ b/gns3server/schemas/label.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# +# Copyright (C) 2016 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 . + +LABEL_OBJECT_SCHEMA = { + "type": "object", + "properties": { + "color": { + "type": "string", + "pattern": "^#[0-9a-f]{6,8}$" + }, + "font": { + "type": "string", + "minLength": 1 + }, + "text": {"type": "string"}, + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number"}, + "rotation": {"type": "number"} + }, + "required": [ + "text", + "x", + "y" + ], + "additionalProperties": False +} diff --git a/gns3server/schemas/node.py b/gns3server/schemas/node.py index bbb2c103..a18da641 100644 --- a/gns3server/schemas/node.py +++ b/gns3server/schemas/node.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from .label import LABEL_OBJECT_SCHEMA NODE_LIST_IMAGES_SCHEMA = { "$schema": "http://json-schema.org/draft-04/schema#", @@ -136,6 +137,24 @@ NODE_OBJECT_SCHEMA = { "status": { "description": "Status of the node", "enum": ["stopped", "started", "suspended"] + }, + "label": LABEL_OBJECT_SCHEMA, + "symbol": { + "description": "Symbol of the node", + "type": "string", + "minLength": 1 + }, + "x": { + "description": "X position of the node", + "type": "number" + }, + "y": { + "description": "Y position of the node", + "type": "number" + }, + "z": { + "description": "Z position of the node", + "type": "number" } }, "additionalProperties": False, diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index a001ad2a..39786ae4 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -60,7 +60,12 @@ def test_json(node, compute): "command_line": None, "node_directory": None, "properties": node.properties, - "status": node.status + "status": node.status, + "x": node.x, + "y": node.y, + "z": node.z, + "symbol": node.symbol, + "label": node.label } @@ -95,6 +100,7 @@ def test_create_image_missing(node, compute, project, async_run): node._console = 2048 node.__calls = 0 + @asyncio.coroutine def resp(*args, **kwargs): node.__calls += 1 @@ -113,12 +119,13 @@ def test_create_image_missing(node, compute, project, async_run): node._upload_missing_image.called is True -def test_update(node, compute, project, async_run): +def test_update(node, compute, project, async_run, controller): response = MagicMock() response.json = {"console": 2048} compute.put = AsyncioMagicMock(return_value=response) + controller._notification = AsyncioMagicMock() - async_run(node.update(console=2048, console_type="vnc", properties={"startup_script": "echo test"}, name="demo")) + async_run(node.update(x=42, console=2048, console_type="vnc", properties={"startup_script": "echo test"}, name="demo")) data = { "console": 2048, "console_type": "vnc", @@ -127,7 +134,38 @@ def test_update(node, compute, project, async_run): } compute.put.assert_called_with("/projects/{}/vpcs/nodes/{}".format(node.project.id, node.id), data=data) assert node._console == 2048 + assert node.x == 42 assert node._properties == {"startup_script": "echo test"} + controller._notification.emit.assert_called_with("node.updated", node.__json__()) + + +def test_update_only_controller(node, compute, project, async_run): + """ + When updating property used only on controller we don't need to + call the compute + """ + compute.put = AsyncioMagicMock() + + async_run(node.update(x=42)) + assert not compute.put.called + assert node.x == 42 + + +def test_update_no_changes(node, compute, project, async_run): + """ + We don't call the compute node if all compute properties has not changed + """ + response = MagicMock() + response.json = {"console": 2048} + compute.put = AsyncioMagicMock(return_value=response) + + async_run(node.update(console=2048, x=42)) + assert compute.put.called + + compute.put = AsyncioMagicMock() + async_run(node.update(console=2048, x=43)) + assert not compute.put.called + assert node.x == 43 def test_start(node, compute, project, async_run): @@ -229,3 +267,14 @@ def test_upload_missing_image(compute, controller, async_run, images_dir): assert async_run(node._upload_missing_image("qemu", "linux.img")) is True compute.post.assert_called_with("/qemu/images/linux.img", data=ANY, timeout=None) + +def test_update_label(node): + """ + The text in label need to be always the + node name + """ + node.name = "Test" + assert node.label["text"] == "Test" + node.label = {"text": "Wrong", "x": 12} + assert node.label["text"] == "Test" + assert node.label["x"] == 12