commit 7492b50d2848e25b41d489664207fcaefbf8c276 Author: Mark McClain <mark.mcclain@dreamhost.com> Date: Wed Aug 29 18:56:07 2012 -0400 Finish repo reorg Moved files into the correct component dir for Horizon and Quantum extensions. Update module and class import paths to match the new organization format. diff --git a/akanda/__init__.py b/akanda/__init__.py new file mode 100644 index 0000000..de40ea7 --- /dev/null +++ b/akanda/__init__.py @@ -0,0 +1 @@ +__import__('pkg_resources').declare_namespace(__name__) diff --git a/akanda/quantum/README.rst b/akanda/quantum/README.rst new file mode 100644 index 0000000..a28b970 --- /dev/null +++ b/akanda/quantum/README.rst @@ -0,0 +1,48 @@ +Akanda User-facing API implemented as a Quantum Extension +========================================================== + +Portforward +----------- + +portfoward.py implemented under quantum/extensions allows... + +Firewall +---------- + +firewall.py implemented under quantum/extensions allows... + +AddressBook +--------- +addressbook.py implemented under quantum/extensions allows... + +Info +---- + +This is the home for the REST API that users will be calling directly with +their preferred REST tool (curl, Python wrapper, etc.). + +This code will eventually become part of OpenStack or act as a source or +inspiration that will. As such, this API should be constructed entirely with +standard OpenStack tools. + + +Exploratory Dev Work +-------------------- + +You also have to update Quantum's policy file for the extension to work with +authZ. + + "create_portforward": [], + "get_portforward": [["rule:admin_or_owner"]], + "update_portforward": [["rule:admin_or_owner"]], + "delete_portforward": [["rule:admin_or_owner"]] + + +If you want to use quotas: + +add to the QUOTAS section of quantum.conf + +quota_portforward = 10 + +======= + diff --git a/akanda/quantum/__init__.py b/akanda/quantum/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/akanda/quantum/_authzbase.py b/akanda/quantum/_authzbase.py new file mode 100644 index 0000000..98795e1 --- /dev/null +++ b/akanda/quantum/_authzbase.py @@ -0,0 +1,199 @@ +import abc + +from sqlalchemy.orm import exc as sa_exc + +from quantum import quota +#from quantum.api.v2 import attributes +from quantum.api.v2 import base +from quantum.api.v2 import resource as api_resource +from quantum.common import exceptions as q_exc +from quantum.openstack.common import cfg + + +class ResourcePlugin(object): + """ + This is a class does some of what the Quantum plugin does, managing + resources in a way very similar to what Quantum does. It differ from + Quantum is that this provides a base plugin infrastructure, and doesn't + manage any resources. + + Quantum doesn't split infrastructure and implementation. + """ + JOINS = () + + def __init__(self, delegate): + # synthesize the hooks because Quantum's base class uses the + # resource name as part of the method name + setattr(self, 'get_%s' % delegate.collection_name, + self._get_collection) + setattr(self, 'get_%s' % delegate.resource_name, self._get_item) + setattr(self, 'update_%s' % delegate.resource_name, self._update_item) + setattr(self, 'create_%s' % delegate.resource_name, self._create_item) + setattr(self, 'delete_%s' % delegate.resource_name, self._delete_item) + self.delegate = delegate + + def _get_tenant_id_for_create(self, context, resource): + if context.is_admin and 'tenant_id' in resource: + tenant_id = resource['tenant_id'] + elif ('tenant_id' in resource and + resource['tenant_id'] != context.tenant_id): + reason = _('Cannot create resource for another tenant') + raise q_exc.AdminRequired(reason=reason) + else: + tenant_id = context.tenant_id + return tenant_id + + def _model_query(self, context): + query = context.session.query(self.delegate.model) + + # NOTE(jkoelker) non-admin queries are scoped to their tenant_id + if not context.is_admin and hasattr(self.delegate.model, 'tenant_id'): + query = query.filter( + self.delegate.model.tenant_id == context.tenant_id) + return query + + def _get_collection(self, context, filters=None, fields=None, + verbose=None): + collection = self._model_query(context) + if filters: + for key, value in filters.iteritems(): + column = getattr(self.delegate.model, key, None) + if column: + collection = collection.filter(column.in_(value)) + return [self._fields(self.delegate.make_dict(c), fields) for c in + collection.all()] + + def _get_by_id(self, context, id, verbose=None): + try: + query = self._model_query(context) + if verbose: + if verbose and isinstance(verbose, list): + # XXX orm is undefined; please fix + options = [orm.joinedload(join) for join in + self.delegate.joins if join in verbose] + else: + # XXX orm is undefined; please fix + options = [orm.joinedload(join) for join in + self.delegate.joins] + query = query.options(*options) + return query.filter_by(id=id).one() + except sa_exc.NoResultFound: + raise q_exc.NotFound() + + def _get_item(self, context, id, fields=None, verbose=None): + obj = self._get_by_id(context, id, verbose=verbose) + return self._fields(self.delegate.make_dict(obj), fields) + + def _update_item(self, id, **kwargs): + key = self.delegate.resource_name + resource_dict = kwargs[key][key] + # XXX context and verbase are not defined here, probably missing in the + # method signature; please fix + obj = self._get_by_id(context, id, verbose=verbose) + return self.delegate.update(obj, resource_dict) + + def _create_item(self, context, **kwargs): + key = self.delegate.resource_name + resource_dict = kwargs[key][key] + tenant_id = self._get_tenant_id_for_create(context, resource_dict) + return self.delegate.create(tenant_id, resource_dict) + + def _delete_item(self, context, id): + # XXX verbose is missing a definition, probably missing from the method + # signature; please fix + obj = self._get_by_id(context, id, verbose=verbose) + with context.session.begin(): + self.delegate.before_delete(obj) + context.session.delete(obj) + + def _fields(self, resource, fields): + if fields: + return dict([(key, item) for key, item in resource.iteritems() + if key in fields]) + return resource + + +class ResourceDelegateInterface(object): + """ + An abstract marker class defines the interface of RESTful resources. + """ + __metaclass__ = abc.ABCMeta + + def before_delete(self, resource): + pass + + @abc.abstractproperty + def model(self): + pass + + @abc.abstractproperty + def resource_name(self): + pass + + @abc.abstractproperty + def collection_name(self): + pass + + @property + def joins(self): + return () + + @abc.abstractmethod + def update(self, tenant_id, resource, body): + pass + + @abc.abstractmethod + def create(self, tenant_id, body): + pass + + @abc.abstractmethod + def make_dict(self, obj): + pass + + +class ResourceDelegate(ResourceDelegateInterface): + """ + This class partially implemnts the ResourceDelegateInterface, providing + common code for use by child classes that inherit from it. + """ + def create(self, tenant_id, body): + with context.session.begin(subtransactions=True): + item = self.model(**body) + context.session.add(item) + return self.make_dict(item) + + def update(self, tenant_id, resource, resource_dict): + with context.session.begin(subtransactions=True): + item = self.model(**resource) + context.session.update(item) + return self.make_dict(item) + + def delete(self, tenant_id, resource, resource_dict): + pass + + +def create_extension(delegate): + """ + """ + #for key, value in delegate.ATTRIBUTE_MAP.iteritems(): + # if key in attributes.RESOURCE_ATTRIBUTE_MAP: + # pass # TODO(mark): should log that we're doing this + # attributes.RESOURCE_ATTRIBUTE_MAP[key] = value + return api_resource.Resource(base.Controller(ResourcePlugin(delegate), + delegate.collection_name, + delegate.resource_name, + delegate.ATTRIBUTE_MAP)) + + +def register_quota(resource_name, config_key_name, default=-1): + """ + """ + quota_opt = cfg.IntOpt(config_key_name, + default=default, + help=('number of %s allowed per tenant, -1 for ' + 'unlimited' % resource_name)) + cfg.CONF.register_opts([quota_opt], 'QUOTAS') + quota.QUOTAS.register_resource( + quota.CountableResource(resource_name, + quota._count_resource, + config_key_name)) diff --git a/akanda/quantum/addressbook.py b/akanda/quantum/addressbook.py new file mode 100644 index 0000000..1325b1b --- /dev/null +++ b/akanda/quantum/addressbook.py @@ -0,0 +1,77 @@ +from quantum.api.v2 import attributes +from quantum.db import models_v2 +from quantum.extensions import extensions + +from akanda.quantum import _authzbase +from akanda.quantum.db import models + + +# XXX: I used Network as an existing model for testing. Need to change to +# use an actual PortForward model. +# +# Duncan: cool, we'll get a PortForward model in place ASAP, so that this code +# can be updated to use it. + + +class AddressBookResource(_authzbase.ResourceDelegate): + """ + """ + model = models.AddressBook + resource_name = 'addressbook' + collection_name = 'addressbookgroup' + + ATTRIBUTE_MAP = { + 'id': {'allow_post': False, 'allow_put': False, + 'validate': {'type:regex': attributes.UUID_PATTERN}, + 'is_visible': True}, + 'name': {'allow_post': True, 'allow_put': True, + 'default': '', 'is_visible': True}, + 'tenant_id': {'allow_post': True, 'allow_put': False, + 'required_by_policy': True, + 'is_visible': True}, + } + + def make_dict(self, addressbook): + """ + Convert a addressbook model object to a dictionary. + """ + res = {'id': addressbook['id'], + 'name': addressbook['name'], + 'groups': [group['id'] + for group in addressbook['groups']]} + + return res + + +_authzbase.register_quota('portforward', 'quota_portforward') + + +class Portforward(object): + """ + """ + def get_name(self): + return "port forward" + + def get_alias(self): + return "dhportforward" + + def get_description(self): + return "A port forwarding extension" + + def get_namespace(self): + return 'http://docs.dreamcompute.com/api/ext/v1.0' + + def get_updated(self): + return "2012-08-02T16:00:00-05:00" + + def get_resources(self): + return [extensions.ResourceExtension( + 'dhportforward', + _authzbase.create_extension(PortforwardResource()))] + #_authzbase.ResourceController(PortforwardResource()))] + + def get_actions(self): + return [] + + def get_request_extensions(self): + return [] diff --git a/akanda/quantum/db/__init__.py b/akanda/quantum/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/akanda/quantum/db/models.py b/akanda/quantum/db/models.py new file mode 100644 index 0000000..9364afe --- /dev/null +++ b/akanda/quantum/db/models.py @@ -0,0 +1,54 @@ +import sqlalchemy as sa +from sqlalchemy import orm + +from quantum.db import model_base +from quantum.db import models_v2 as models +from quantum.openstack.common import timeutils + + +class PortForward(model_base.BASEV2, models.HasId, models.HasTenant): + name = sa.Column(sa.String(255)) + public_port = sa.Column(sa.Integer, nullable=False) + instance_id = sa.Column(sa.String(36), nullable=False) + private_port = sa.Column(sa.Integer, nullable=True) + # Quantum port address are stored in ipallocation which are internally + # referred to as fixed_id, thus the name below. + # XXX can we add a docsting to this model that explains how fixed_id is + # used? + fixed_id = sa.Column( + sa.String(36), sa.ForeignKey('ipallocation.id', ondelete="CASCADE"), + nullable=True) + + +class AddressBookEntry(model_base.BASEV2, models.HasId, models.HasTenant): + group_id = sa.Column(sa.String(36), sa.ForeignKey('addressbookgroup.id'), + nullable=False) + cidr = sa.Column(sa.String(64), nullable=False) + + +class AddressBookGroup(model_base.BASEV2, models.HasId, models.HasTenant): + name = sa.Column(sa.String(255), nullable=False, primary_key=True) + table_id = sa.Column(sa.String(36), sa.ForeignKey('addressbook.id'), + nullable=False) + entries = orm.relationship(AddressBookEntry, backref='groups') + + +class AddressBook(model_base.BASEV2, models.HasId, models.HasTenant): + name = sa.Column(sa.String(255), nullable=False, primary_key=True) + groups = orm.relationship(AddressBookGroup, backref='book') + + +class FilterRule(model_base.BASEV2, models.HasId, models.HasTenant): + action = sa.Column(sa.String(6), nullable=False, primary_key=True) + ip_version = sa.Column(sa.Integer, nullable=True) + protocol = sa.Column(sa.String(4), nullable=False) + source_alias = sa.Column(sa.String(36), + sa.ForeignKey('addressbookentry.id'), + nullable=False) + source_port = sa.Column(sa.Integer, nullable=True) + destination_alias = sa.Column(sa.String(36), + sa.ForeignKey('addressbookentry.id'), + nullable=False) + destination_port = sa.Column(sa.Integer, nullable=True) + created_at = sa.Column(sa.DateTime, default=timeutils.utcnow, + nullable=False) diff --git a/akanda/quantum/firewall.py b/akanda/quantum/firewall.py new file mode 100644 index 0000000..06efeca --- /dev/null +++ b/akanda/quantum/firewall.py @@ -0,0 +1,80 @@ +from quantum.api.v2 import attributes +from quantum.db import models_v2 +from quantum.extensions import extensions + +from akanda.quantum import _authzbase +from akanda.quantum.db import models + + +# XXX: I used Network as an existing model for testing. Need to change to +# use an actual PortForward model. +# +# Duncan: cool, we'll get a PortForward model in place ASAP, so that this code +# can be updated to use it. + + +class FirewallResource(_authzbase.ResourceDelegate): + """ + """ + model = models.Firewall + resource_name = 'firewall' + collection_name = 'firewalls' + + ATTRIBUTE_MAP = { + 'id': {'allow_post': False, 'allow_put': False, + 'validate': {'type:regex': attributes.UUID_PATTERN}, + 'is_visible': True}, + 'name': {'allow_post': True, 'allow_put': True, + 'default': '', 'is_visible': True}, + 'tenant_id': {'allow_post': True, 'allow_put': False, + 'required_by_policy': True, + 'is_visible': True}, + } + + def make_dict(self, firewall): + """ + Convert a firewall model object to a dictionary. + """ + res = {'id': firewall['id'], + 'action': firewall['action'], + 'protocol': firewall['protocol'], + 'source_alias': firewall['source_alias'], + 'source_port': firewall['source_port'], + 'destination_alias': firewall['destination_alias'], + 'destination_port': firewall['destination_port'], + 'created_at': firewall['created_at']} + return res + + +_authzbase.register_quota('portforward', 'quota_portforward') + + +class Portforward(object): + """ + """ + def get_name(self): + return "port forward" + + def get_alias(self): + return "dhportforward" + + def get_description(self): + return "A port forwarding extension" + + def get_namespace(self): + return 'http://docs.dreamcompute.com/api/ext/v1.0' + + def get_updated(self): + return "2012-08-02T16:00:00-05:00" + + def get_resources(self): + return [extensions.ResourceExtension( + 'dhportforward', + _authzbase.create_extension(PortforwardResource()))] + #_authzbase.ResourceController(PortforwardResource()))] + + def get_actions(self): + return [] + + def get_request_extensions(self): + return [] diff --git a/akanda/quantum/portforward.py b/akanda/quantum/portforward.py new file mode 100644 index 0000000..ebd227b --- /dev/null +++ b/akanda/quantum/portforward.py @@ -0,0 +1,78 @@ +from quantum.api.v2 import attributes +from quantum.extensions import extensions + +from akanda.quantum import _authzbase +from akanda.quantum.db import models + + +# XXX: I used Network as an existing model for testing. Need to change to +# use an actual PortForward model. +# +# Duncan: cool, we'll get a PortForward model in place ASAP, so that this code +# can be updated to use it. + + +class PortforwardResource(_authzbase.ResourceDelegate): + """ + This class is responsible for receiving REST requests and operating on the + defined data model to create, update, or delete portforward-related data. + """ + model = models.PortForward + resource_name = 'portforward' + collection_name = 'portforwards' + + ATTRIBUTE_MAP = { + 'id': {'allow_post': False, 'allow_put': False, + 'validate': {'type:regex': attributes.UUID_PATTERN}, + 'is_visible': True}, + 'name': {'allow_post': True, 'allow_put': True, + 'default': '', 'is_visible': True}, + 'tenant_id': {'allow_post': True, 'allow_put': False, + 'required_by_policy': True, + 'is_visible': True}, + } + + def make_dict(self, portforward): + """ + Convert a portforward model object to a dictionary. + """ + res = {'id': portforward['id'], + 'name': portforward['name'], + 'instance_id': portforward['instance_id'], + 'private_port': portforward['private_port'], + 'fixed_id': portforward['fixed_id']} + return res + + +_authzbase.register_quota('portforward', 'quota_portforward') + + +class Portforward(object): + """ + """ + def get_name(self): + return "port forward" + + def get_alias(self): + return "dhportforward" + + def get_description(self): + return "A port forwarding extension" + + def get_namespace(self): + return 'http://docs.dreamcompute.com/api/ext/v1.0' + + def get_updated(self): + return "2012-08-02T16:00:00-05:00" + + def get_resources(self): + return [extensions.ResourceExtension( + 'dhportforward', + _authzbase.create_extension(PortforwardResource()))] + #_authzbase.ResourceController(PortforwardResource()))] + + def get_actions(self): + return [] + + def get_request_extensions(self): + return [] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1fb7f5d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[nosetests] +where = test +verbosity = 2 +detailed-errors = 1 +cover-package = akanda diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..47f0a8d --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +import os + +from setuptools import setup, find_packages + + +setup( + name='Akanda Horizon Dashboard Plugin', + version='0.1.0', + description='OpenStack Horizon dashboards for manipulating L3 extensions', + author='DreamHost', + author_email='dev-community@dreamhost.com', + url='http://github.com/dreamhost/akanda', + license='BSD', + install_requires=[ + ], + namespace_packages=['akanda'], + packages=find_packages(), + include_package_data=True, + zip_safe=False, +) + diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..12d57b1 --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1,7 @@ +tox +unittest2 +nose +coverage +mock>=0.8.0 +pep8 +pyflakes diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..2bc9a2a --- /dev/null +++ b/tox.ini @@ -0,0 +1,26 @@ +[tox] +envlist = py26,py27,pep8,pyflakes + +[testenv] +setenv = VIRTUAL_ENV={envdir} +deps = -r{toxinidir}/test_requirements.txt +commands = nosetests {posargs} +sitepackages = True + +[tox:jenkins] + +[testenv:pep8] +deps = pep8 + setuptools_git>=0.4 +commands = pep8 --repeat --show-source --ignore=E125 --exclude=.venv,.tox,dist,doc,*egg . + +[testenv:cover] +setenv = NOSE_WITH_COVERAGE=1 + +[testenv:venv] +commands = {posargs} + +[testenv:pyflakes] +deps = pyflakes +commands = pyflakes akanda +