From 2f23a092e318e1b1d0ce16654e9ff7def64a2aa9 Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 5 Dec 2013 21:39:27 -0700 Subject: [PATCH] Polishing base server implementation --- gns3server/handlers/__init__.py | 0 gns3server/{ => handlers}/stomp_websocket.py | 17 +++++-- gns3server/handlers/version_handler.py | 26 ++++++++++ gns3server/module_manager.py | 6 +-- gns3server/modules/base.py | 26 +++++++--- gns3server/modules/dynamips/__init__.py | 50 ++++++++++++++++++++ gns3server/server.py | 23 +++++---- gns3server/stomp/frame.py | 2 + gns3server/stomp/protocol.py | 7 +++ tests/conftest.py | 3 ++ tests/test_stomp.py | 32 +++++++++++++ tests/test_version_handler.py | 47 +++++++++--------- 12 files changed, 191 insertions(+), 48 deletions(-) create mode 100644 gns3server/handlers/__init__.py rename gns3server/{ => handlers}/stomp_websocket.py (93%) create mode 100644 gns3server/handlers/version_handler.py create mode 100644 gns3server/modules/dynamips/__init__.py diff --git a/gns3server/handlers/__init__.py b/gns3server/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gns3server/stomp_websocket.py b/gns3server/handlers/stomp_websocket.py similarity index 93% rename from gns3server/stomp_websocket.py rename to gns3server/handlers/stomp_websocket.py index a64ade86..cacf93cf 100644 --- a/gns3server/stomp_websocket.py +++ b/gns3server/handlers/stomp_websocket.py @@ -22,10 +22,10 @@ STOMP protocol over Websockets import zmq import uuid import tornado.websocket -from .version import __version__ from tornado.escape import json_decode -from .stomp import frame as stomp_frame -from .stomp import protocol as stomp_protocol +from ..version import __version__ +from ..stomp import frame as stomp_frame +from ..stomp import protocol as stomp_protocol import logging log = logging.getLogger(__name__) @@ -48,12 +48,15 @@ class StompWebSocket(tornado.websocket.WebSocketHandler): def __init__(self, application, request, zmq_router): tornado.websocket.WebSocketHandler.__init__(self, application, request) self._session_id = str(uuid.uuid4()) + self._connected = False self.zmq_router = zmq_router @property def session_id(self): """ Session ID uniquely representing a Websocket client + + :returns: the session id """ return self._session_id @@ -63,7 +66,7 @@ class StompWebSocket(tornado.websocket.WebSocketHandler): """ Sends a message to Websocket client - :param message: message from a module + :param message: message from a module (received via ZeroMQ) """ # Module name that is replying @@ -117,6 +120,7 @@ class StompWebSocket(tornado.websocket.WebSocketHandler): else: self.write_message(self.stomp.connected(self.session_id, 'gns3server/' + __version__)) + self._connected = True def stomp_handle_send(self, frame): """ @@ -212,6 +216,11 @@ class StompWebSocket(tornado.websocket.WebSocketHandler): if frame.cmd == stomp_protocol.CMD_STOMP or frame.cmd == stomp_protocol.CMD_CONNECT: self.stomp_handle_connect(frame) + # Do not enforce that the client must have send a + # STOMP CONNECT frame for now (need to refactor unit tests) + #elif not self._connected: + # self.stomp_error("Not connected") + elif frame.cmd == stomp_protocol.CMD_SEND: self.stomp_handle_send(frame) diff --git a/gns3server/handlers/version_handler.py b/gns3server/handlers/version_handler.py new file mode 100644 index 00000000..c85aa31c --- /dev/null +++ b/gns3server/handlers/version_handler.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013 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 tornado.web +from ..version import __version__ + + +class VersionHandler(tornado.web.RequestHandler): + + def get(self): + response = {'version': __version__} + self.write(response) diff --git a/gns3server/module_manager.py b/gns3server/module_manager.py index d3ce8a21..14bbd440 100644 --- a/gns3server/module_manager.py +++ b/gns3server/module_manager.py @@ -47,7 +47,6 @@ class Module(object): def name(self, new_name): self._name = new_name - #@property def cls(self): return self._cls @@ -93,7 +92,7 @@ class ModuleManager(object): """ Returns all modules. - :return: list of Module objects + :returns: list of Module objects """ return self._modules @@ -105,7 +104,8 @@ class ModuleManager(object): :param module: module to activate (Module object) :param args: args passed to the module :param kwargs: kwargs passed to the module - :return: instantiated module class + + :returns: instantiated module class """ module_class = module.cls() diff --git a/gns3server/modules/base.py b/gns3server/modules/base.py index 25233ab5..7ec78040 100644 --- a/gns3server/modules/base.py +++ b/gns3server/modules/base.py @@ -15,6 +15,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +""" +Base class (interface) for modules +""" + import multiprocessing import zmq @@ -44,16 +48,16 @@ class IModule(multiprocessing.Process): self._current_session = None self._current_destination = None - def setup(self): + def _setup(self): """ Sets up PyZMQ and creates the stream to handle requests """ self._context = zmq.Context() self._ioloop = zmq.eventloop.ioloop.IOLoop.instance() - self._stream = self.create_stream(self._host, self._port, self.decode_request) + self._stream = self._create_stream(self._host, self._port, self._decode_request) - def create_stream(self, host=None, port=0, callback=None): + def _create_stream(self, host=None, port=0, callback=None): """ Creates a new ZMQ stream """ @@ -82,10 +86,10 @@ class IModule(multiprocessing.Process): def run(self): """ - Sets up everything and starts the event loop + Starts the event loop """ - self.setup() + self._setup() try: self._ioloop.start() except KeyboardInterrupt: @@ -102,6 +106,8 @@ class IModule(multiprocessing.Process): def send_response(self, response): """ Sends a response back to the requester + + :param response: """ # add session and destination to the response @@ -109,9 +115,11 @@ class IModule(multiprocessing.Process): log.debug("ZeroMQ client ({}) sending: {}".format(self.name, response)) self._stream.send_json(response) - def decode_request(self, request): + def _decode_request(self, request): """ Decodes the request to JSON + + :param request: request from ZeroMQ server """ try: @@ -132,7 +140,9 @@ class IModule(multiprocessing.Process): def destinations(self): """ - Channels handled by this modules. + Destinations handled by this module. + + :returns: list of destinations """ return self.destination.keys() @@ -141,6 +151,8 @@ class IModule(multiprocessing.Process): def route(cls, destination): """ Decorator to register a destination routed to a method + + :param destination: destination to be routed """ def wrapper(method): diff --git a/gns3server/modules/dynamips/__init__.py b/gns3server/modules/dynamips/__init__.py new file mode 100644 index 00000000..83261edc --- /dev/null +++ b/gns3server/modules/dynamips/__init__.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013 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.modules import IModule +from .hypervisor import Hypervisor +from .hypervisor_manager import HypervisorManager +from .dynamips_error import DynamipsError +from .nodes.router import Router + +import logging +log = logging.getLogger(__name__) + + +class Dynamips(IModule): + + def __init__(self, name=None, args=(), kwargs={}): + IModule.__init__(self, name=name, args=args, kwargs=kwargs) + #self._hypervisor_manager = HypervisorManager("/usr/bin/dynamips", "/tmp") + + @IModule.route("dynamips/echo") + def echo(self, request): + print("Echo!") + log.debug("received request {}".format(request)) + self.send_response(request) + + @IModule.route("dynamips/create_vm") + def create_vm(self, request): + print("Create VM!") + log.debug("received request {}".format(request)) + self.send_response(request) + + @IModule.route("dynamips/start_vm") + def start_vm(self, request): + print("Start VM!") + log.debug("received request {}".format(request)) + self.send_response(request) diff --git a/gns3server/server.py b/gns3server/server.py index 594e6d87..59e3135b 100644 --- a/gns3server/server.py +++ b/gns3server/server.py @@ -15,31 +15,29 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +""" +Set up and run the server +""" + import zmq from zmq.eventloop import ioloop, zmqstream ioloop.install() import os +import errno import functools import socket import tornado.ioloop import tornado.web import tornado.autoreload -from .version import __version__ -from .stomp_websocket import StompWebSocket +from .handlers.stomp_websocket import StompWebSocket +from .handlers.version_handler import VersionHandler from .module_manager import ModuleManager import logging log = logging.getLogger(__name__) -class VersionHandler(tornado.web.RequestHandler): - - def get(self): - response = {'version': __version__} - self.write(response) - - class Server(object): # built-in handlers @@ -57,7 +55,8 @@ class Server(object): self._modules = [] def load_modules(self): - """Loads the plugins + """ + Loads the modules """ cwd = os.path.dirname(os.path.abspath(__file__)) @@ -86,7 +85,7 @@ class Server(object): print("Starting server on port {}".format(self._port)) tornado_app.listen(self._port) except socket.error as e: - if e.errno is 48: # socket already in use + if e.errno == errno.EADDRINUSE: # socket already in use logging.critical("socket in use for port {}".format(self._port)) raise SystemExit @@ -106,7 +105,7 @@ class Server(object): Creates the ZeroMQ router socket to send requests to modules. - :returns: ZeroMQ socket + :returns: ZeroMQ router socket """ context = zmq.Context() diff --git a/gns3server/stomp/frame.py b/gns3server/stomp/frame.py index 61b86124..90d83e48 100644 --- a/gns3server/stomp/frame.py +++ b/gns3server/stomp/frame.py @@ -106,6 +106,7 @@ class Frame(object): :param lines: Frame preamble lines :param offset: To start parsing at the given offset + :returns: Headers in dict header:value """ @@ -124,6 +125,7 @@ class Frame(object): Parses a frame :params frame: The frame data to be parsed + :returns: STOMP Frame object """ diff --git a/gns3server/stomp/protocol.py b/gns3server/stomp/protocol.py index ad2db09d..0491c1bf 100644 --- a/gns3server/stomp/protocol.py +++ b/gns3server/stomp/protocol.py @@ -85,6 +85,7 @@ class serverProtocol(object): :param session: A session identifier that uniquely identifies the session. :param server: A field that contains information about the STOMP server. + :returns: STOMP Frame object """ @@ -109,6 +110,7 @@ class serverProtocol(object): :param body: Data to be added in the frame body :param content_type: MIME type which describes the format of the body :param message_id: Unique identifier for that message + :returns: STOMP Frame object """ @@ -133,6 +135,7 @@ class serverProtocol(object): Sends an acknowledgment for client frame that requests a receipt. :param receipt_id: Receipt ID to send back to the client + :returns: STOMP Frame object """ @@ -147,6 +150,7 @@ class serverProtocol(object): :param message: Short description of the error :param body: Detailed information :param content_type: MIME type which describes the format of the body + :returns: STOMP Frame object """ @@ -176,6 +180,7 @@ class clientProtocol(object): :param host: Host name that the socket was established against. :param accept_version: The versions of the STOMP protocol the client supports. + :returns: STOMP Frame object """ @@ -195,6 +200,7 @@ class clientProtocol(object): Disconnects to a STOMP server. :param receipt: unique identifier + :returns: STOMP Frame object """ @@ -211,6 +217,7 @@ class clientProtocol(object): :param destination: Destination string :param body: Data to be added in the frame body :param content_type: MIME type which describes the format of the body + :returns: STOMP Frame object """ diff --git a/tests/conftest.py b/tests/conftest.py index f9ae1cf7..fda6fe57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,9 @@ import time @pytest.fixture(scope="session", autouse=True) def server(request): + """ + Starts GNS3 server for all the tests. + """ cwd = os.path.dirname(os.path.abspath(__file__)) server_script = os.path.join(cwd, "../gns3server/main.py") diff --git a/tests/test_stomp.py b/tests/test_stomp.py index ac793107..443d4a1d 100644 --- a/tests/test_stomp.py +++ b/tests/test_stomp.py @@ -5,6 +5,10 @@ from ws4py.client.tornadoclient import TornadoWebSocketClient from gns3server.stomp import frame as stomp_frame from gns3server.stomp import protocol as stomp_protocol +""" +Tests STOMP protocol over Websockets +""" + class Stomp(AsyncTestCase): @@ -16,6 +20,10 @@ class Stomp(AsyncTestCase): AsyncTestCase.setUp(self) def test_connect(self): + """ + Sends a STOMP CONNECT frame and + check for a STOMP CONNECTED frame. + """ request = self.stomp.connect("localhost") AsyncWSRequest(self.URL, self.io_loop, self.stop, request) @@ -25,6 +33,11 @@ class Stomp(AsyncTestCase): assert frame.cmd == stomp_protocol.CMD_CONNECTED def test_protocol_negotiation_failure(self): + """ + Sends a STOMP CONNECT frame with protocol version 1.0 required + and check for a STOMP ERROR sent back by the server which supports + STOMP version 1.2 only. + """ request = self.stomp.connect("localhost", accept_version='1.0') AsyncWSRequest(self.URL, self.io_loop, self.stop, request) @@ -34,6 +47,9 @@ class Stomp(AsyncTestCase): assert frame.cmd == stomp_protocol.CMD_ERROR def test_malformed_frame(self): + """ + Sends an empty frame and check for a STOMP ERROR. + """ request = b"" AsyncWSRequest(self.URL, self.io_loop, self.stop, request) @@ -43,6 +59,10 @@ class Stomp(AsyncTestCase): assert frame.cmd == stomp_protocol.CMD_ERROR def test_send(self): + """ + Sends a STOMP SEND frame with a message and a destination + and check for a STOMP MESSAGE with echoed message and destination. + """ destination = "dynamips/echo" message = {"ping": "test"} @@ -57,6 +77,10 @@ class Stomp(AsyncTestCase): assert message == json_reply def test_unimplemented_frame(self): + """ + Sends an STOMP BEGIN frame which is not implemented by the server + and check for a STOMP ERROR frame. + """ frame = stomp_frame.Frame(stomp_protocol.CMD_BEGIN) request = frame.encode() @@ -67,6 +91,11 @@ class Stomp(AsyncTestCase): assert frame.cmd == stomp_protocol.CMD_ERROR def test_disconnect(self): + """ + Sends a STOMP DISCONNECT frame is a receipt id + and check for a STOMP RECEIPT frame with the same receipt id + confirming the disconnection. + """ myid = str(uuid.uuid4()) request = self.stomp.disconnect(myid) @@ -79,6 +108,9 @@ class Stomp(AsyncTestCase): class AsyncWSRequest(TornadoWebSocketClient): + """ + Very basic Websocket client for the tests + """ def __init__(self, url, io_loop, callback, message): TornadoWebSocketClient.__init__(self, url, io_loop=io_loop) diff --git a/tests/test_version_handler.py b/tests/test_version_handler.py index 19b09ed2..feeef3d3 100644 --- a/tests/test_version_handler.py +++ b/tests/test_version_handler.py @@ -1,37 +1,40 @@ from tornado.testing import AsyncHTTPTestCase +from tornado.escape import json_decode from gns3server.server import VersionHandler -from gns3server._compat import urlencode +from gns3server.version import __version__ import tornado.web -import json -# URL to test -URL = "/version" +""" +Tests for the web server version handler +""" class TestVersionHandler(AsyncHTTPTestCase): + URL = "/version" + def get_app(self): - return tornado.web.Application([(URL, VersionHandler)]) + + return tornado.web.Application([(self.URL, VersionHandler)]) def test_endpoint(self): - self.http_client.fetch(self.get_url(URL), self.stop) + """ + Tests if the response HTTP code is 200 (success) + """ + + self.http_client.fetch(self.get_url(self.URL), self.stop) response = self.wait() assert response.code == 200 -# def test_post(self): -# data = urlencode({'test': 'works'}) -# req = tornado.httpclient.HTTPRequest(self.get_url(URL), -# method='POST', -# body=data) -# self.http_client.fetch(req, self.stop) -# response = self.wait() -# assert response.code == 200 -# -# def test_endpoint_differently(self): -# self.http_client.fetch(self.get_url(URL), self.stop) -# response = self.wait() -# assert(response.headers['Content-Type'].startswith('application/json')) -# assert(response.body != "") -# body = json.loads(response.body.decode('utf-8')) -# assert body['version'] == "0.1.dev" + def test_received_version(self): + """ + Tests if the returned content type is JSON and + if the received version is the same as the server + """ + self.http_client.fetch(self.get_url(self.URL), self.stop) + response = self.wait() + assert(response.headers['Content-Type'].startswith('application/json')) + assert(response.body) + body = json_decode(response.body) + assert body['version'] == __version__