Refactor the plugin layer to use entrypoints

Introduce the concept of a feature set, which can be
registered to an entrypoint.

Rework all existing core elements into a 'core' feature
set.

Remove the ability to add in random django apps, and drop
the ablity for plugins to optionally be able to great
new DB models.

Change-Id: Idc5c3bf3facc44bb615fa4006d417d6f48a16ddc
This commit is contained in:
Adrian Turjak 2019-09-12 17:52:20 +12:00 committed by Adrian Turjak
parent c750fd6d6c
commit 0eaac89b38
60 changed files with 634 additions and 463 deletions

View File

@ -46,5 +46,5 @@ class Action(models.Model):
def get_action(self):
"""Returns self as the appropriate action wrapper type."""
data = self.action_data
return actions.ACTION_CLASSES[self.action_name][0](
return actions.ACTION_CLASSES[self.action_name](
data=data, action_model=self)

View File

@ -1 +0,0 @@
default_app_config = 'adjutant.actions.v1.app.ActionV1Config'

View File

@ -1,7 +0,0 @@
from django.apps import AppConfig
class ActionV1Config(AppConfig):
name = "adjutant.actions.v1"
label = 'actions_v1'

View File

@ -60,6 +60,8 @@ class BaseAction(object):
required = []
serializer = None
config_group = None
def __init__(self, data, action_model=None, task=None,

View File

@ -19,6 +19,7 @@ from confspirator import fields
from confspirator import types
from adjutant.actions.v1.base import BaseAction
from adjutant.actions.v1 import serializers
from adjutant.actions.utils import send_email
from adjutant.common import user_store
from adjutant.common import constants
@ -95,6 +96,8 @@ def _build_default_email_group(group_name):
class SendAdditionalEmailAction(BaseAction):
serializer = serializers.SendAdditionalEmailSerializer
config_group = groups.DynamicNameConfigGroup(
children=[
_build_default_email_group("prepare"),

View File

@ -1,88 +0,0 @@
# Copyright (C) 2015 Catalyst IT Ltd
#
# 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 rest_framework import serializers as drf_serializers
from adjutant import actions
from adjutant.actions.v1 import serializers
from adjutant.actions.v1.base import BaseAction
from adjutant.actions.v1.projects import (
NewProjectWithUserAction, NewProjectAction,
AddDefaultUsersToProjectAction)
from adjutant.actions.v1.users import (
EditUserRolesAction, NewUserAction, ResetUserPasswordAction,
UpdateUserEmailAction)
from adjutant.actions.v1.resources import (
NewDefaultNetworkAction, NewProjectDefaultNetworkAction,
SetProjectQuotaAction, UpdateProjectQuotasAction)
from adjutant.actions.v1.misc import SendAdditionalEmailAction
from adjutant import exceptions
from adjutant.config.workflow import action_defaults_group as action_config
# Update ACTION_CLASSES dict with tuples in the format:
# (<ActionClass>, <ActionSerializer>)
def register_action_class(action_class, serializer_class):
if not issubclass(action_class, BaseAction):
raise exceptions.InvalidActionClass(
"'%s' is not a built off the BaseAction class."
% action_class.__name__
)
if serializer_class and not issubclass(
serializer_class, drf_serializers.Serializer):
raise exceptions.InvalidActionSerializer(
"serializer for '%s' is not a valid DRF serializer."
% action_class.__name__
)
data = {}
data[action_class.__name__] = (action_class, serializer_class)
actions.ACTION_CLASSES.update(data)
if action_class.config_group:
# NOTE(adriant): We copy the config_group before naming it
# to avoid cases where a subclass inherits but doesn't extend it
setting_group = action_class.config_group.copy()
setting_group.set_name(
action_class.__name__, reformat_name=False)
action_config.register_child_config(setting_group)
# Register Project actions:
register_action_class(
NewProjectWithUserAction, serializers.NewProjectWithUserSerializer)
register_action_class(NewProjectAction, serializers.NewProjectSerializer)
register_action_class(
AddDefaultUsersToProjectAction,
serializers.AddDefaultUsersToProjectSerializer)
# Register User actions:
register_action_class(NewUserAction, serializers.NewUserSerializer)
register_action_class(ResetUserPasswordAction, serializers.ResetUserSerializer)
register_action_class(EditUserRolesAction, serializers.EditUserRolesSerializer)
register_action_class(
UpdateUserEmailAction, serializers.UpdateUserEmailSerializer)
# Register Resource actions:
register_action_class(
NewDefaultNetworkAction, serializers.NewDefaultNetworkSerializer)
register_action_class(
NewProjectDefaultNetworkAction,
serializers.NewProjectDefaultNetworkSerializer)
register_action_class(
SetProjectQuotaAction, serializers.SetProjectQuotaSerializer)
register_action_class(
UpdateProjectQuotasAction, serializers.UpdateProjectQuotasSerializer)
# Register Misc actions:
register_action_class(
SendAdditionalEmailAction, serializers.SendAdditionalEmailSerializer)

View File

@ -14,17 +14,18 @@
from uuid import uuid4
from django.utils import timezone
from confspirator import groups
from confspirator import fields
from django.utils import timezone
from adjutant.config import CONF
from adjutant.common import user_store
from adjutant.common.utils import str_datetime
from adjutant.actions.utils import validate_steps
from adjutant.actions.v1.base import (
BaseAction, UserNameAction, UserMixin, ProjectMixin)
from adjutant.actions.v1 import serializers
class NewProjectAction(BaseAction, ProjectMixin, UserMixin):
@ -41,6 +42,8 @@ class NewProjectAction(BaseAction, ProjectMixin, UserMixin):
'description',
]
serializer = serializers.NewProjectSerializer
config_group = groups.DynamicNameConfigGroup(
children=[
fields.ListConfig(
@ -149,6 +152,8 @@ class NewProjectWithUserAction(UserNameAction, ProjectMixin, UserMixin):
'email'
]
serializer = serializers.NewProjectWithUserSerializer
config_group = groups.DynamicNameConfigGroup(
children=[
fields.ListConfig(
@ -439,6 +444,8 @@ class AddDefaultUsersToProjectAction(BaseAction, ProjectMixin, UserMixin):
'domain_id',
]
serializer = serializers.AddDefaultUsersToProjectSerializer
config_group = groups.DynamicNameConfigGroup(
children=[
fields.ListConfig(

View File

@ -20,6 +20,7 @@ from confspirator import groups
from confspirator import fields
from adjutant.actions.v1.base import BaseAction, ProjectMixin, QuotaMixin
from adjutant.actions.v1 import serializers
from adjutant.actions.utils import validate_steps
from adjutant.common import openstack_clients, user_store
from adjutant.api import models
@ -40,6 +41,8 @@ class NewDefaultNetworkAction(BaseAction, ProjectMixin):
'region',
]
serializer = serializers.NewDefaultNetworkSerializer
config_group = groups.DynamicNameConfigGroup(
children=[
groups.ConfigGroup(
@ -232,6 +235,8 @@ class NewProjectDefaultNetworkAction(NewDefaultNetworkAction):
'region',
]
serializer = serializers.NewProjectDefaultNetworkSerializer
def _pre_validate(self):
# Note: Don't check project here as it doesn't exist yet.
self.action.valid = validate_steps([
@ -266,6 +271,8 @@ class UpdateProjectQuotasAction(BaseAction, QuotaMixin):
'regions',
]
serializer = serializers.UpdateProjectQuotasSerializer
config_group = groups.DynamicNameConfigGroup(
children=[
fields.FloatConfig(
@ -429,6 +436,8 @@ class SetProjectQuotaAction(UpdateProjectQuotasAction):
""" Updates quota for a given project to a configured quota level """
required = []
serializer = serializers.SetProjectQuotaSerializer
config_group = UpdateProjectQuotasAction.config_group.extend(
children=[
fields.DictConfig(

View File

@ -80,7 +80,7 @@ class NewProjectWithUserSerializer(BaseUserNameSerializer):
project_name = serializers.CharField(max_length=64)
class ResetUserSerializer(BaseUserNameSerializer):
class ResetUserPasswordSerializer(BaseUserNameSerializer):
domain_name = serializers.CharField(max_length=64, default='Default')
# override domain_id so serializer doesn't set it up.
domain_id = None

View File

@ -19,6 +19,7 @@ from adjutant.config import CONF
from adjutant.common import user_store
from adjutant.actions.v1.base import (
UserNameAction, UserIdAction, UserMixin, ProjectMixin)
from adjutant.actions.v1 import serializers
from adjutant.actions.utils import validate_steps
@ -39,6 +40,8 @@ class NewUserAction(UserNameAction, ProjectMixin, UserMixin):
'domain_id',
]
serializer = serializers.NewUserSerializer
def _validate_target_user(self):
id_manager = user_store.IdentityManager()
@ -181,6 +184,8 @@ class ResetUserPasswordAction(UserNameAction, UserMixin):
'email'
]
serializer = serializers.ResetUserPasswordSerializer
config_group = groups.DynamicNameConfigGroup(
children=[
fields.ListConfig(
@ -267,6 +272,8 @@ class EditUserRolesAction(UserIdAction, ProjectMixin, UserMixin):
'remove'
]
serializer = serializers.EditUserRolesSerializer
def _validate_target_user(self):
# Get target user
user = self._get_target_user()
@ -403,6 +410,8 @@ class UpdateUserEmailAction(UserIdAction, UserMixin):
'new_email',
]
serializer = serializers.UpdateUserEmailSerializer
def _get_email(self):
# Sending to new email address
return self.new_email

View File

@ -12,24 +12,23 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.apps import apps
from django.conf.urls import url, include
from django.conf import settings
from rest_framework_swagger.views import get_swagger_view
from adjutant.api import views
from adjutant.api.views import build_version_details
from adjutant.api.v1 import views as views_v1
urlpatterns = [
url(r'^$', views.VersionView.as_view()),
]
# NOTE(adriant): This may not be the best approach, but it does work. Will
# gladly accept a cleaner alternative if it presents itself.
if apps.is_installed('adjutant.api.v1'):
urlpatterns.append(url(r'^v1/?$', views_v1.V1VersionEndpoint.as_view()))
urlpatterns.append(url(r'^v1/', include('adjutant.api.v1.urls')))
# NOTE(adriant): make this conditional once we have a v2.
build_version_details('1.0', 'CURRENT', relative_endpoint='v1/')
urlpatterns.append(url(r'^v1/?$', views_v1.V1VersionEndpoint.as_view()))
urlpatterns.append(url(r'^v1/', include('adjutant.api.v1.urls')))
if settings.DEBUG:

View File

@ -1 +0,0 @@
default_app_config = 'adjutant.api.v1.app.APIV1Config'

View File

@ -1,10 +0,0 @@
from django.apps import AppConfig
from adjutant.api.views import build_version_details
class APIV1Config(AppConfig):
name = "adjutant.api.v1"
label = 'api_v1'
def ready(self):
build_version_details('1.0', 'CURRENT', relative_endpoint='v1/')

View File

@ -20,6 +20,8 @@ from adjutant.config import CONF
class BaseDelegateAPI(APIViewWithLogger):
"""Base Class for Adjutant's deployer configurable APIs."""
url = None
config_group = None
def __init__(self, *args, **kwargs):

View File

@ -1,66 +0,0 @@
# Copyright (C) 2015 Catalyst IT Ltd
#
# 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 adjutant import api
from adjutant.api.v1 import tasks
from adjutant.api.v1 import openstack
from adjutant.api.v1.base import BaseDelegateAPI
from adjutant import exceptions
from adjutant.config.api import delegate_apis_group as api_config
def register_delegate_api_class(url, api_class):
if not issubclass(api_class, BaseDelegateAPI):
raise exceptions.InvalidAPIClass(
"'%s' is not a built off the BaseDelegateAPI class."
% api_class.__name__
)
data = {}
data[api_class.__name__] = {
'class': api_class,
'url': url}
api.DELEGATE_API_CLASSES.update(data)
if api_class.config_group:
# NOTE(adriant): We copy the config_group before naming it
# to avoid cases where a subclass inherits but doesn't extend it
setting_group = api_class.config_group.copy()
setting_group.set_name(
api_class.__name__, reformat_name=False)
api_config.register_child_config(setting_group)
register_delegate_api_class(
r'^actions/CreateProjectAndUser/?$', tasks.CreateProjectAndUser)
register_delegate_api_class(r'^actions/InviteUser/?$', tasks.InviteUser)
register_delegate_api_class(r'^actions/ResetPassword/?$', tasks.ResetPassword)
register_delegate_api_class(r'^actions/EditUser/?$', tasks.EditUser)
register_delegate_api_class(r'^actions/UpdateEmail/?$', tasks.UpdateEmail)
register_delegate_api_class(
r'^openstack/users/?$', openstack.UserList)
register_delegate_api_class(
r'^openstack/users/(?P<user_id>\w+)/?$', openstack.UserDetail)
register_delegate_api_class(
r'^openstack/users/(?P<user_id>\w+)/roles/?$', openstack.UserRoles)
register_delegate_api_class(
r'^openstack/roles/?$', openstack.RoleList)
register_delegate_api_class(
r'^openstack/users/password-reset/?$', openstack.UserResetPassword)
register_delegate_api_class(
r'^openstack/users/email-update/?$', openstack.UserUpdateEmail)
register_delegate_api_class(
r'^openstack/sign-up/?$', openstack.SignUp)
register_delegate_api_class(
r'^openstack/quotas/?$', openstack.UpdateProjectQuotas)

View File

@ -30,6 +30,8 @@ from adjutant.config import CONF
class UserList(tasks.InviteUser):
url = r'^openstack/users/?$'
config_group = groups.DynamicNameConfigGroup(
children=[
fields.ListConfig(
@ -168,6 +170,8 @@ class UserList(tasks.InviteUser):
class UserDetail(BaseDelegateAPI):
url = r'^openstack/users/(?P<user_id>\w+)/?$'
config_group = groups.DynamicNameConfigGroup(
children=[
fields.ListConfig(
@ -244,6 +248,8 @@ class UserDetail(BaseDelegateAPI):
class UserRoles(BaseDelegateAPI):
url = r'^openstack/users/(?P<user_id>\w+)/roles/?$'
config_group = groups.DynamicNameConfigGroup(
children=[
fields.ListConfig(
@ -317,6 +323,8 @@ class UserRoles(BaseDelegateAPI):
class RoleList(BaseDelegateAPI):
url = r'^openstack/roles/?$'
@utils.mod_or_admin
def get(self, request):
"""Returns a list of roles that may be managed for this project"""
@ -343,6 +351,8 @@ class UserResetPassword(tasks.ResetPassword):
---
"""
url = r'^openstack/users/password-reset/?$'
pass
@ -352,6 +362,8 @@ class UserUpdateEmail(tasks.UpdateEmail):
---
"""
url = r'^openstack/users/email-update/?$'
pass
@ -360,6 +372,8 @@ class SignUp(tasks.CreateProjectAndUser):
The openstack endpoint for signups.
"""
url = r'^openstack/sign-up/?$'
pass
@ -369,6 +383,8 @@ class UpdateProjectQuotas(BaseDelegateAPI):
one or more regions
"""
url = r'^openstack/quotas/?$'
task_type = "update_quota"
_number_of_returned_tasks = 5

View File

@ -29,6 +29,8 @@ from adjutant.api.v1.base import BaseDelegateAPI
class CreateProjectAndUser(BaseDelegateAPI):
url = r'^actions/CreateProjectAndUser/?$'
config_group = groups.DynamicNameConfigGroup(
children=[
fields.StrConfig(
@ -83,6 +85,8 @@ class CreateProjectAndUser(BaseDelegateAPI):
class InviteUser(BaseDelegateAPI):
url = r'^actions/InviteUser/?$'
task_type = "invite_user_to_project"
@utils.mod_or_admin
@ -118,6 +122,8 @@ class InviteUser(BaseDelegateAPI):
class ResetPassword(BaseDelegateAPI):
url = r'^actions/ResetPassword/?$'
task_type = "reset_user_password"
@utils.minimal_duration(min_time=3)
@ -160,6 +166,8 @@ class ResetPassword(BaseDelegateAPI):
class EditUser(BaseDelegateAPI):
url = r'^actions/EditUser/?$'
task_type = "edit_user_roles"
@utils.mod_or_admin
@ -179,6 +187,9 @@ class EditUser(BaseDelegateAPI):
class UpdateEmail(BaseDelegateAPI):
url = r'^actions/UpdateEmail/?$'
task_type = "update_user_email"
@utils.authenticated

View File

@ -33,5 +33,5 @@ for active_view in CONF.api.active_delegate_apis:
delegate_api = api.DELEGATE_API_CLASSES[active_view]
urlpatterns.append(
url(delegate_api['url'], delegate_api['class'].as_view())
url(delegate_api.url, delegate_api.as_view())
)

View File

@ -45,13 +45,6 @@ config_group.register_child_config(
unsafe_default=True,
)
)
config_group.register_child_config(
fields.ListConfig(
"additional_apps",
help_text="A list of additional django apps.",
default=[]
)
)
config_group.register_child_config(
fields.DictConfig(
"databases",

View File

@ -15,4 +15,4 @@
from confspirator import groups
config_group = groups.ConfigGroup("plugin")
config_group = groups.ConfigGroup("feature_sets")

83
adjutant/core.py Normal file
View File

@ -0,0 +1,83 @@
# Copyright (C) 2019 Catalyst Cloud Ltd
#
# 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 adjutant.feature_set import BaseFeatureSet
from adjutant.actions.v1 import misc as misc_actions
from adjutant.actions.v1 import projects as project_actions
from adjutant.actions.v1 import resources as resource_actions
from adjutant.actions.v1 import users as user_actions
from adjutant.api.v1 import openstack as openstack_apis
from adjutant.api.v1 import tasks as task_apis
from adjutant.tasks.v1 import projects as project_tasks
from adjutant.tasks.v1 import resources as resource_tasks
from adjutant.tasks.v1 import users as user_tasks
from adjutant.notifications.v1 import email as email_handlers
class AdjutantCore(BaseFeatureSet):
"""Adjutant's Core feature set."""
actions = [
project_actions.NewProjectWithUserAction,
project_actions.NewProjectAction,
project_actions.AddDefaultUsersToProjectAction,
resource_actions.NewDefaultNetworkAction,
resource_actions.NewProjectDefaultNetworkAction,
resource_actions.SetProjectQuotaAction,
resource_actions.UpdateProjectQuotasAction,
user_actions.NewUserAction,
user_actions.ResetUserPasswordAction,
user_actions.EditUserRolesAction,
user_actions.UpdateUserEmailAction,
misc_actions.SendAdditionalEmailAction,
]
tasks = [
project_tasks.CreateProjectAndUser,
user_tasks.EditUserRoles,
user_tasks.InviteUser,
user_tasks.ResetUserPassword,
user_tasks.UpdateUserEmail,
resource_tasks.UpdateProjectQuotas,
]
delegate_apis = [
task_apis.CreateProjectAndUser,
task_apis.InviteUser,
task_apis.ResetPassword,
task_apis.EditUser,
task_apis.UpdateEmail,
openstack_apis.UserList,
openstack_apis.UserDetail,
openstack_apis.UserRoles,
openstack_apis.RoleList,
openstack_apis.UserResetPassword,
openstack_apis.UserUpdateEmail,
openstack_apis.SignUp,
openstack_apis.UpdateProjectQuotas,
]
notification_handlers = [
email_handlers.EmailNotification,
]

176
adjutant/feature_set.py Normal file
View File

@ -0,0 +1,176 @@
# Copyright (C) 2019 Catalyst Cloud Ltd
#
# 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 logging import getLogger
from rest_framework import serializers as drf_serializers
from confspirator import exceptions as conf_exceptions
from confspirator import groups
from adjutant import exceptions
from adjutant import actions
from adjutant.actions.v1.base import BaseAction
from adjutant.config.workflow import action_defaults_group
from adjutant import tasks
from adjutant.tasks.v1 import base as tasks_base
from adjutant.config.workflow import tasks_group
from adjutant import api
from adjutant.api.v1.base import BaseDelegateAPI
from adjutant.config.api import delegate_apis_group as api_config
from adjutant import notifications
from adjutant.notifications.v1.base import BaseNotificationHandler
from adjutant.config.notification import handler_defaults_group
from adjutant.config.feature_sets import config_group as feature_set_config
def register_action_class(action_class):
if not issubclass(action_class, BaseAction):
raise exceptions.InvalidActionClass(
"'%s' is not a built off the BaseAction class."
% action_class.__name__
)
if action_class.serializer and not issubclass(
action_class.serializer, drf_serializers.Serializer):
raise exceptions.InvalidActionSerializer(
"serializer for '%s' is not a valid DRF serializer."
% action_class.__name__
)
data = {}
data[action_class.__name__] = action_class
actions.ACTION_CLASSES.update(data)
if action_class.config_group:
# NOTE(adriant): We copy the config_group before naming it
# to avoid cases where a subclass inherits but doesn't extend it
setting_group = action_class.config_group.copy()
setting_group.set_name(
action_class.__name__, reformat_name=False)
action_defaults_group.register_child_config(setting_group)
def register_task_class(task_class):
if not issubclass(task_class, tasks_base.BaseTask):
raise exceptions.InvalidTaskClass(
"'%s' is not a built off the BaseTask class."
% task_class.__name__
)
data = {}
data[task_class.task_type] = task_class
if task_class.deprecated_task_types:
for old_type in task_class.deprecated_task_types:
data[old_type] = task_class
tasks.TASK_CLASSES.update(data)
config_group = tasks_base.make_task_config(task_class)
config_group.set_name(
task_class.task_type, reformat_name=False)
tasks_group.register_child_config(config_group)
def register_delegate_api_class(api_class):
if not issubclass(api_class, BaseDelegateAPI):
raise exceptions.InvalidAPIClass(
"'%s' is not a built off the BaseDelegateAPI class."
% api_class.__name__
)
data = {}
data[api_class.__name__] = api_class
api.DELEGATE_API_CLASSES.update(data)
if api_class.config_group:
# NOTE(adriant): We copy the config_group before naming it
# to avoid cases where a subclass inherits but doesn't extend it
setting_group = api_class.config_group.copy()
setting_group.set_name(
api_class.__name__, reformat_name=False)
api_config.register_child_config(setting_group)
def register_notification_handler(notification_handler):
if not issubclass(notification_handler, BaseNotificationHandler):
raise exceptions.InvalidActionClass(
"'%s' is not a built off the BaseNotificationHandler class."
% notification_handler.__name__
)
notifications.NOTIFICATION_HANDLERS[
notification_handler.__name__
] = notification_handler
if notification_handler.config_group:
# NOTE(adriant): We copy the config_group before naming it
# to avoid cases where a subclass inherits but doesn't extend it
setting_group = notification_handler.config_group.copy()
setting_group.set_name(notification_handler.__name__, reformat_name=False)
handler_defaults_group.register_child_config(setting_group)
def register_feature_set_config(feature_set_group):
if not isinstance(feature_set_group, groups.ConfigGroup):
raise conf_exceptions.InvalidConfigClass(
"'%s' is not a valid config group class" % feature_set_group)
feature_set_config.register_child_config(feature_set_group)
class BaseFeatureSet(object):
"""A grouping of Adjutant pluggable features.
Contains within it definitions for:
- actions
- tasks
- delegate_apis
- notification_handlers
And additional feature set specific config:
- config
These are just lists of the appropriate class types, and will
imported into Adjutant when the featureset is included.
"""
actions = None
tasks = None
delegate_apis = None
notification_handlers = None
config = None
def __init__(self):
self.logger = getLogger('adjutant')
def load(self):
self.logger.info("Loading feature set: '%s'" % self.__class__.__name__)
if self.actions:
for action in self.actions:
register_action_class(action)
if self.tasks:
for task in self.tasks:
register_task_class(task)
if self.delegate_apis:
for delegate_api in self.delegate_apis:
register_delegate_api_class(delegate_api)
if self.notification_handlers:
for notification_handler in self.notification_handlers:
register_notification_handler(notification_handler)
if self.config:
if isinstance(self.config, groups.DynamicNameConfigGroup):
self.config.set_name(self.__class__.__name__)
register_feature_set_config(self.config)

View File

@ -0,0 +1,60 @@
# Copyright (C) 2015 Catalyst IT Ltd
#
# 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 logging import getLogger
from adjutant.config import CONF
class BaseNotificationHandler(object):
""""""
config_group = None
def __init__(self):
self.logger = getLogger("adjutant")
def config(self, task, notification):
"""build config based on conf and defaults
Will use the Handler defaults, and the overlay them with more
specific overrides from the task defaults, and the per task
type config.
"""
try:
notif_config = CONF.notifications.handler_defaults.get(
self.__class__.__name__)
except KeyError:
# Handler has no config
return {}
task_defaults = task.config.notifications
try:
if notification.error:
task_defaults = task_defaults.error_handler_config.get(
self.__class__.__name__)
else:
task_defaults = task_defaults.standard_handler_config.get(
self.__class__.__name__)
except KeyError:
task_defaults = {}
return notif_config.overlay(task_defaults)
def notify(self, task, notification):
return self._notify(task, notification)
def _notify(self, task, notification):
raise NotImplementedError

View File

@ -12,7 +12,6 @@
# License for the specific language governing permissions and limitations
# under the License.
from logging import getLogger
from smtplib import SMTPException
from django.core.mail import EmailMultiAlternatives
@ -25,56 +24,11 @@ from confspirator import types
from adjutant.config import CONF
from adjutant.common import constants
from adjutant import notifications
from adjutant.api.models import Notification
from adjutant import exceptions
from adjutant.config.notification import handler_defaults_group
from adjutant.notifications.v1 import base
class BaseNotificationHandler(object):
""""""
config_group = None
def __init__(self):
self.logger = getLogger("adjutant")
def config(self, task, notification):
"""build config based on conf and defaults
Will use the Handler defaults, and the overlay them with more
specific overrides from the task defaults, and the per task
type config.
"""
try:
notif_config = CONF.notifications.handler_defaults.get(
self.__class__.__name__)
except KeyError:
# Handler has no config
return {}
task_defaults = task.config.notifications
try:
if notification.error:
task_defaults = task_defaults.error_handler_config.get(
self.__class__.__name__)
else:
task_defaults = task_defaults.standard_handler_config.get(
self.__class__.__name__)
except KeyError:
task_defaults = {}
return notif_config.overlay(task_defaults)
def notify(self, task, notification):
return self._notify(task, notification)
def _notify(self, task, notification):
raise NotImplementedError
class EmailNotification(BaseNotificationHandler):
class EmailNotification(base.BaseNotificationHandler):
"""
Basic email notification handler. Will
send an email with the given templates.
@ -186,23 +140,3 @@ class EmailNotification(BaseNotificationHandler):
task=notification.task, notes=notes, error=True
)
error_notification.save()
def register_notification_handler(notification_handler):
if not issubclass(notification_handler, BaseNotificationHandler):
raise exceptions.InvalidActionClass(
"'%s' is not a built off the BaseNotificationHandler class."
% notification_handler.__name__
)
notifications.NOTIFICATION_HANDLERS[
notification_handler.__name__
] = notification_handler
if notification_handler.config_group:
# NOTE(adriant): We copy the config_group before naming it
# to avoid cases where a subclass inherits but doesn't extend it
setting_group = notification_handler.config_group.copy()
setting_group.set_name(notification_handler.__name__, reformat_name=False)
handler_defaults_group.register_child_config(setting_group)
register_notification_handler(EmailNotification)

View File

@ -20,7 +20,8 @@ from rest_framework import status
from confspirator.tests import utils as conf_utils
from adjutant.api.models import Task, Notification
from adjutant.api.models import Notification
from adjutant.tasks.models import Task
from adjutant.common.tests.fake_clients import (
FakeManager, setup_identity_cache)
from adjutant.common.tests.utils import AdjutantAPITestCase

View File

@ -1,46 +0,0 @@
# Copyright (C) 2019 Catalyst Cloud Ltd
#
# 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 confspirator import exceptions
from confspirator import groups
from adjutant.actions.v1 import models as _action_models
from adjutant.api.v1 import models as _api_models
from adjutant.notifications import models as _notif_models
from adjutant.tasks.v1 import models as _task_models
from adjutant.config.plugin import config_group as _config_group
def register_plugin_config(plugin_group):
if not isinstance(plugin_group, groups.ConfigGroup):
raise exceptions.InvalidConfigClass(
"'%s' is not a valid config group class" % plugin_group)
_config_group.register_child_config(plugin_group)
def register_plugin_action(action_class, serializer_class):
_action_models.register_action_class(action_class, serializer_class)
def register_plugin_task(task_class):
_task_models.register_task_class(task_class)
def register_plugin_delegate_api(url, api_class):
_api_models.register_delegate_api_class(url, api_class)
def register_notification_handler(notification_handler):
_notif_models.register_notification_handler(notification_handler)

View File

@ -47,11 +47,7 @@ INSTALLED_APPS = (
'adjutant.api',
'adjutant.notifications',
'adjutant.tasks',
# NOTE(adriant): Until we have v2 options, hardcode our v1s
'adjutant.actions.v1',
'adjutant.tasks.v1',
'adjutant.api.v1',
'adjutant.startup',
)
MIDDLEWARE_CLASSES = (
@ -123,12 +119,6 @@ if DEBUG:
ALLOWED_HOSTS = adj_conf.django.allowed_hosts
_INSTALLED_APPS = list(INSTALLED_APPS) + adj_conf.django.additional_apps
# NOTE(adriant): Because the order matters, we want this import to be last
# so the startup checks run after everything is imported.
_INSTALLED_APPS.append("adjutant.startup")
INSTALLED_APPS = _INSTALLED_APPS
DATABASES = adj_conf.django.databases
if adj_conf.django.logging:

View File

@ -1 +1 @@
default_app_config = 'adjutant.startup.checks.StartUpConfig'
default_app_config = 'adjutant.startup.config.StartUpConfig'

View File

@ -1,4 +1,16 @@
from django.apps import AppConfig
# Copyright (C) 2019 Catalyst Cloud Ltd
#
# 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 adjutant.config import CONF
from adjutant import actions, api, tasks
@ -34,22 +46,3 @@ def check_configured_actions():
if missing_actions:
raise ActionNotRegistered(
"Configured actions are unregistered: %s" % missing_actions)
class StartUpConfig(AppConfig):
name = "adjutant.startup"
def ready(self):
"""A pre-startup function for the api
Code run here will occur before the API is up and active but after
all models have been loaded.
Useful for any start up checks.
"""
# First check that all expect DelegateAPIs are present
check_expected_delegate_apis()
# Now check if all the actions those views expecte are present.
check_configured_actions()

View File

@ -0,0 +1,40 @@
# Copyright (C) 2019 Catalyst Cloud Ltd
#
# 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 django.apps import AppConfig
from adjutant.startup import checks
from adjutant.startup import loading
class StartUpConfig(AppConfig):
name = "adjutant.startup"
def ready(self):
"""A pre-startup function for the api
Code run here will occur before the API is up and active but after
all models have been loaded.
Loads feature_sets.
Useful for any start up checks.
"""
# load all the feature sets
loading.load_feature_sets()
# First check that all expect DelegateAPIs are present
checks.check_expected_delegate_apis()
# Now check if all the actions those views expecte are present.
checks.check_configured_actions()

View File

@ -0,0 +1,21 @@
# Copyright (C) 2019 Catalyst Cloud Ltd
#
# 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.
import pkg_resources
def load_feature_sets():
for entry_point in pkg_resources.iter_entry_points('adjutant.feature_sets'):
feature_set = entry_point.load()
feature_set().load()

View File

@ -1 +0,0 @@
default_app_config = 'adjutant.tasks.v1.app.TasksV1Config'

View File

@ -1,7 +0,0 @@
from django.apps import AppConfig
class TasksV1Config(AppConfig):
name = "adjutant.tasks.v1"
label = 'tasks_v1'

View File

@ -201,17 +201,16 @@ class BaseTask(object):
else:
action_name = action
action_class, serializer_class = \
adj_actions.ACTION_CLASSES[action_name]
action_class = adj_actions.ACTION_CLASSES[action_name]
if use_existing_actions:
action_class = action
# instantiate serializer class
if not serializer_class:
if not action_class.serializer:
raise exceptions.SerializerMissingException(
"No serializer defined for action %s" % action_name)
serializer = serializer_class(data=action_data)
serializer = action_class.serializer(data=action_data)
action_serializer_list.append({
'name': action_name,

View File

@ -1,47 +0,0 @@
# Copyright (C) 2019 Catalyst Cloud Ltd
#
# 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 adjutant import exceptions
from adjutant import tasks
from adjutant.config.workflow import tasks_group as tasks_group
from adjutant.tasks.v1 import base
from adjutant.tasks.v1 import projects, users, resources
def register_task_class(task_class):
if not issubclass(task_class, base.BaseTask):
raise exceptions.InvalidTaskClass(
"'%s' is not a built off the BaseTask class."
% task_class.__name__
)
data = {}
data[task_class.task_type] = task_class
if task_class.deprecated_task_types:
for old_type in task_class.deprecated_task_types:
data[old_type] = task_class
tasks.TASK_CLASSES.update(data)
setting_group = base.make_task_config(task_class)
setting_group.set_name(
task_class.task_type, reformat_name=False)
tasks_group.register_child_config(setting_group)
register_task_class(projects.CreateProjectAndUser)
register_task_class(users.EditUserRoles)
register_task_class(users.InviteUser)
register_task_class(users.ResetUserPassword)
register_task_class(users.UpdateUserEmail)
register_task_class(resources.UpdateProjectQuotas)

View File

@ -18,7 +18,7 @@ from adjutant.tasks.v1.base import BaseTask
class CreateProjectAndUser(BaseTask):
duplicate_policy = "block"
task_type = "create_project_and_user"
deprecated_task_types = ['create_project']
deprecated_task_types = ['create_project', 'signup']
default_actions = [
"NewProjectWithUserAction",
]

View File

@ -72,7 +72,7 @@ Actions themselves can also effectively do anything within the scope of those
three stages, and there is even the ability to chain multiple actions together,
and pass data along to other actions.
Details for adding task and actions can be found on the :doc:`plugins`
Details for adding task and actions can be found on the :doc:`feature-sets`
page.

View File

@ -1,30 +1,84 @@
##############################
Creating Plugins for Adjutant
##############################
##################################
Creating Feature Sets for Adjutant
##################################
As Adjutant is built on top of Django, we've used parts of Django's installed
apps system to allow us a plugin mechanism that allows additional actions and
views to be brought in via external sources. This allows company specific or
deployer specific changes to easily live outside of the core service and simply
extend the core service where and when need.
Adjutant supports the introduction of new Actions, Tasks, and DelegateAPIs
via additional feature sets. A feature set is a bundle of these elements
with maybe some feature set specific extra config. This allows company specific
or deployer specific changes to easily live outside of the core service and
simply extend the core service where and when need.
An example of such a plugin is here:
https://github.com/catalyst/adjutant-odoo
An example of such a plugin is here (although it may not yet be using the new
'feature set' plugin mechanism):
https://github.com/catalyst-cloud/adjutant-odoo
Once you have all the Actions, Tasks, DelegateAPIs, or Notification Handlers
that you want to include in a feature set, you register them by making a
feature set class::
from adjutant.feature_set import BaseFeatureSet
from myplugin.actions import MyCustonAction
from myplugin.tasks import MyCustonTask
from myplugin.apis import MyCustonAPI
from myplugin.handlers import MyCustonNotificationHandler
class MyFeatureSet(BaseFeatureSet):
actions = [
MyCustonAction,
]
tasks = [
MyCustonTask,
]
delegate_apis = [
MyCustonAPI,
]
notification_handlers = [
MyCustonNotificationHandler,
]
Then adding it to the library entrypoints::
adjutant.feature_sets =
custom_thing = myplugin.features:MyFeatureSet
If you need custom config for your plugin that should be accessible
and the same across all your Actions, Tasks, APIs, or Notification Handlers
then you can register config to the feature set itself::
from confspirator import groups
....
class MyFeatureSet(BaseFeatureSet):
.....
config = groups.DynamicNameConfigGroup(
children=[
fields.StrConfig(
'myconfig',
help_text="Some custom config.",
required=True,
default="Stuff",
),
]
)
Which will be accessible via Adjutant's config at:
``CONF.feature_sets.MyFeatureSet.myconfig``
Building DelegateAPIs
=====================
New DelegateAPIs should inherit from adjutant.api.v1.base.BaseDelegateAPI
can be registered as such::
from adjutant.plugins import register_plugin_delegate_api,
from myplugin import apis
register_plugin_delegate_api(r'^my-plugin/some-action/?$', apis.MyAPIView)
A DelegateAPI must both be registered with a valid URL and specified in
ACTIVE_DELEGATE_APIS in the configuration to be accessible.
New DelegateAPIs should inherit from ``adjutant.api.v1.base.BaseDelegateAPI``
A new DelegateAPI from a plugin can effectively 'override' a default
DelegateAPI by registering with the same URL. However it must have
@ -35,7 +89,9 @@ Examples of DelegateAPIs can be found in adjutant.api.v1.openstack
Minimally they can look like this::
class NewCreateProject(BaseDelegateAPI):
class MyCustomAPI(BaseDelegateAPI):
url = r'^custom/mycoolstuff/?$'
@utils.authenticated
def post(self, request):
@ -48,18 +104,30 @@ admin decorators found in adjutant.api.utils. The request handlers are fairly
standard django view handlers and can execute any needed code. Additional
information for the task should be placed in request.data.
You can also add customer config for the DelegateAPI by setting a
config_group::
class MyCustomAPI(BaseDelegateAPI):
url = r'^custom/mycoolstuff/?$'
config_group = groups.DynamicNameConfigGroup(
children=[
fields.StrConfig(
'myconfig',
help_text="Some custom config.",
required=True,
default="Stuff",
),
]
)
Building Tasks
==============
Tasks must be derived from adjutant.tasks.v1.base.BaseTask and can be
registered as such::
from adjutant.plugins import register_plugin_task
register_plugin_task(MyPluginTask)
Examples of tasks can be found in `adjutant.tasks.v1`
Tasks must be derived from ``adjutant.tasks.v1.base.BaseTask``. Examples
of tasks can be found in ``adjutant.tasks.v1``
Minimally task should define their required fields::
@ -70,21 +138,32 @@ Minimally task should define their required fields::
]
duplicate_policy = "cancel" # default is cancel
Then there are other optional values you can set::
class My(MyPluginTask):
....
# previous task_types
deprecated_task_types = ['create_project']
# config defaults for the task (used to generate default config):
allow_auto_approve = True
additional_actions = None
token_expiry = None
action_config = None
email_config = None
notification_config = None
Building Actions
================
Actions must be derived from adjutant.actions.v1.base.BaseAction and are
registered alongside their serializer::
from adjutant.plugins import register_plugin_action
register_action_class(MyCustomAction, MyCustomActionSerializer)
Actions must be derived from ``adjutant.actions.v1.base.BaseAction``.
Serializers can inherit from either rest_framework.serializers.Serializer, or
the current serializers in adjutant.actions.v1.serializers.
Examples of actions can be found in `adjutant.actions.v1`
Examples of actions can be found in ``adjutant.actions.v1``
Minimally actions should define their required fields and implement 3
functions::
@ -96,6 +175,8 @@ functions::
'value1',
]
serializer = MyCustomActionSerializer
def _prepare(self):
# Do some validation here
pass
@ -109,7 +190,8 @@ functions::
self.add_note("Submit action performed")
Information set in the action task cache is available in email templates under
task.cache.value, and the action data is available in action.ActionName.value.
``task.cache.value``, and the action data is available in
``action.ActionName.value``.
If a token email is needed to be sent the action should also implement::
@ -132,7 +214,7 @@ are django-rest-framework serializers, but there are also two base serializers
available in adjutant.actions.v1.serializers, BaseUserNameSerializer and
BaseUserIdSerializer.
All fields required for an action must be placed through the serializer
All fields required for an action must be plassed through the serializer
otherwise they will be inaccessible to the action.
Example::
@ -154,7 +236,7 @@ Notification Handlers can also be added through a plugin::
class NewNotificationHandler(BaseNotificationHandler):
settings_group = groups.DynamicNameConfigGroup(
config_group = groups.DynamicNameConfigGroup(
children=[
fields.BoolConfig(
"do_this_thing",
@ -169,9 +251,6 @@ Notification Handlers can also be added through a plugin::
if conf.do_this_thing:
# do something with the task and notification
register_notification_handler(NewNotificationHandler)
You then need to setup the handler to be used either by default for a task,
or for a specific task::

View File

@ -11,9 +11,10 @@ handles.
Adjutant does have default implementations of workflows and the APIs for
them. These are in part meant to be workflow that is applicable to any cloud,
but also example implementations, as well as actions that could potentially be
reused in deployer specific workflow in their own plugins. If anything could
be considered a feature, it potentially could be these. The plan is to add many
of these, which any cloud can use out of the box, or augment as needed.
reused in deployer specific workflow in their own feature sets. If anything
could be considered a feature, it potentially could be these. The plan is to
add many of these, which any cloud can use out of the box, or augment as
needed.
To enable these they must be added to `ACTIVE_DELEGATE_APIS` in the conf file.

View File

@ -9,9 +9,9 @@ Adjutant is a service to let cloud providers build workflow around certain
actions, or to build smaller APIs around existing things in OpenStack. Or even
APIs to integrate with OpenStack, but do actions in external systems.
Ultimately Adjutant is a Django project with a few limitations, and the plugin
system probably exposes too much extra functionality which can be added by a
plugin. Some of this we plan to cut down, and throw in some explicitly defined
Ultimately Adjutant is a Django project with a few limitations, and the feature
set system probably exposes too much extra functionality which can be added.
Some of this we plan to cut down, and throw in some explicitly defined
limitations, but even with the planned limitations the framework will always
be very flexible.
@ -58,14 +58,14 @@ wrappers or supplementary logic around existing OpenStack APIs and features.
.. note::
If an action, task, or API doesn't fit in core, it may fit in a plugin,
potentially even one that is maintained by the core team. If a feature isn't
If an action, task, or API doesn't fit in core, it may fit in a external feature
set, potentially even one that is maintained by the core team. If a feature isn't
yet present in OpenStack that we can build in Adjutant quickly, we can do so
as a semi-official plugin with the knowledge that we plan to deprecate that
as a semi-official feature set with the knowledge that we plan to deprecate that
feature when it becomes present in OpenStack proper. In addition this process
allows us to potentially allow providers to expose a variant of the feature
if they are running older versions of OpenStack that don't entirely support
it, but Adjutant could via the plugin mechanism. This gives us a large amount
it, but Adjutant could via the feature set mechanism. This gives us a large amount
of flexibility, while ensuring we aren't reinventing the wheel.
@ -97,7 +97,7 @@ clean, and the changes auditable.
.. warning::
Anyone writing API plugins that break the above convention will not be
Anyone writing feature sets that break the above convention will not be
supported. We may help and encourage you to move to using the underlying
workflows, but the core team won't help you troubleshoot any logic that isn't
in the right place.

View File

@ -9,7 +9,7 @@ Welcome to Adjutant's documentation!
release-notes
devstack-guide
configuration
plugins
feature-sets
quota
guide-lines
features

View File

@ -9,9 +9,6 @@ django:
# The Django allowed hosts
allowed_hosts:
- '*'
# List
# A list of additional django apps.
# additional_apps:
# Dict
# Django databases config.
databases:
@ -272,11 +269,6 @@ workflow:
# List
# Roles which those users should get.
# default_roles:
ResetUserPasswordAction:
# List
# Users with these roles cannot reset their passwords.
blacklisted_roles:
- admin
NewDefaultNetworkAction:
region_defaults:
# String
@ -341,6 +333,11 @@ workflow:
# Integer
# The allowed number of days between auto approved quota changes.
days_between_autoapprove: 30
ResetUserPasswordAction:
# List
# Users with these roles cannot reset their passwords.
blacklisted_roles:
- admin
SendAdditionalEmailAction:
prepare:
# String

View File

@ -0,0 +1,17 @@
---
features:
- |
Feature sets have been introduced, allowing Adjutant's plugins to be
registered via entrypoints, so all that is required to include them
is to install them in the same environment. Then which DelegateAPIs
are enabled from the feature sets is still controlled by
``adjutant.api.active_delegate_apis``.
upgrade:
- |
Plugins that want to work with Adjutant will need to be upgraded to use
the new feature set pattern for registrations of Actions, Tasks, DelegateAPIs,
and NotificationHandlers.
deprecations:
- |
Adjutant's plugin mechanism has entirely changed, making many plugins
imcompatible until updated to match the new plugin mechanism.

View File

@ -38,3 +38,6 @@ packages =
[entry_points]
console_scripts =
adjutant-api = adjutant:management_command
adjutant.feature_sets =
core = adjutant.core:AdjutantCore