Polishing base server implementation

This commit is contained in:
grossmj 2013-12-05 21:39:27 -07:00
parent f4e51ea74f
commit 2f23a092e3
12 changed files with 191 additions and 48 deletions

View File

View File

@ -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)

View File

@ -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 <http://www.gnu.org/licenses/>.
import tornado.web
from ..version import __version__
class VersionHandler(tornado.web.RequestHandler):
def get(self):
response = {'version': __version__}
self.write(response)

View File

@ -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()

View File

@ -15,6 +15,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
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):

View File

@ -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 <http://www.gnu.org/licenses/>.
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)

View File

@ -15,31 +15,29 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
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()

View File

@ -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
"""

View File

@ -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
"""

View File

@ -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")

View File

@ -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)

View File

@ -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__