pass
return aliases
+ @classmethod
+ def clear_instance(cls):
+ cls._instance = None
+
def check_if_plugin_extensions_loaded(self):
"""Check if an extension supported by a plugin has been loaded."""
plugin_extensions = self.get_supported_extension_aliases()
def build_plural_mappings(special_mappings, resource_map):
"""Create plural to singular mapping for all resources.
- Allows for special mappings to be provided, like policies -> policy.
+ Allows for special mappings to be provided, for particular cases..
Otherwise, will strip off the last character for normal mappings, like
- routers -> router.
+ routers -> router, unless the plural name ends with 'ies', in which
+ case the singular form will end with a 'y' (e.g.: policy/policies)
"""
plural_mappings = {}
for plural in resource_map:
- singular = special_mappings.get(plural, plural[:-1])
+ singular = special_mappings.get(plural)
+ if not singular:
+ if plural.endswith('ies'):
+ singular = "%sy" % plural[:-3]
+ else:
+ singular = plural[:-1]
plural_mappings[plural] = singular
return plural_mappings
# the rest of service plugins
self.service_plugins = {constants.CORE: self.plugin}
self._load_service_plugins()
+ # Used by pecan WSGI
+ self.resource_plugin_mappings = {}
+ self.resource_controller_mappings = {}
@staticmethod
def load_class_for_provider(namespace, plugin_provider):
def get_unique_service_plugins(cls):
service_plugins = cls.get_instance().service_plugins
return tuple(weakref.proxy(x) for x in set(service_plugins.values()))
+
+ @classmethod
+ def set_plugin_for_resource(cls, resource, plugin):
+ cls.get_instance().resource_plugin_mappings[resource] = plugin
+
+ @classmethod
+ def get_plugin_for_resource(cls, resource):
+ return cls.get_instance().resource_plugin_mappings.get(resource)
+
+ @classmethod
+ def set_controller_for_resource(cls, resource, controller):
+ cls.get_instance().resource_controller_mappings[resource] = controller
+
+ @classmethod
+ def get_controller_for_resource(cls, resource):
+ return cls.get_instance().resource_controller_mappings.get(resource)
app_hooks = [
hooks.ExceptionTranslationHook(), # priority 100
hooks.ContextHook(), # priority 95
- hooks.ResourceIdentifierHook(), # priority 95
+ hooks.MemberActionHook(), # piority 95
hooks.AttributePopulationHook(), # priority 120
hooks.OwnershipValidationHook(), # priority 125
hooks.QuotaEnforcementHook(), # priority 130
# License for the specific language governing permissions and limitations
# under the License.
+from oslo_log import log
import pecan
from pecan import request
from neutron.api import extensions
from neutron.api.views import versions as versions_view
+from neutron.i18n import _LW
+from neutron import manager
+LOG = log.getLogger(__name__)
_VERSION_INFO = {}
pecan.abort(405)
@expose()
- def _lookup(self, endpoint, *remainder):
- return CollectionsController(endpoint), remainder
+ def _lookup(self, collection, *remainder):
+ controller = manager.NeutronManager.get_controller_for_resource(
+ collection)
+ if not controller:
+ LOG.warn(_LW("No controller found for: %s - returning response "
+ "code 404"), collection)
+ pecan.abort(404)
+ # Store resource name in pecan request context so that hooks can
+ # leverage it if necessary
+ request.context['resource'] = controller.resource
+ return controller, remainder
# This controller cannot be specified directly as a member of RootController
return {'extension': extensions.ExtensionController._translate(ext)}
-class CollectionsController(object):
+class NeutronPecanController(object):
- def __init__(self, collection):
+ def __init__(self, collection, resource):
self.collection = collection
+ self.resource = resource
+ self.plugin = manager.NeutronManager.get_plugin_for_resource(
+ self.resource)
+
+
+class CollectionsController(NeutronPecanController):
@expose()
def _lookup(self, item, *remainder):
- return ItemController(item), remainder
+ return ItemController(self.resource, item), remainder
@expose(generic=True)
def index(self, *args, **kwargs):
_listify = lambda x: x if isinstance(x, list) else [x]
filters = {k: _listify(v) for k, v in kwargs.items()}
# TODO(kevinbenton): convert these using api_common.get_filters
- lister = getattr(request.plugin, 'get_%s' % self.collection)
- return {self.collection: lister(request.context, filters=filters)}
+ lister = getattr(self.plugin, 'get_%s' % self.collection)
+ neutron_context = request.context.get('neutron_context')
+ return {self.collection: lister(neutron_context, filters=filters)}
@when(index, method='POST')
def post(self, *args, **kwargs):
# TODO(kevinbenton): emulated bulk!
pecan.response.status = 201
if request.bulk:
- method = 'create_%s_bulk' % request.resource_type
+ method = 'create_%s_bulk' % self.resource
else:
- method = 'create_%s' % request.resource_type
- creator = getattr(request.plugin, method)
- key = self.collection if request.bulk else request.resource_type
- return {key: creator(request.context, request.prepared_data)}
+ method = 'create_%s' % self.resource
+ creator = getattr(self.plugin, method)
+ key = self.collection if request.bulk else self.resource
+ neutron_context = request.context.get('neutron_context')
+ return {key: creator(neutron_context, request.prepared_data)}
-class ItemController(object):
+class ItemController(NeutronPecanController):
- def __init__(self, item):
+ def __init__(self, resource, item):
+ super(ItemController, self).__init__(None, resource)
self.item = item
@expose(generic=True)
return self.get()
def get(self, *args, **kwargs):
- getter = getattr(request.plugin, 'get_%s' % request.resource_type)
- return {request.resource_type: getter(request.context, self.item)}
+ getter = getattr(self.plugin, 'get_%s' % self.resource)
+ neutron_context = request.context.get('neutron_context')
+ return {self.resource: getter(neutron_context, self.item)}
@when(index, method='PUT')
def put(self, *args, **kwargs):
+ neutron_context = request.context.get('neutron_context')
if request.member_action:
- member_action_method = getattr(request.plugin,
+ member_action_method = getattr(self.plugin,
request.member_action)
- return member_action_method(request.context, self.item,
+ return member_action_method(neutron_context, self.item,
request.prepared_data)
- updater = getattr(request.plugin, 'update_%s' % request.resource_type)
- return {request.resource_type: updater(
- request.context, self.item, request.prepared_data)}
+ # TODO(kevinbenton): bulk?
+ updater = getattr(self.plugin, 'update_%s' % self.resource)
+ return updater(neutron_context, self.item, request.prepared_data)
@when(index, method='DELETE')
def delete(self):
# TODO(kevinbenton): setting code could be in a decorator
pecan.response.status = 204
- deleter = getattr(request.plugin, 'delete_%s' % request.resource_type)
- return deleter(request.context, self.item)
+ neutron_context = request.context.get('neutron_context')
+ deleter = getattr(self.plugin, 'delete_%s' % self.resource)
+ return deleter(neutron_context, self.item)
from neutron.pecan_wsgi.hooks import attribute_population
from neutron.pecan_wsgi.hooks import context
+from neutron.pecan_wsgi.hooks import member_action
from neutron.pecan_wsgi.hooks import notifier
from neutron.pecan_wsgi.hooks import ownership_validation
from neutron.pecan_wsgi.hooks import policy_enforcement
from neutron.pecan_wsgi.hooks import quota_enforcement
-from neutron.pecan_wsgi.hooks import resource_identifier
from neutron.pecan_wsgi.hooks import translation
ExceptionTranslationHook = translation.ExceptionTranslationHook
ContextHook = context.ContextHook
-ResourceIdentifierHook = resource_identifier.ResourceIdentifierHook
+MemberActionHook = member_action.MemberActionHook
AttributePopulationHook = attribute_population.AttributePopulationHook
OwnershipValidationHook = ownership_validation.OwnershipValidationHook
PolicyHook = policy_enforcement.PolicyHook
from neutron.api.v2 import attributes
from neutron.api.v2 import base as v2base
+from neutron import manager
class AttributePopulationHook(hooks.PecanHook):
if state.request.method not in ('POST', 'PUT'):
return
is_create = state.request.method == 'POST'
- resource = state.request.resource_type
+ resource = state.request.context.get('resource')
+ neutron_context = state.request.context['neutron_context']
if not resource:
return
if state.request.member_action:
else:
state.request.prepared_data = (
v2base.Controller.prepare_request_body(
- state.request.context, state.request.json, is_create,
+ neutron_context, state.request.json, is_create,
resource, _attributes_for_resource(resource),
allow_bulk=True))
# TODO(kevinbenton): conditional allow_bulk
+
state.request.resources = _extract_resources_from_state(state)
# make the original object available:
if not is_create and not state.request.member_action:
- obj_id = _pull_id_from_request(state.request)
+ obj_id = _pull_id_from_request(state.request, resource)
attrs = _attributes_for_resource(resource)
field_list = [name for (name, value) in attrs.items()
if (value.get('required_by_policy') or
value.get('primary_key') or
'default' not in value)]
- plugin = state.request.plugin
+ plugin = manager.NeutronManager.get_plugin_for_resource(resource)
getter = getattr(plugin, 'get_%s' % resource)
# TODO(kevinbenton): the parent_id logic currently in base.py
- obj = getter(state.request.context, obj_id, fields=field_list)
+ obj = getter(neutron_context, obj_id, fields=field_list)
state.request.original_object = obj
_plural(resource), {})
-def _pull_id_from_request(request):
+def _pull_id_from_request(request, resource):
# NOTE(kevinbenton): this sucks
# Converting /v2.0/ports/dbbdae29-82f6-49cf-b05e-3365bcc95b7a.json
# into dbbdae29-82f6-49cf-b05e-3365bcc95b7a
- resources = _plural(request.resource_type)
+ resources = _plural(resource)
jsontrail = request.path_info.replace('/v2.0/%s/' % resources, '')
obj_id = jsontrail.replace('.json', '')
return obj_id
def _extract_resources_from_state(state):
- resource_type = state.request.resource_type
- if not resource_type:
+ resource = state.request.context['resource']
+ if not resource:
return []
data = state.request.prepared_data
# single item
- if resource_type in data:
+ if resource in data:
state.request.bulk = False
- return [data[resource_type]]
+ return [data[resource]]
# multiple items
- if _plural(resource_type) in data:
+ if _plural(resource) in data:
state.request.bulk = True
- return [x[resource_type] for x in data[_plural(resource_type)]]
+ return data[_plural(resource)]
return []
request_id=req_id, auth_token=auth_token)
# Inject the context...
- state.request.context = ctx
+ state.request.context['neutron_context'] = ctx
--- /dev/null
+# Copyright (c) 2015 Mirantis, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from pecan import abort
+from pecan import hooks
+
+from neutron.api import extensions
+from neutron.api.v2 import attributes
+
+
+class MemberActionHook(hooks.PecanHook):
+
+ priority = 95
+
+ def before(self, state):
+ # TODO(salv-orlando): This hook must go. Handling actions like this is
+ # shameful
+ resource = state.request.context.get('resource')
+ if not resource:
+ return
+ try:
+ # Remove the format suffix if any
+ uri = state.request.path.rsplit('.', 1)[0].split('/')[2:]
+ if not uri:
+ # there's nothing to process in the URI
+ return
+ except IndexError:
+ return
+ collection = None
+ for (collection, res) in attributes.PLURALS.items():
+ if res == resource:
+ break
+ else:
+ return
+ state.request.member_action = self._parse_action(
+ resource, collection, uri[1:])
+
+ def _parse_action(self, resource, collection, remainder):
+ # NOTE(salv-orlando): This check is revolting and makes me
+ # puke, but avoids silly failures when dealing with API actions
+ # such as "add_router_interface".
+ if len(remainder) > 1:
+ action = remainder[1]
+ else:
+ return
+ ext_mgr = extensions.PluginAwareExtensionManager.get_instance()
+ resource_exts = ext_mgr.get_resources()
+ for ext in resource_exts:
+ if (ext.collection == collection and action in ext.member_actions):
+ return action
+ # Action or resource extension not found
+ if action:
+ abort(404, detail="Action %(action)s for resource "
+ "%(resource)s undefined" %
+ {'action': action,
+ 'resource': resource})
return
items = state.request.resources
for item in items:
- self._validate_network_tenant_ownership(state.request, item)
+ self._validate_network_tenant_ownership(state, item)
- def _validate_network_tenant_ownership(self, request, resource_item):
+ def _validate_network_tenant_ownership(self, state, resource_item):
# TODO(salvatore-orlando): consider whether this check can be folded
# in the policy engine
- rtype = request.resource_type
- if (request.context.is_admin or request.context.is_advsvc or
- rtype not in ('port', 'subnet')):
+ neutron_context = state.request.context.get('neutron_context')
+ resource = state.request.context.get('resource')
+ if (neutron_context.is_admin or neutron_context.is_advsvc or
+ resource not in ('port', 'subnet')):
return
plugin = manager.NeutronManager.get_plugin()
- network = plugin.get_network(request.context,
+ network = plugin.get_network(neutron_context,
resource_item['network_id'])
# do not perform the check on shared networks
if network.get('shared'):
"create %(resource)s on this network")
raise webob.exc.HTTPForbidden(msg % {
"tenant_id": resource_item['tenant_id'],
- "resource": rtype,
+ "resource": resource,
})
import copy
import simplejson
+from oslo_log import log
from oslo_policy import policy as oslo_policy
from oslo_utils import excutils
import pecan
import webob
from neutron.common import constants as const
+from neutron import manager
from neutron.pecan_wsgi.hooks import attribute_population
from neutron import policy
+LOG = log.getLogger(__name__)
+
class PolicyHook(hooks.PecanHook):
priority = 135
def before(self, state):
if state.request.method not in self.ACTION_MAP:
pecan.abort(405)
- rtype = state.request.resource_type
- if not rtype:
- return
+ neutron_context = state.request.context.get('neutron_context')
+ resource = state.request.context.get('resource')
is_update = (state.request.method == 'PUT')
items = state.request.resources
policy.init()
- action = '%s_%s' % (self.ACTION_MAP[state.request.method], rtype)
+ action = '%s_%s' % (self.ACTION_MAP[state.request.method], resource)
for item in items:
if is_update:
obj = copy.copy(state.request.original_object)
obj[const.ATTRIBUTES_TO_UPDATE] = item.keys()
item = obj
try:
- policy.enforce(state.request.context, action, item,
- pluralized=attribute_population._plural(rtype))
+ policy.enforce(
+ neutron_context, action, item,
+ pluralized=attribute_population._plural(resource))
except oslo_policy.PolicyNotAuthorized:
with excutils.save_and_reraise_exception() as ctxt:
# If a tenant is modifying it's own object, it's safe to
# return a 403. Otherwise, pretend that it doesn't exist
# to avoid giving away information.
- context = state.request.context
if (is_update and
- context.tenant_id != obj['tenant_id']):
+ neutron_context.tenant_id != obj['tenant_id']):
ctxt.reraise = False
msg = _('The resource could not be found.')
raise webob.exc.HTTPNotFound(msg)
def after(self, state):
- resource_type = getattr(state.request, 'resource_type', None)
- if not resource_type:
+ neutron_context = state.request.context.get('neutron_context')
+ resource = state.request.context.get('resource')
+ if not resource:
# can't filter a resource we don't recognize
return
# NOTE(kevinbenton): extension listing isn't controlled by policy
- if resource_type == 'extension':
+ if resource == 'extension':
return
try:
data = state.response.json
except simplejson.JSONDecodeError:
return
action = '%s_%s' % (self.ACTION_MAP[state.request.method],
- resource_type)
- plural = attribute_population._plural(resource_type)
- if not data or (resource_type not in data and plural not in data):
+ resource)
+ plural = attribute_population._plural(resource)
+ if not data or (resource not in data and plural not in data):
return
- is_single = resource_type in data
- key = resource_type if is_single else plural
- to_process = [data[resource_type]] if is_single else data[plural]
+ is_single = resource in data
+ key = resource if is_single else plural
+ to_process = [data[resource]] if is_single else data[plural]
# in the single case, we enforce which raises on violation
# in the plural case, we just check so violating items are hidden
policy_method = policy.enforce if is_single else policy.check
- resp = [self._get_filtered_item(state.request, resource_type, item)
+ plugin = manager.NeutronManager.get_plugin_for_resource(resource)
+ resp = [self._get_filtered_item(state.request, resource, item)
for item in to_process
if (state.request.method != 'GET' or
- policy_method(state.request.context, action, item,
- plugin=state.request.plugin,
+ policy_method(neutron_context, action, item,
+ plugin=plugin,
pluralized=plural))]
if is_single:
resp = resp[0]
data[key] = resp
state.response.json = data
- def _get_filtered_item(self, request, resource_type, data):
- to_exclude = self._exclude_attributes_by_policy(request.context,
- resource_type, data)
+ def _get_filtered_item(self, request, resource, data):
+ neutron_context = request.context.get('neutron_context')
+ to_exclude = self._exclude_attributes_by_policy(
+ neutron_context, resource, data)
return self._filter_attributes(request, data, to_exclude)
def _filter_attributes(self, request, data, fields_to_strip):
if (item[0] not in fields_to_strip and
(not user_fields or item[0] in user_fields)))
- def _exclude_attributes_by_policy(self, context, resource_type, data):
+ def _exclude_attributes_by_policy(self, context, resource, data):
"""Identifies attributes to exclude according to authZ policies.
Return a list of attribute names which should be stripped from the
attributes_to_exclude = []
for attr_name in data.keys():
attr_data = attribute_population._attributes_for_resource(
- resource_type).get(attr_name)
+ resource).get(attr_name)
if attr_data and attr_data['is_visible']:
if policy.check(
context,
# NOTE(kevinbenton): this used to reference a
# _plugin_handlers dict, why?
- 'get_%s:%s' % (resource_type, attr_name),
+ 'get_%s:%s' % (resource, attr_name),
data,
might_not_exist=True,
- pluralized=attribute_population._plural(resource_type)):
+ pluralized=attribute_population._plural(resource)):
# this attribute is visible, check next one
continue
# if the code reaches this point then either the policy check
# License for the specific language governing permissions and limitations
# under the License.
+from oslo_log import log as logging
+from pecan import hooks
+
from neutron.common import exceptions
+from neutron import manager
+from neutron.pecan_wsgi.hooks import attribute_population
from neutron import quota
-from oslo_log import log as logging
-from pecan import hooks
LOG = logging.getLogger(__name__)
priority = 130
def before(self, state):
+ # TODO(salv-orlando): This hook must go when adaptin the pecan code to
+ # use reservations.
if state.request.method != 'POST':
return
+ resource = state.request.context.get('resource')
+ plugin = manager.NeutronManager.get_plugin_for_resource(resource)
items = state.request.resources
- rtype = state.request.resource_type
deltas = {}
for item in items:
tenant_id = item['tenant_id']
try:
- count = quota.QUOTAS.count(state.request.context, rtype,
- state.request.plugin,
+ neutron_context = state.request.context.get('neutron_context')
+ count = quota.QUOTAS.count(neutron_context,
+ resource,
+ plugin,
+ attribute_population._plural(
+ resource),
tenant_id)
delta = deltas.get(tenant_id, 0) + 1
- kwargs = {rtype: count + delta}
+ kwargs = {resource: count + delta}
except exceptions.QuotaResourceUnknown as e:
# We don't want to quota this resource
LOG.debug(e)
else:
- quota.QUOTAS.limit_check(state.request.context, tenant_id,
+ quota.QUOTAS.limit_check(neutron_context, tenant_id,
**kwargs)
# License for the specific language governing permissions and limitations
# under the License.
+from oslo_log import log
+
from neutron.api import extensions
from neutron.api.v2 import attributes
+from neutron.api.v2 import router
+from neutron.i18n import _LI, _LW
+from neutron import manager
+from neutron.pecan_wsgi.controllers import root
from neutron import policy
+LOG = log.getLogger(__name__)
+
+
+def _plugin_for_resource(collection):
+ if collection in router.RESOURCES.values():
+ # this is a core resource, return the core plugin
+ return manager.NeutronManager.get_plugin()
+ ext_mgr = extensions.PluginAwareExtensionManager.get_instance()
+ # Multiple extensions can map to the same resource. This happens
+ # because of 'attribute' extensions. Due to the way in which neutron
+ # plugins and request dispatching is constructed, it is impossible for
+ # the same resource to be handled by more than one plugin. Therefore
+ # all the extensions mapped to a given resource will necessarily be
+ # implemented by the same plugin.
+ ext_res_mappings = dict((ext.get_alias(), collection) for
+ ext in ext_mgr.extensions.values() if
+ collection in ext.get_extended_resources('2.0'))
+ LOG.debug("Extension mappings for: %(collection)s: %(aliases)s",
+ {'collection': collection, 'aliases': ext_res_mappings.keys()})
+ # find the plugin that supports this extension
+ for plugin in ext_mgr.plugins.values():
+ ext_aliases = getattr(plugin, 'supported_extension_aliases', [])
+ for alias in ext_aliases:
+ if alias in ext_res_mappings:
+ # This plugin implements this resource
+ return plugin
+ LOG.warn(_LW("No plugin found for:%s"), collection)
+
+
+def _handle_plurals(collection):
+ resource = attributes.PLURALS.get(collection)
+ if not resource:
+ if collection.endswith('ies'):
+ resource = "%sy" % collection[:-3]
+ else:
+ resource = collection[:-1]
+ attributes.PLURALS[collection] = resource
+ return resource
+
def initialize_all():
ext_mgr = extensions.PluginAwareExtensionManager.get_instance()
ext_mgr.extend_resources("2.0", attributes.RESOURCE_ATTRIBUTE_MAP)
+ # At this stage we have a fully populated resource attribute map;
+ # build Pecan controllers and routes for every resource (both core
+ # and extensions)
+ pecanized_exts = [ext for ext in ext_mgr.extensions.values() if
+ hasattr(ext, 'get_pecan_controllers')]
+ pecan_controllers = {}
+ for ext in pecanized_exts:
+ LOG.debug("Extension %s is pecan-enabled. Fetching resources "
+ "and controllers", ext.get_name())
+ controllers = ext.get_pecan_controllers()
+ # controllers is actually a list of pairs where the first element is
+ # the collection name and the second the actual controller
+ for (collection, coll_controller) in controllers:
+ pecan_controllers[collection] = coll_controller
+
+ for collection in attributes.RESOURCE_ATTRIBUTE_MAP:
+ if collection not in pecan_controllers:
+ resource = _handle_plurals(collection)
+ LOG.debug("Building controller for resource:%s", resource)
+ plugin = _plugin_for_resource(collection)
+ if plugin:
+ manager.NeutronManager.set_plugin_for_resource(
+ resource, plugin)
+ controller = root.CollectionsController(collection, resource)
+ manager.NeutronManager.set_controller_for_resource(
+ collection, controller)
+ LOG.info(_LI("Added controller for resource %(resource)s "
+ "via URI path segment:%(collection)s"),
+ {'resource': resource,
+ 'collection': collection})
+ else:
+ LOG.debug("There are already controllers for resource:%s",
+ resource)
+
for ext in ext_mgr.extensions.values():
# make each extension populate its plurals
if hasattr(ext, 'get_resources'):
from pecan.testing import load_test_app
import testtools
+from neutron.api import extensions
from neutron.api.v2 import attributes
from neutron.common import exceptions as n_exc
from neutron import context
def setUp(self):
self.setup_coreplugin('neutron.plugins.ml2.plugin.Ml2Plugin')
super(PecanFunctionalTest, self).setUp()
+ self.addCleanup(extensions.PluginAwareExtensionManager.clear_instance)
self.addCleanup(set_config, {}, overwrite=True)
self.set_config_overrides()
self.setup_app()
def capture_request_details(*args, **kwargs):
self.req_stash = {
- 'context': request.context,
- 'resource_type': request.resource_type,
- 'plugin': request.plugin
+ 'context': request.context['neutron_context'],
+ 'resource_type': request.context['resource'],
}
mock.patch(
'neutron.pecan_wsgi.controllers.root.CollectionsController.get',
def test_core_resource_identified(self):
self.app.get('/v2.0/ports.json')
self.assertEqual('port', self.req_stash['resource_type'])
- # make sure the core plugin was identified as the handler for ports
- self.assertEqual(manager.NeutronManager.get_plugin(),
- self.req_stash['plugin'])
def test_service_plugin_identified(self):
# TODO(kevinbenton): fix the unit test setup to include an l3 plugin