diff --git a/docs/curl.rst b/docs/curl.rst
index d1a44957..ea92f409 100644
--- a/docs/curl.rst
+++ b/docs/curl.rst
@@ -74,10 +74,6 @@ With this project id we can now create two VPCS Node.
"node_id": "f124dec0-830a-451e-a314-be50bbd58a00",
"node_type": "vpcs",
"project_id": "b8c070f7-f34c-4b7b-ba6f-be3d26ed073f",
- "properties": {
- "startup_script": null,
- "startup_script_path": null
- },
"status": "stopped"
}
@@ -91,10 +87,6 @@ With this project id we can now create two VPCS Node.
"node_id": "83892a4d-aea0-4350-8b3e-d0af3713da74",
"node_type": "vpcs",
"project_id": "b8c070f7-f34c-4b7b-ba6f-be3d26ed073f",
- "properties": {
- "startup_script": null,
- "startup_script_path": null
- },
"status": "stopped"
}
@@ -230,8 +222,52 @@ This will display a red square in the middle of your topologies:
Tips: you can embed png/jpg... by using a base64 encoding in the SVG.
-Create a Qemu node
-###################
+Creation of nodes
+#################
+
+Their is two way of adding nodes. Manual by passing all the information require for a Node.
+
+Or by using an appliance. The appliance is a node model saved in your server.
+
+Using an appliance
+------------------
+
+First you need to list the available appliances
+
+.. code-block:: shell-session
+
+ # curl "http://localhost:3080/v2/appliances"
+
+ [
+ {
+ "appliance_id": "5fa8a8ca-0f80-4ac4-8104-2b32c7755443",
+ "category": "guest",
+ "compute_id": "vm",
+ "default_name_format": "{name}-{0}",
+ "name": "MicroCore",
+ "node_type": "qemu",
+ "symbol": ":/symbols/qemu_guest.svg"
+ },
+ {
+ "appliance_id": "9cd59d5a-c70f-4454-8313-6a9e81a8278f",
+ "category": "guest",
+ "compute_id": "vm",
+ "default_name_format": "{name}-{0}",
+ "name": "Chromium",
+ "node_type": "docker",
+ "symbol": ":/symbols/docker_guest.svg"
+ }
+ ]
+
+Now you can use the appliance and put it at a specific position
+
+.. code-block:: shell-session
+
+ # curl -X POST http://localhost:3080/v2/projects/b8c070f7-f34c-4b7b-ba6f-be3d26ed073f -d '{"x": 12, "y": 42}'
+
+
+Manual creation of a Qemu node
+-------------------------------
.. code-block:: shell-session
@@ -315,9 +351,8 @@ Create a Qemu node
}
-Create a dynamips node
-######################
-
+Manual creation of a dynamips node
+-----------------------------------
.. code-block:: shell-session
diff --git a/docs/glossary.rst b/docs/glossary.rst
index 3fde097f..3da68aea 100644
--- a/docs/glossary.rst
+++ b/docs/glossary.rst
@@ -1,11 +1,29 @@
Glossary
========
+Topology
+--------
+
+The place where you have all things (node, drawing, link...)
+
+
Node
-----
A Virtual Machine (Dynamips, IOU, Qemu, VPCS...), a cloud, a builtin device (switch, hub...)
+Appliance
+---------
+
+A model for a node. When you drag an appliance to the topology a node is created.
+
+
+Appliance template
+------------------
+
+A file (.gns3a) use for creating new node model.
+
+
Drawing
--------
diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py
index e916947a..048b7489 100644
--- a/gns3server/controller/__init__.py
+++ b/gns3server/controller/__init__.py
@@ -57,13 +57,12 @@ class Controller:
# Store settings shared by the different GUI will be replace by dedicated API later
self._settings = None
- self._local_server = None
+ self._appliances = {}
+ self._appliance_templates = {}
+
self._config_file = os.path.join(Config.instance().config_dir, "gns3_controller.conf")
log.info("Load controller configuration file {}".format(self._config_file))
- self._appliance_templates = {}
- self.load_appliances()
-
def load_appliances(self):
self._appliance_templates = {}
for file in os.listdir(get_resource('appliances')):
@@ -72,6 +71,59 @@ class Controller:
if appliance.status != 'broken':
self._appliance_templates[appliance.id] = appliance
+ self._appliances = {}
+ for vm in self._settings.get("Qemu", {}).get("vms", []):
+ vm["node_type"] = "qemu"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ for vm in self._settings.get("IOU", {}).get("devices", []):
+ vm["node_type"] = "iou"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ for vm in self._settings.get("Docker", {}).get("containers", []):
+ vm["node_type"] = "docker"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ for vm in self._settings.get("Builtin", {}).get("cloud_nodes", []):
+ vm["node_type"] = "cloud"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ for vm in self._settings.get("Builtin", {}).get("ethernet_switches", []):
+ vm["node_type"] = "ethernet_switch"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ for vm in self._settings.get("Builtin", {}).get("ethernet_hubs", []):
+ vm["node_type"] = "ethernet_hub"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ for vm in self._settings.get("Dynamips", {}).get("routers", []):
+ vm["node_type"] = "dynamips"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ for vm in self._settings.get("VMware", {}).get("vms", []):
+ vm["node_type"] = "vmware"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ for vm in self._settings.get("VirtualBox", {}).get("vms", []):
+ vm["node_type"] = "virtualbox"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ for vm in self._settings.get("VPCS", {}).get("nodes", []):
+ vm["node_type"] = "vpcs"
+ appliance = Appliance(None, vm)
+ self._appliances[appliance.id] = appliance
+ # Add builtins
+ builtins = []
+ builtins.append(Appliance(None, {"node_type": "cloud", "name": "Cloud", "category": 2, "symbol": ":/symbols/cloud.svg"}, builtin=True))
+ builtins.append(Appliance(None, {"node_type": "nat", "name": "NAT", "category": 2, "symbol": ":/symbols/cloud.svg"}, builtin=True))
+ builtins.append(Appliance(None, {"node_type": "vpcs", "name": "VPCS", "category": 2, "symbol": ":/symbols/vpcs_guest.svg"}, builtin=True))
+ builtins.append(Appliance(None, {"node_type": "ethernet_switch", "name": "Ethernet switch", "category": 1, "symbol": ":/symbols/ethernet_switch.svg"}, builtin=True))
+ builtins.append(Appliance(None, {"node_type": "ethernet_hub", "name": "Ethernet hub", "category": 1, "symbol": ":/symbols/hub.svg"}, builtin=True))
+ builtins.append(Appliance(None, {"node_type": "frame_relay_switch", "name": "Frame Relay switch", "category": 1, "symbol": ":/symbols/frame_relay_switch.svg"}, builtin=True))
+ builtins.append(Appliance(None, {"node_type": "atm_switch", "name": "ATM switch", "category": 1, "symbol": ":/symbols/atm_switch.svg"}, builtin=True))
+ for b in builtins:
+ self._appliances[b.id] = b
+
@asyncio.coroutine
def start(self):
log.info("Start controller")
@@ -190,6 +242,7 @@ class Controller:
if "gns3vm" in data:
self.gns3vm.settings = data["gns3vm"]
+ self.load_appliances()
return data["computes"]
@asyncio.coroutine
@@ -309,6 +362,7 @@ class Controller:
self._settings = val
self._settings["modification_uuid"] = str(uuid.uuid4()) # We add a modification id to the settings it's help the gui to detect changes
self.save()
+ self.load_appliances()
self.notification.emit("settings.updated", val)
@asyncio.coroutine
@@ -515,6 +569,13 @@ class Controller:
"""
return self._appliance_templates
+ @property
+ def appliances(self):
+ """
+ :returns: The dictionary of appliances managed by GNS3
+ """
+ return self._appliances
+
def projects_directory(self):
server_config = Config.instance().get_section_config("Server")
return os.path.expanduser(server_config.get("projects_path", "~/GNS3/projects"))
diff --git a/gns3server/controller/appliance.py b/gns3server/controller/appliance.py
index 6b05ae6d..3b035d60 100644
--- a/gns3server/controller/appliance.py
+++ b/gns3server/controller/appliance.py
@@ -18,21 +18,56 @@
import uuid
+# Convert old GUI category to text category
+ID_TO_CATEGORY = {
+ 3: "firewall",
+ 2: "guest",
+ 1: "switch",
+ 0: "router"
+}
+
+
class Appliance:
- def __init__(self, appliance_id, data):
+ def __init__(self, appliance_id, data, builtin=False):
if appliance_id is None:
self._id = str(uuid.uuid4())
else:
self._id = appliance_id
self._data = data
+ self._builtin = builtin
@property
def id(self):
return self._id
+ @property
+ def data(self):
+ return self._data
+
+ @property
+ def name(self):
+ return self._data["name"]
+
+ @property
+ def compute_id(self):
+ return self._data.get("server")
+
+ @property
+ def builtin(self):
+ return self._builtin
+
def __json__(self):
"""
Appliance data (a hash)
"""
- return self._data
+ return {
+ "appliance_id": self._id,
+ "node_type": self._data["node_type"],
+ "name": self._data["name"],
+ "default_name_format": self._data.get("default_name_format", "{name}-{0}"),
+ "category": ID_TO_CATEGORY[self._data["category"]],
+ "symbol": self._data["symbol"],
+ "compute_id": self.compute_id,
+ "builtin": self._builtin
+ }
diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py
index 6622145f..a182291a 100644
--- a/gns3server/controller/project.py
+++ b/gns3server/controller/project.py
@@ -19,6 +19,7 @@ import re
import os
import json
import uuid
+import copy
import shutil
import asyncio
import aiohttp
@@ -317,6 +318,29 @@ class Project:
return self.update_allocated_node_name(new_name)
return new_name
+ @open_required
+ @asyncio.coroutine
+ def add_node_from_appliance(self, appliance_id, x=0, y=0, compute_id=None):
+ """
+ Create a node from an appliance
+ """
+ try:
+ template = copy.copy(self.controller.appliances[appliance_id].data)
+ except KeyError:
+ msg = "Appliance {} doesn't exist".format(appliance_id)
+ log.error(msg)
+ raise aiohttp.web.HTTPNotFound(text=msg)
+ template["x"] = x
+ template["y"] = y
+ node_type = template.pop("node_type")
+ compute = self.controller.get_compute(template.pop("server", compute_id))
+ name = template.pop("name")
+ default_name_format = template.pop("default_name_format", "{name}-{0}")
+ name = default_name_format.replace("{name}", name)
+ node_id = str(uuid.uuid4())
+ node = yield from self.add_node(compute, name, node_id, node_type=node_type, **template)
+ return node
+
@open_required
@asyncio.coroutine
def add_node(self, compute, name, node_id, dump=True, node_type=None, **kwargs):
diff --git a/gns3server/handlers/api/controller/appliance_handler.py b/gns3server/handlers/api/controller/appliance_handler.py
index 226ab8a7..b9dbc4cf 100644
--- a/gns3server/handlers/api/controller/appliance_handler.py
+++ b/gns3server/handlers/api/controller/appliance_handler.py
@@ -17,6 +17,9 @@
from gns3server.web.route import Route
from gns3server.controller import Controller
+from gns3server.schemas.node import NODE_OBJECT_SCHEMA
+from gns3server.schemas.appliance import APPLIANCE_USAGE_SCHEMA
+
import logging
log = logging.getLogger(__name__)
@@ -27,6 +30,17 @@ class ApplianceHandler:
@Route.get(
r"/appliances/templates",
+ description="List of appliance templates",
+ status_codes={
+ 200: "Appliance template list returned"
+ })
+ def list_templates(request, response):
+
+ controller = Controller.instance()
+ response.json([c for c in controller.appliance_templates.values()])
+
+ @Route.get(
+ r"/appliances",
description="List of appliance",
status_codes={
200: "Appliance list returned"
@@ -34,4 +48,27 @@ class ApplianceHandler:
def list(request, response):
controller = Controller.instance()
- response.json([c for c in controller.appliance_templates.values()])
+ response.json([c for c in controller.appliances.values()])
+
+ @Route.post(
+ r"/projects/{project_id}/appliances/{appliance_id}",
+ description="Create a node from an appliance",
+ parameters={
+ "project_id": "Project UUID",
+ "appliance_id": "Appliance template UUID"
+ },
+ status_codes={
+ 201: "Node created",
+ 404: "The project or template doesn't exist"
+ },
+ input=APPLIANCE_USAGE_SCHEMA,
+ output=NODE_OBJECT_SCHEMA)
+ def create_node_from_appliance(request, response):
+
+ controller = Controller.instance()
+ project = controller.get_project(request.match_info["project_id"])
+ yield from project.add_node_from_appliance(request.match_info["appliance_id"],
+ x=request.json["x"],
+ y=request.json["y"],
+ compute_id=request.json.get("compute_id"))
+ response.set_status(201)
diff --git a/gns3server/schemas/appliance.py b/gns3server/schemas/appliance.py
new file mode 100644
index 00000000..fde89ae3
--- /dev/null
+++ b/gns3server/schemas/appliance.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2015 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 .
+
+
+APPLIANCE_USAGE_SCHEMA = {
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "description": "Request validation to use an Appliance instance",
+ "type": "object",
+ "properties": {
+ "x": {
+ "description": "X position",
+ "type": "integer"
+ },
+ "y": {
+ "description": "Y position",
+ "type": "integer"
+ },
+ "compute_id": {
+ "description": "If the appliance don't have a default compute use this compute",
+ "type": ["null", "string"]
+ }
+ },
+ "additionalProperties": False,
+ "required": ["x", "y"]
+}
diff --git a/tests/controller/test_appliance.py b/tests/controller/test_appliance.py
new file mode 100644
index 00000000..326e514b
--- /dev/null
+++ b/tests/controller/test_appliance.py
@@ -0,0 +1,39 @@
+#!/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 .
+
+from gns3server.controller.appliance import Appliance
+
+
+def test_appliance_json():
+ a = Appliance(None, {
+ "node_type": "qemu",
+ "name": "Test",
+ "default_name_format": "{name}-{0}",
+ "category": 0,
+ "symbol": "qemu.svg",
+ "server": "local"
+ })
+ assert a.__json__() == {
+ "appliance_id": a.id,
+ "node_type": "qemu",
+ "builtin": False,
+ "name": "Test",
+ "default_name_format": "{name}-{0}",
+ "category": "router",
+ "symbol": "qemu.svg",
+ "compute_id": "local"
+ }
diff --git a/tests/controller/test_controller.py b/tests/controller/test_controller.py
index f734300f..7c43c9c0 100644
--- a/tests/controller/test_controller.py
+++ b/tests/controller/test_controller.py
@@ -53,7 +53,7 @@ def test_load_controller_settings(controller, controller_config_path, async_run)
"compute_id": "test1"
}
]
- data["settings"] = {"IOU": True}
+ data["settings"] = {"IOU": {"test": True}}
data["gns3vm"] = {"vmname": "Test VM"}
with open(controller_config_path, "w+") as f:
json.dump(data, f)
@@ -468,3 +468,23 @@ def test_load_base_files(controller, config, tmpdir):
# Check is the file has not been overwrite
with open(str(tmpdir / 'iou_l2_base_startup-config.txt')) as f:
assert f.read() == 'test'
+
+
+def test_appliance_templates(controller, async_run):
+ controller.load_appliances()
+ assert len(controller.appliance_templates) > 0
+
+
+def test_load_appliances(controller):
+ controller._settings = {
+ "Qemu": {
+ "vms": [
+ {
+ "name": "Test"
+ }
+ ]
+ }
+ }
+ controller.load_appliances()
+ assert "Test" in [appliance.name for appliance in controller.appliances.values()]
+ assert "Cloud" in [appliance.name for appliance in controller.appliances.values()]
diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py
index 9d6af268..b270526f 100644
--- a/tests/controller/test_project.py
+++ b/tests/controller/test_project.py
@@ -18,17 +18,15 @@
import os
import sys
-import uuid
-import json
import pytest
import aiohttp
-import zipfile
from unittest.mock import MagicMock
from tests.utils import AsyncioMagicMock, asyncio_patch
from unittest.mock import patch
from uuid import uuid4
from gns3server.controller.project import Project
+from gns3server.controller.appliance import Appliance
from gns3server.controller.ports.ethernet_port import EthernetPort
from gns3server.config import Config
@@ -182,6 +180,42 @@ def test_add_node_non_local(async_run, controller):
controller.notification.emit.assert_any_call("node.created", node.__json__())
+def test_add_node_from_appliance(async_run, controller):
+ """
+ For a local server we send the project path
+ """
+ compute = MagicMock()
+ compute.id = "local"
+ project = Project(controller=controller, name="Test")
+ controller._notification = MagicMock()
+ controller._appliances["fakeid"] = Appliance("fakeid", {
+ "server": "local",
+ "name": "Test",
+ "default_name_format": "{name}-{0}",
+ "node_type": "vpcs"
+
+ })
+ controller._computes["local"] = compute
+
+ response = MagicMock()
+ response.json = {"console": 2048}
+ compute.post = AsyncioMagicMock(return_value=response)
+
+ node = async_run(project.add_node_from_appliance("fakeid", x=23, y=12))
+
+ compute.post.assert_any_call('/projects', data={
+ "name": project._name,
+ "project_id": project._id,
+ "path": project._path
+ })
+ compute.post.assert_any_call('/projects/{}/vpcs/nodes'.format(project.id),
+ data={'node_id': node.id,
+ 'name': 'Test-1'},
+ timeout=120)
+ assert compute in project._project_created_on_compute
+ controller.notification.emit.assert_any_call("node.created", node.__json__())
+
+
def test_create_iou_on_multiple_node(async_run, controller):
"""
Due to mac address collision you can't create an IOU node
diff --git a/tests/handlers/api/controller/test_appliance.py b/tests/handlers/api/controller/test_appliance.py
index 01fdbe74..0758b4a5 100644
--- a/tests/handlers/api/controller/test_appliance.py
+++ b/tests/handlers/api/controller/test_appliance.py
@@ -16,10 +16,73 @@
# along with this program. If not, see .
+import uuid
+import pytest
+from unittest.mock import MagicMock
+from tests.utils import asyncio_patch
+
+
+from gns3server.controller import Controller
+from gns3server.controller.appliance import Appliance
+
+
+@pytest.fixture
+def compute(http_controller, async_run):
+ compute = MagicMock()
+ compute.id = "example.com"
+ compute.host = "example.org"
+ Controller.instance()._computes = {"example.com": compute}
+ return compute
+
+
+@pytest.fixture
+def project(http_controller, async_run):
+ return async_run(Controller.instance().add_project(name="Test"))
+
+
def test_appliance_list(http_controller, controller):
- response = http_controller.get("/appliances/templates")
+ id = str(uuid.uuid4())
+ controller.load_appliances()
+ controller._appliances[id] = Appliance(id, {
+ "node_type": "qemu",
+ "category": 0,
+ "name": "test",
+ "symbol": "guest.svg",
+ "default_name_format": "{name}-{0}",
+ "server": "local"
+ })
+ response = http_controller.get("/appliances", example=True)
assert response.status == 200
- assert response.route == "/appliances/templates"
+ assert response.route == "/appliances"
+ assert len(response.json) > 0
+
+def test_appliance_templates_list(http_controller, controller, async_run):
+
+ controller.load_appliances()
+ response = http_controller.get("/appliances/templates", example=True)
+ assert response.status == 200
assert len(response.json) > 0
+
+
+def test_create_node_from_appliance(http_controller, controller, project, compute):
+
+ id = str(uuid.uuid4())
+ controller._appliances = {id: Appliance(id, {
+ "node_type": "qemu",
+ "category": 0,
+ "name": "test",
+ "symbol": "guest.svg",
+ "default_name_format": "{name}-{0}",
+ "server": "example.com"
+ })}
+ with asyncio_patch("gns3server.controller.project.Project.add_node_from_appliance") as mock:
+ response = http_controller.post("/projects/{}/appliances/{}".format(project.id, id), {
+ "x": 42,
+ "y": 12
+ })
+ mock.assert_called_with(id, x=42, y=12, compute_id=None)
+ print(response.body)
+ assert response.route == "/projects/{project_id}/appliances/{appliance_id}"
+ assert response.status == 201