Commit d7495f79 authored by Giorgos Verigakis's avatar Giorgos Verigakis
Browse files

Move to OpenStack API v1.1

* Removes piston dependency.
* Adds dateutil dependency.

Fixes #183 - Implement /servers
Fixes #185 - Implement /images
Fixes #245 - Details in exception handlers
Fixes #249 - Metadata handling
Fixes #253 - Transition to OpenStack API v1.1
Fixes #255 - Fix XML for addresses
Fixes #287 - Piston related bug
Fixes #289 - Support both XML and JSON
Fixes #309 - GUI not refreshing
Fixes #339 - Support API extensions
Fixes #359 - action returns serviceUnavailable
Fixes #361 - return dates with timezones
Refs #301 - Sanitization of API
Refs #315 - Replace API v1.1
parent 491ba48a
......@@ -5,9 +5,10 @@
from django.conf import settings
from django.http import HttpResponse
from synnefo.api.errors import *
from synnefo.api.faults import BadRequest, ResizeNotAllowed, ServiceUnavailable
from synnefo.util.rapi import GanetiRapiClient
from synnefo.logic import backend, utils
from synnefo.logic import backend
server_actions = {}
......@@ -15,6 +16,11 @@ rapi = GanetiRapiClient(*settings.GANETI_CLUSTER_INFO)
def server_action(name):
'''Decorator for functions implementing server actions.
`name` is the key in the dict passed by the client.
'''
def decorator(func):
server_actions[name] = func
return func
......@@ -22,7 +28,7 @@ def server_action(name):
@server_action('changePassword')
def change_password(server, args):
def change_password(vm, args):
# Normal Response Code: 202
# Error Response Codes: computeFault (400, 500),
# serviceUnavailable (503),
......@@ -41,7 +47,7 @@ def change_password(server, args):
raise ServiceUnavailable()
@server_action('reboot')
def reboot(server, args):
def reboot(vm, args):
# Normal Response Code: 202
# Error Response Codes: computeFault (400, 500),
# serviceUnavailable (503),
......@@ -56,30 +62,32 @@ def reboot(server, args):
if reboot_type not in ('SOFT', 'HARD'):
raise BadRequest()
backend.start_action(server, 'REBOOT')
rapi.RebootInstance(server.backend_id, reboot_type.lower())
backend.start_action(vm, 'REBOOT')
rapi.RebootInstance(vm.backend_id, reboot_type.lower())
return HttpResponse(status=202)
@server_action('start')
def start(server, args):
def start(vm, args):
# Normal Response Code: 202
# Error Response Codes: serviceUnavailable (503), itemNotFound (404)
# Error Response Codes: serviceUnavailable (503),
# itemNotFound (404)
backend.start_action(server, 'START')
rapi.StartupInstance(server.backend_id)
backend.start_action(vm, 'START')
rapi.StartupInstance(vm.backend_id)
return HttpResponse(status=202)
@server_action('shutdown')
def shutdown(server, args):
def shutdown(vm, args):
# Normal Response Code: 202
# Error Response Codes: serviceUnavailable (503), itemNotFound (404)
# Error Response Codes: serviceUnavailable (503),
# itemNotFound (404)
backend.start_action(server, 'STOP')
rapi.ShutdownInstance(server.backend_id)
backend.start_action(vm, 'STOP')
rapi.ShutdownInstance(vm.backend_id)
return HttpResponse(status=202)
@server_action('rebuild')
def rebuild(server, args):
def rebuild(vm, args):
# Normal Response Code: 202
# Error Response Codes: computeFault (400, 500),
# serviceUnavailable (503),
......@@ -94,7 +102,7 @@ def rebuild(server, args):
raise ServiceUnavailable()
@server_action('resize')
def resize(server, args):
def resize(vm, args):
# Normal Response Code: 202
# Error Response Codes: computeFault (400, 500),
# serviceUnavailable (503),
......@@ -110,7 +118,7 @@ def resize(server, args):
raise ResizeNotAllowed()
@server_action('confirmResize')
def confirm_resize(server, args):
def confirm_resize(vm, args):
# Normal Response Code: 204
# Error Response Codes: computeFault (400, 500),
# serviceUnavailable (503),
......@@ -126,7 +134,7 @@ def confirm_resize(server, args):
raise ResizeNotAllowed()
@server_action('revertResize')
def revert_resize(server, args):
def revert_resize(vm, args):
# Normal Response Code: 202
# Error Response Codes: computeFault (400, 500),
# serviceUnavailable (503),
......
# vim: ts=4 sts=4 et ai sw=4 fileencoding=utf-8
#
# Copyright © 2010 Greek Research and Technology Network
#
from django.contrib.auth.models import User, AnonymousUser
from synnefo.api.faults import fault
# XXX: we need to add a Vary X-Auth-Token, somehow
# XXX: or use a standard auth middleware instead?
# but watch out for CSRF issues:
# http://andrew.io/weblog/2010/01/django-piston-and-handling-csrf-tokens/
class TokenAuthentication(object):
def is_authenticated(self, request):
request.user = User()
return True
token = request.META.get('HTTP_X_AUTH_TOKEN', None)
if not token:
return False
# XXX: lookup token in models and set request.user
if token:
request.user = AnonymousUser()
return True
def challenge(self):
return fault.unauthorized.response
#
# Copyright (c) 2010 Greek Research and Technology Network
#
from synnefo.api.faults import BadRequest
from synnefo.api.util import api_method
@api_method()
def not_found(request):
raise BadRequest('Not found.')
@api_method()
def method_not_allowed(request):
raise BadRequest('Method not allowed.')
# vim: ts=4 sts=4 et ai sw=4 fileencoding=utf-8
#
# Copyright © 2010 Greek Research and Technology Network
#
from xml.dom import minidom
from piston.emitters import Emitter, Mimer
class OSXMLEmitter(Emitter):
"""
Custom XML Emitter that handles some special stuff needed by the API.
Shamelessly stolen^Wborrowed code (sans Piston integration) by OpenStack's
Nova project and hence:
Copyright 2010 United States Government as represented by the
Administrator of the National Aeronautics and Space Administration.
Copyright 2010 OpenStack LLC.
and licensed under the Apache License, Version 2.0
"""
_metadata = {
"server": [ "id", "imageRef", "name", "flavorRef", "hostId",
"status", "progress", "progress", "description", "created", "updated" ],
'ip': ['addr'],
'ip6': ['addr'],
'meta': ['key'],
"flavor": [ "id", "name", "ram", "disk", "cpu" ],
"image": [ "id", "name", "updated", "created", "status",
"serverId", "progress", "size", "description"],
"group": [ "id", "name", "server_id"],
}
def _to_xml_node(self, doc, nodename, data):
"""Recursive method to convert data members to XML nodes."""
result = doc.createElement(nodename)
if type(data) is list:
if nodename.endswith('s'):
singular = nodename[:-1]
else:
singular = 'item'
for item in data:
node = self._to_xml_node(doc, singular, item)
result.appendChild(node)
elif type(data) is dict:
attrs = self._metadata.get(nodename, {})
for k, v in data.items():
if k in attrs:
#protect from case where unicode with ascii chars is casted to str
v = v.__class__ == str and v.decode("utf8") or unicode(v)
result.setAttribute(k, v)
else:
node = self._to_xml_node(doc, k, v)
result.appendChild(node)
else: # atom
node = doc.createTextNode(str(data))
result.appendChild(node)
return result
def render(self, request):
data = self.construct()
# We expect data to contain a single key which is the XML root.
root_key = data.keys()[0]
doc = minidom.Document()
node = self._to_xml_node(doc, root_key, data[root_key])
return node.toprettyxml(indent=' ')
Emitter.register('xml', OSXMLEmitter, 'application/xml')
Mimer.register(lambda *a: None, ('application/xml',))
#
# Copyright (c) 2010 Greek Research and Technology Network
#
def camelCase(s):
return s[0].lower() + s[1:]
class Fault(BaseException):
def __init__(self, message='', details='', name=''):
BaseException.__init__(self, message, details, name)
self.message = message
self.details = details
self.name = name or camelCase(self.__class__.__name__)
class BadRequest(Fault):
code = 400
class Unauthorized(Fault):
code = 401
class ResizeNotAllowed(Fault):
code = 403
class ItemNotFound(Fault):
code = 404
class ServiceUnavailable(Fault):
code = 503
# vim: ts=4 sts=4 et ai sw=4 fileencoding=utf-8
#
# Copyright © 2010 Greek Research and Technology Network
# Copyright (c) 2010 Greek Research and Technology Network
#
from django.http import HttpResponse
from django.utils import simplejson as json
from piston.utils import HttpStatusCode
def camelCase(s):
return s[0].lower() + s[1:]
class Fault(HttpStatusCode):
"""Fault Exception"""
pass
class _fault_factory(object):
"""
Openstack API Faults factory
"""
class Fault(BaseException):
def __init__(self, message='', details='', name=''):
BaseException.__init__(self, message, details, name)
self.message = message
self.details = details
self.name = name or camelCase(self.__class__.__name__)
faults = {
'serviceUnavailable': {
'code': 503,
'message': 'Service Unavailable',
},
'unauthorized': {
'code': 401,
'message': 'Unauthorized',
},
'badRequest': {
'code': 400,
'message': 'Bad request',
},
'overLimit': {
'code': 413,
'message': 'Overlimit',
},
'badMediaType': {
'code': 415,
'message': 'Bad media type',
},
'badMethod': {
'code': 405,
'message': 'Bad method',
},
'itemNotFound': {
'code': 404,
'message': 'Not Found',
},
'buildInProgress': {
'code': 409,
'message': 'Build in progress',
},
'serverCapacityUnavailable': {
'code': 503,
'message': 'Server capacity unavailable',
},
'backupOrResizeInProgress': {
'code': 409,
'message': 'Backup or resize in progress',
},
'resizeNotAllowed': {
'code': 403,
'message': 'Resize not allowed',
},
'notImplemented': {
'code': 501,
'message': 'Not Implemented',
},
}
class BadRequest(Fault):
code = 400
def __getattr__(self, attr):
try:
m = self.faults.get(attr)
except TypeError:
raise AttributeError(attr)
class Unauthorized(Fault):
code = 401
# details are not supported for now
m['details'] = ''
class ResizeNotAllowed(Fault):
code = 403
# piston > 0.2.2 does the serialization for us, but be compatible
# 'till the next version gets released. XXX: this doesn't do XML!
message = json.dumps({ attr: m }, ensure_ascii=False, indent=4)
code = m['code']
response = HttpResponse(message, status=code)
class ItemNotFound(Fault):
code = 404
return Fault(response)
fault = _fault_factory()
# these are in the 2xx range, hence not faults/exceptions
noContent = HttpResponse(status=204)
accepted = HttpResponse(status=202)
created = HttpResponse(status=201)
notModified = HttpResponse(status=304)
class ServiceUnavailable(Fault):
code = 503
......@@ -2,14 +2,14 @@
# Copyright (c) 2010 Greek Research and Technology Network
#
from synnefo.api.util import *
from synnefo.db.models import Flavor
from django.conf.urls.defaults import *
from django.conf.urls.defaults import patterns
from django.http import HttpResponse
from django.template.loader import render_to_string
from django.utils import simplejson as json
from synnefo.api.util import get_user, get_request_dict, api_method
from synnefo.db.models import Flavor
urlpatterns = patterns('synnefo.api.flavors',
(r'^(?:/|.json|.xml)?$', 'list_flavors'),
......@@ -39,14 +39,12 @@ def list_flavors(request, detail=False):
all_flavors = Flavor.objects.all()
flavors = [flavor_to_dict(flavor, detail) for flavor in all_flavors]
if request.type == 'xml':
mimetype = 'application/xml'
if request.serialization == 'xml':
data = render_to_string('list_flavors.xml', {'flavors': flavors, 'detail': detail})
else:
mimetype = 'application/json'
data = json.dumps({'flavors': {'values': flavors}})
return HttpResponse(data, mimetype=mimetype, status=200)
return HttpResponse(data, status=200)
@api_method('GET')
def get_flavor_details(request, flavor_id):
......@@ -64,7 +62,7 @@ def get_flavor_details(request, flavor_id):
except Flavor.DoesNotExist:
raise ItemNotFound
if request.type == 'xml':
if request.serialization == 'xml':
data = render_to_string('flavor.xml', {'flavor': flavor})
else:
data = json.dumps({'flavor': flavor})
......
This diff is collapsed.
# vim: ts=4 sts=4 et ai sw=4 fileencoding=utf-8
#
# Copyright © 2010 Greek Research and Technology Network
#
# XXX: most of the keys below are dummy
def instance_to_server(instance):
server = {
"id": instance["name"],
"name": instance["name"],
"hostId": instance["pnode"],
"imageRef": 1,
"flavorRef": 1,
"addresses": {
"public": [ ],
"private": [ ],
},
"metadata": { }
}
if instance["status"] == "running":
server["status"] = "ACTIVE"
elif instance["status"] == "ADMIN_down":
server["status"] = "SUSPENDED"
else:
server["status"] = "UNKNOWN"
return server
def paginator(func):
"""
A dummy paginator decorator that uses limit/offset query parameters to
limit the result set of a view. The view must return a dict with a single
key and an iterable for its value.
This doesn't actually speed up the internal processing, but it's useful to
easily provide compatibility for the API
"""
def inner_func(self, request, *args, **kwargs):
resp = func(self, request, *args, **kwargs)
if 'limit' not in request.GET or 'offset' not in request.GET:
return resp
# handle structures such as { '
if len(resp.keys()) != 1:
return resp
key = resp.keys()[0]
full = resp.values()[0]
try:
limit = int(request.GET['limit'])
offset = int(request.GET['offset'])
if offset < 0:
raise ValueError
if limit < 0:
raise ValueError
limit = limit + offset
partial = full[offset:limit]
return { key: partial }
except (ValueError, TypeError):
return { key: [] }
return inner_func
......@@ -2,10 +2,11 @@
# Copyright (c) 2010 Greek Research and Technology Network
#
from synnefo.api.util import *
from synnefo.db.models import Image
from synnefo.api.common import method_not_allowed
from synnefo.api.util import isoformat, isoparse, get_user, get_image, get_request_dict, api_method
from synnefo.db.models import Image, ImageMetadata, VirtualMachine
from django.conf.urls.defaults import *
from django.conf.urls.defaults import patterns
from django.http import HttpResponse
from django.template.loader import render_to_string
from django.utils import simplejson as json
......@@ -23,8 +24,7 @@ def demux(request):
elif request.method == 'POST':
return create_image(request)
else:
fault = BadRequest()
return render_fault(request, fault)
return method_not_allowed(request)
def image_demux(request, image_id):
if request.method == 'GET':
......@@ -32,15 +32,14 @@ def image_demux(request, image_id):
elif request.method == 'DELETE':
return delete_image(request, image_id)
else:
fault = BadRequest()
return render_fault(request, fault)
return method_not_allowed(request)
def image_to_dict(image, detail=True):
d = {'id': image.id, 'name': image.name}
if detail:
d['updated'] = image.updated.isoformat()
d['created'] = image.created.isoformat()
d['updated'] = isoformat(image.updated)
d['created'] = isoformat(image.created)
d['status'] = image.state
d['progress'] = 100 if image.state == 'ACTIVE' else 0
d['description'] = image.description
......@@ -66,17 +65,23 @@ def list_images(request, detail=False):
# badRequest (400),
# overLimit (413)
all_images = Image.objects.all()
images = [image_to_dict(image, detail) for image in all_images]
since = isoparse(request.GET.get('changes-since'))
if request.type == 'xml':
mimetype = 'application/xml'
if since:
avail_images = Image.objects.filter(updated__gt=since)
if not avail_images:
return HttpResponse(status=304)
else:
avail_images = Image.objects.all()
images = [image_to_dict(image, detail) for image in avail_images]
if request.serialization == 'xml':
data = render_to_string('list_images.xml', {'images': images, 'detail': detail})
else:
mimetype = 'application/json'
data = json.dumps({'images': {'values': images}})
return HttpResponse(data, mimetype=mimetype, status=200)
return HttpResponse(data, status=200)
@api_method('POST')
def create_image(request):
......@@ -110,7 +115,7 @@ def create_image(request):
raise ItemNotFound
imagedict = image_to_dict(image)
if request.type == 'xml':
if request.serialization == 'xml':
data = render_to_string('image.xml', {'image': imagedict})
else:
data = json.dumps({'image': imagedict})
......@@ -127,13 +132,10 @@ def get_image_details(request, image_id):
# itemNotFound (404),
# overLimit (413)
try:
image_id = int(image_id)
imagedict = image_to_dict(Image.objects.get(id=image_id))
except Image.DoesNotExist:
raise ItemNotFound
image = get_image(image_id)
imagedict = image_to_dict(image)
if request.type == 'xml':
if request.serialization == 'xml':
data = render_to_string('image.xml', {'image': imagedict})
else:
data = json.dumps({'image': imagedict})
......@@ -149,12 +151,7 @@ def delete_image(request, image_id):
# itemNotFound (404),
# overLimit (413)
try:
image_id = int(image_id)
image = Image.objects.get(id=image_id)
except Image.DoesNotExist:
raise ItemNotFound
image = get_image(image_id)
if image.owner != get_user():
raise Unauthorized()
image.delete()
......
auth api
========
cloud api
=========
* auth
* version (DONE)
* faults (DONE)
* limits
* servers
+ list
- index: GET /servers
- detail: GET /servers/detail
+ create
- create: POST /servers
+ get details
- show: GET /servers/<id>
+ update password
- update: PUT /servers/<id>
+ delete servers
- delete: DELETE /servers/<id>
* addresses
+ list
+ list public
+ list private
+ share
+ unshare
* actions
+ reboot
+ rebuild
+ resize