From c750fd6d6c0ab5390d66428069a4752dfaae1182 Mon Sep 17 00:00:00 2001 From: Adrian Turjak Date: Tue, 9 Jul 2019 19:09:51 +1200 Subject: [PATCH] Rework Adjutant's config system to use CONFspirator CONFspirator was written to just specifically for Adjutant and it allows us to do oslo.config style config management and definition with nested groups and for yaml. This is a major change that touches vast amounts of the code simply because of how much the config touches. Actions, Tasks, DelegateAPIs, and Notification Handlers now can define config in their own class and this will be added to the config. All the other config is located in `adjutant.config`, with everything now registed nicely on the config tree, and grouped in much saner ways. CONFspirator will also now allow Adjutant to be entirely configured via environment variables. We have removed `modify_dict_settings` because that is now entirely handled by CONFspirator's test utils. `NotificationEngine`s are now `NotificationHandler`s. `test_settings.py` is gone! And we now have better ways to define test settings and defaults. Project line length bumped to 88, and bugbear added to enforce that instead. Story: 2004488 Change-Id: I1d97d72d06b3a3a5df90355d3a4b4fe414381424 --- adjutant/__init__.py | 11 +- adjutant/actions/__init__.py | 17 + adjutant/actions/models.py | 5 +- adjutant/actions/utils.py | 28 +- adjutant/actions/v1/base.py | 58 +- adjutant/actions/v1/misc.py | 98 ++- adjutant/actions/v1/models.py | 15 +- adjutant/actions/v1/projects.py | 52 +- adjutant/actions/v1/resources.py | 158 ++-- adjutant/actions/v1/serializers.py | 36 +- .../actions/v1/tests/test_misc_actions.py | 99 +-- .../actions/v1/tests/test_project_actions.py | 77 +- .../actions/v1/tests/test_resource_actions.py | 114 ++- .../actions/v1/tests/test_user_actions.py | 150 ++-- adjutant/actions/v1/users.py | 34 +- adjutant/api/__init__.py | 17 + adjutant/api/exception_handler.py | 2 +- adjutant/api/v1/base.py | 17 +- adjutant/api/v1/models.py | 23 +- adjutant/api/v1/openstack.py | 93 +- adjutant/api/v1/tasks.py | 40 +- adjutant/api/v1/tests/test_api_admin.py | 213 ++--- adjutant/api/v1/tests/test_api_openstack.py | 191 +++-- adjutant/api/v1/tests/test_api_taskview.py | 463 ++++++---- adjutant/api/v1/urls.py | 7 +- adjutant/api/v1/utils.py | 44 - adjutant/commands/__init__.py | 0 adjutant/commands/management/__init__.py | 0 .../commands/management/commands/__init__.py | 0 .../management/commands/exampleconfig.py | 89 ++ adjutant/common/constants.py | 2 + adjutant/common/openstack_clients.py | 16 +- adjutant/common/quota.py | 39 +- adjutant/common/tests/fake_clients.py | 48 +- adjutant/common/tests/test_utils.py | 263 ------ adjutant/common/tests/utils.py | 198 +---- adjutant/common/user_store.py | 46 +- adjutant/config/__init__.py | 105 +++ adjutant/config/api.py | 52 ++ adjutant/config/django.py | 121 +++ adjutant/config/identity.py | 137 +++ adjutant/config/notification.py | 21 + adjutant/config/plugin.py | 18 + adjutant/config/quota.py | 160 ++++ adjutant/config/workflow.py | 170 ++++ adjutant/notifications/__init__.py | 15 + adjutant/notifications/models.py | 202 +++-- .../notifications/tests/test_notifications.py | 87 +- adjutant/notifications/utils.py | 42 + adjutant/plugins.py | 46 + adjutant/settings.py | 156 ++-- adjutant/startup/checks.py | 17 +- adjutant/tasks/__init__.py | 15 + adjutant/tasks/models.py | 14 +- adjutant/tasks/v1/base.py | 147 +++- adjutant/tasks/v1/manager.py | 9 +- adjutant/tasks/v1/models.py | 14 +- adjutant/tasks/v1/projects.py | 15 + adjutant/tasks/v1/resources.py | 9 + adjutant/tasks/v1/users.py | 53 ++ adjutant/tasks/v1/utils.py | 46 +- adjutant/test_settings.py | 422 ---------- adjutant/utils.py | 48 -- adjutant/wsgi.py | 19 +- api-ref/source/delegate-apis.inc | 6 +- conf/conf.yaml | 414 --------- doc/source/configuration.rst | 278 ++---- doc/source/plugins.rst | 64 +- etc/adjutant.yaml | 793 ++++++++++++++++++ .../notes/story-2004488-5468c184cc3a4691.yaml | 13 + requirements.txt | 1 + test-requirements.txt | 1 + tox.ini | 4 +- 73 files changed, 3808 insertions(+), 2689 deletions(-) create mode 100644 adjutant/commands/__init__.py create mode 100644 adjutant/commands/management/__init__.py create mode 100644 adjutant/commands/management/commands/__init__.py create mode 100644 adjutant/commands/management/commands/exampleconfig.py delete mode 100644 adjutant/common/tests/test_utils.py create mode 100644 adjutant/config/__init__.py create mode 100644 adjutant/config/api.py create mode 100644 adjutant/config/django.py create mode 100644 adjutant/config/identity.py create mode 100644 adjutant/config/notification.py create mode 100644 adjutant/config/plugin.py create mode 100644 adjutant/config/quota.py create mode 100644 adjutant/config/workflow.py create mode 100644 adjutant/notifications/utils.py create mode 100644 adjutant/plugins.py delete mode 100644 adjutant/test_settings.py delete mode 100644 adjutant/utils.py delete mode 100644 conf/conf.yaml create mode 100644 etc/adjutant.yaml create mode 100644 releasenotes/notes/story-2004488-5468c184cc3a4691.yaml diff --git a/adjutant/__init__.py b/adjutant/__init__.py index 4b871f4..eb37484 100644 --- a/adjutant/__init__.py +++ b/adjutant/__init__.py @@ -12,6 +12,10 @@ # License for the specific language governing permissions and limitations # under the License. +import json + +from confspirator.exceptions import InvalidConf + def management_command(): """Entry-point for the 'adjutant' command-line admin utility.""" @@ -22,4 +26,9 @@ def management_command(): from django.core.management import execute_from_command_line - execute_from_command_line(sys.argv) + try: + execute_from_command_line(sys.argv) + except InvalidConf as e: + print("This command requires a valid config, see following errors:") + print(json.dumps(e.errors["adjutant"], indent=2)) + sys.exit(1) diff --git a/adjutant/actions/__init__.py b/adjutant/actions/__init__.py index e69de29..bab8bc2 100644 --- a/adjutant/actions/__init__.py +++ b/adjutant/actions/__init__.py @@ -0,0 +1,17 @@ +# 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. + +# Dict of actions and their serializers. +# - This is populated from the various model modules at startup: +ACTION_CLASSES = {} diff --git a/adjutant/actions/models.py b/adjutant/actions/models.py index 2104a30..d5525a1 100644 --- a/adjutant/actions/models.py +++ b/adjutant/actions/models.py @@ -14,10 +14,11 @@ from jsonfield import JSONField -from django.conf import settings from django.db import models from django.utils import timezone +from adjutant import actions + class Action(models.Model): """ @@ -45,5 +46,5 @@ class Action(models.Model): def get_action(self): """Returns self as the appropriate action wrapper type.""" data = self.action_data - return settings.ACTION_CLASSES[self.action_name][0]( + return actions.ACTION_CLASSES[self.action_name][0]( data=data, action_model=self) diff --git a/adjutant/actions/utils.py b/adjutant/actions/utils.py index 9c32a26..5870618 100644 --- a/adjutant/actions/utils.py +++ b/adjutant/actions/utils.py @@ -1,11 +1,9 @@ import six -from smtplib import SMTPException - -from adjutant.api.v1.utils import create_notification from django.core.mail import EmailMultiAlternatives from django.template import loader -from django.conf import settings + +from adjutant.notifications.utils import create_notification def validate_steps(validation_steps): @@ -46,7 +44,7 @@ def send_email(to_addresses, context, conf, task): conf['template'], using='include_etc_templates') - html_template = conf.get('html_template', None) + html_template = conf.get('html_template') if html_template: html_template = loader.get_template( html_template, @@ -89,25 +87,21 @@ def send_email(to_addresses, context, conf, task): email.send(fail_silently=False) return True - except SMTPException as e: + except Exception as e: notes = { 'errors': - ("Error: '%s' while sending additional email for task: %s" - % (e, task.uuid)) + ("Error: '%s' while sending additional email for task: %s" % + (e, task.uuid)) } - errors_conf = settings.TASK_SETTINGS.get( - task.task_type, settings.DEFAULT_TASK_SETTINGS).get( - 'errors', {}).get("SMTPException", {}) + notif_conf = task.config.notifications - if errors_conf: + if e.__class__.__name__ in notif_conf.safe_errors: notification = create_notification( task, notes, error=True, - engines=errors_conf.get('engines', True)) - - if errors_conf.get('notification') == "acknowledge": - notification.acknowledged = True - notification.save() + handlers=False) + notification.acknowledged = True + notification.save() else: create_notification(task, notes, error=True) diff --git a/adjutant/actions/v1/base.py b/adjutant/actions/v1/base.py index 0f9654a..1d1f7fe 100644 --- a/adjutant/actions/v1/base.py +++ b/adjutant/actions/v1/base.py @@ -14,9 +14,9 @@ from logging import getLogger -from django.conf import settings from django.utils import timezone +from adjutant.config import CONF from adjutant.common.quota import QuotaManager from adjutant.common import user_store from adjutant.common.utils import str_datetime @@ -60,6 +60,8 @@ class BaseAction(object): required = [] + config_group = None + def __init__(self, data, action_model=None, task=None, order=None): """ @@ -87,6 +89,11 @@ class BaseAction(object): action.save() self.action = action + # NOTE(adriant): override this since we don't need the group + # beyond registration. + self.config_group = None + self._config = None + @property def valid(self): return self.action.valid @@ -136,18 +143,28 @@ class BaseAction(object): str(self), note) @property - def settings(self): - """Get my settings. + def config(self): + """Get my config. - Returns a dict of the settings for this action. + Returns a config_group of the config for this action. """ + if self._config is not None: + return self._config + try: - task_conf = settings.TASK_SETTINGS[self.action.task.task_type] - return task_conf['action_settings'].get( - self.__class__.__name__, {}) + action_defaults = CONF.workflow.action_defaults.get( + self.__class__.__name__) except KeyError: - return settings.DEFAULT_ACTION_SETTINGS.get( - self.__class__.__name__, {}) + self._config = {} + return self._config + + try: + task_conf = CONF.workflow.tasks[self.action.task.task_type] + self._config = action_defaults.overlay( + task_conf.actions[self.__class__.__name__]) + except KeyError: + self._config = action_defaults + return self._config def prepare(self): try: @@ -266,13 +283,13 @@ class UserMixin(ResourceMixin): def _validate_role_permissions(self): keystone_user = self.action.task.keystone_user # Role permissions check - if not self.are_roles_managable(user_roles=keystone_user['roles'], - requested_roles=self.roles): + if not self.are_roles_manageable(user_roles=keystone_user['roles'], + requested_roles=self.roles): self.add_note('User does not have permission to edit role(s).') return False return True - def are_roles_managable(self, user_roles=None, requested_roles=None): + def are_roles_manageable(self, user_roles=None, requested_roles=None): if user_roles is None: user_roles = [] if requested_roles is None: @@ -280,13 +297,14 @@ class UserMixin(ResourceMixin): requested_roles = set(requested_roles) # blacklist checks - blacklist_roles = set(['admin']) - if len(blacklist_roles & requested_roles) > 0: + blacklisted_roles = set(['admin']) + if len(blacklisted_roles & requested_roles) > 0: return False - # user managable role - managable_roles = user_store.get_managable_roles(user_roles) - intersection = set(managable_roles) & requested_roles + # user manageable role + id_manager = user_store.IdentityManager() + manageable_roles = id_manager.get_manageable_roles(user_roles) + intersection = set(manageable_roles) & requested_roles # if all requested roles match, we can proceed return intersection == requested_roles @@ -457,8 +475,8 @@ class QuotaMixin(ResourceMixin): def _usage_greater_than_quota(self, regions): quota_manager = QuotaManager( self.project_id, - size_difference_threshold=self.size_difference_threshold) - quota = settings.PROJECT_QUOTA_SIZES.get(self.size, {}) + size_difference_threshold=self.config.size_difference_threshold) + quota = CONF.quota.sizes.get(self.size, {}) for region in regions: current_usage = quota_manager.get_current_usage(region) if self._region_usage_greater_than_quota(current_usage, quota): @@ -498,7 +516,7 @@ class UserNameAction(BaseAction): """ def __init__(self, *args, **kwargs): - if settings.USERNAME_IS_EMAIL: + if CONF.identity.username_is_email: # NOTE(amelia): Make a copy to avoid editing it globally. self.required = list(self.required) try: diff --git a/adjutant/actions/v1/misc.py b/adjutant/actions/v1/misc.py index 519706c..8784895 100644 --- a/adjutant/actions/v1/misc.py +++ b/adjutant/actions/v1/misc.py @@ -14,20 +14,100 @@ import six -from django.conf import settings +from confspirator import groups +from confspirator import fields +from confspirator import types from adjutant.actions.v1.base import BaseAction -from adjutant.common import user_store from adjutant.actions.utils import send_email +from adjutant.common import user_store +from adjutant.common import constants +from adjutant.config import CONF + + +def _build_default_email_group(group_name): + email_group = groups.ConfigGroup(group_name) + email_group.register_child_config( + fields.StrConfig( + "subject", + help_text="Email subject for this stage.", + default="Openstack Email Notification") + ) + email_group.register_child_config( + fields.StrConfig( + "from", + help_text="From email for this stage.", + regex=constants.EMAIL_WITH_TEMPLATE_REGEX, + default="bounce+%(task_uuid)s@example.com") + ) + email_group.register_child_config( + fields.StrConfig( + "reply", + help_text="Reply-to email for this stage.", + regex=constants.EMAIL_WITH_TEMPLATE_REGEX, + default="no-reply@example.com") + ) + email_group.register_child_config( + fields.StrConfig( + "template", + help_text="Email template for this stage. " + "No template will cause the email not to send.", + default=None) + ) + email_group.register_child_config( + fields.StrConfig( + "html_template", + help_text="Email html template for this stage. " + "No template will cause the email not to send.", + default=None) + ) + email_group.register_child_config( + fields.BoolConfig( + "email_current_user", + help_text="Email the user who started the task.", + default=False, + ) + ) + email_group.register_child_config( + fields.BoolConfig( + "email_task_cache", + help_text="Send to an email set in the task cache.", + default=False, + ) + ) + email_group.register_child_config( + fields.ListConfig( + "email_roles", + help_text="Send emails to the given roles on the project.", + default=[], + ) + ) + email_group.register_child_config( + fields.ListConfig( + "email_additional_addresses", + help_text="Send emails to an arbitrary admin emails", + item_type=types.String(regex=constants.EMAIL_WITH_TEMPLATE_REGEX), + default=[], + ) + ) + return email_group class SendAdditionalEmailAction(BaseAction): + config_group = groups.DynamicNameConfigGroup( + children=[ + _build_default_email_group("prepare"), + _build_default_email_group("approve"), + _build_default_email_group("submit"), + ], + ) + def set_email(self, conf): self.emails = set() if conf.get('email_current_user'): self.add_note("Adding the current user's email address") - if settings.USERNAME_IS_EMAIL: + if CONF.identity.username_is_email: self.emails.add(self.action.task.keystone_user['username']) else: try: @@ -49,7 +129,7 @@ class SendAdditionalEmailAction(BaseAction): for user in users: user_roles = [role.name for role in user.roles] if roles.intersection(user_roles): - if settings.USERNAME_IS_EMAIL: + if CONF.identity.username_is_email: self.emails.add(user.name) else: self.emails.add(user.email) @@ -61,7 +141,7 @@ class SendAdditionalEmailAction(BaseAction): for email in task_emails: self.emails.add(email) - for email in conf.get('email_additional_addresses', []): + for email in conf.get('email_additional_addresses'): self.emails.add(email) def _validate(self): @@ -69,13 +149,13 @@ class SendAdditionalEmailAction(BaseAction): self.action.save() def _prepare(self): - self.perform_action('initial') + self.perform_action('prepare') def _approve(self): - self.perform_action('token') + self.perform_action('approve') def _submit(self, data): - self.perform_action('completed') + self.perform_action('submit') def perform_action(self, stage): self._validate() @@ -85,7 +165,7 @@ class SendAdditionalEmailAction(BaseAction): if not action.valid: return - email_conf = self.settings.get(stage, {}) + email_conf = self.config.get(stage) # If either of these are false we won't be sending anything. if not email_conf or not email_conf.get('template'): diff --git a/adjutant/actions/v1/models.py b/adjutant/actions/v1/models.py index c697884..ad11b95 100644 --- a/adjutant/actions/v1/models.py +++ b/adjutant/actions/v1/models.py @@ -12,10 +12,9 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf import settings - 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 ( @@ -29,9 +28,10 @@ from adjutant.actions.v1.resources import ( 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 settings dict with tuples in the format: +# Update ACTION_CLASSES dict with tuples in the format: # (, ) def register_action_class(action_class, serializer_class): if not issubclass(action_class, BaseAction): @@ -47,7 +47,14 @@ def register_action_class(action_class, serializer_class): ) data = {} data[action_class.__name__] = (action_class, serializer_class) - settings.ACTION_CLASSES.update(data) + 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: diff --git a/adjutant/actions/v1/projects.py b/adjutant/actions/v1/projects.py index eac849c..a43e682 100644 --- a/adjutant/actions/v1/projects.py +++ b/adjutant/actions/v1/projects.py @@ -14,9 +14,12 @@ from uuid import uuid4 -from django.conf import settings +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 @@ -38,6 +41,17 @@ class NewProjectAction(BaseAction, ProjectMixin, UserMixin): 'description', ] + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.ListConfig( + "default_roles", + help_text="Roles to be given on project to the creating user.", + default=[], + sample_default=["member", "project_admin"] + ), + ], + ) + def __init__(self, *args, **kwargs): super(NewProjectAction, self).__init__(*args, **kwargs) @@ -90,7 +104,7 @@ class NewProjectAction(BaseAction, ProjectMixin, UserMixin): self.action.task.cache['user_id'] = user_id self.add_note("User already given roles.") else: - default_roles = self.settings.get("default_roles", {}) + default_roles = self.config.default_roles project_id = self.get_cache('project_id') keystone_user = self.action.task.keystone_user @@ -135,6 +149,17 @@ class NewProjectWithUserAction(UserNameAction, ProjectMixin, UserMixin): 'email' ] + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.ListConfig( + "default_roles", + help_text="Roles to be given on project for the user.", + default=[], + sample_default=["member", "project_admin"] + ), + ], + ) + def __init__(self, *args, **kwargs): super(NewProjectWithUserAction, self).__init__(*args, **kwargs) @@ -166,7 +191,7 @@ class NewProjectWithUserAction(UserNameAction, ProjectMixin, UserMixin): self.set_token_fields(["password"]) return True - if (not settings.USERNAME_IS_EMAIL + if (not CONF.identity.username_is_email and getattr(user, 'email', None) != self.email): self.add_note("Existing user '%s' with non-matching email." % self.username) @@ -263,7 +288,7 @@ class NewProjectWithUserAction(UserNameAction, ProjectMixin, UserMixin): def _create_user_for_project(self): id_manager = user_store.IdentityManager() - default_roles = self.settings.get("default_roles", {}) + default_roles = self.config.default_roles project_id = self.get_cache('project_id') @@ -414,10 +439,25 @@ class AddDefaultUsersToProjectAction(BaseAction, ProjectMixin, UserMixin): 'domain_id', ] + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.ListConfig( + "default_users", + help_text="Users which this action should add to the project.", + default=[], + ), + fields.ListConfig( + "default_roles", + help_text="Roles which those users should get.", + default=[], + ), + ], + ) + def __init__(self, *args, **kwargs): super(AddDefaultUsersToProjectAction, self).__init__(*args, **kwargs) - self.users = self.settings.get('default_users', []) - self.roles = self.settings.get('default_roles', []) + self.users = self.config.default_users + self.roles = self.config.default_roles def _validate_users(self): id_manager = user_store.IdentityManager() diff --git a/adjutant/actions/v1/resources.py b/adjutant/actions/v1/resources.py index af1cb75..fd763b3 100644 --- a/adjutant/actions/v1/resources.py +++ b/adjutant/actions/v1/resources.py @@ -12,16 +12,19 @@ # License for the specific language governing permissions and limitations # under the License. +from datetime import timedelta + +from django.utils import timezone + +from confspirator import groups +from confspirator import fields + from adjutant.actions.v1.base import BaseAction, ProjectMixin, QuotaMixin from adjutant.actions.utils import validate_steps from adjutant.common import openstack_clients, user_store from adjutant.api import models from adjutant.common.quota import QuotaManager - -from django.utils import timezone -from django.conf import settings - -from datetime import timedelta +from adjutant.config import CONF class NewDefaultNetworkAction(BaseAction, ProjectMixin): @@ -37,6 +40,49 @@ class NewDefaultNetworkAction(BaseAction, ProjectMixin): 'region', ] + config_group = groups.DynamicNameConfigGroup( + children=[ + groups.ConfigGroup( + "region_defaults", + children=[ + fields.StrConfig( + "network_name", + help_text="Name to be given to the default network.", + default="default_network", + ), + fields.StrConfig( + "subnet_name", + help_text="Name to be given to the default subnet.", + default="default_subnet", + ), + fields.StrConfig( + "router_name", + help_text="Name to be given to the default router.", + default="default_router", + ), + fields.StrConfig( + "public_network", + help_text="ID of the public network.", + ), + fields.StrConfig( + "subnet_cidr", + help_text="CIDR for the default subnet.", + ), + fields.ListConfig( + "dns_nameservers", + help_text="DNS nameservers for the subnet.", + ), + ] + ), + fields.DictConfig( + "regions", + help_text="Specific per region config for default network. " + "See 'region_defaults'.", + default={}, + ), + ] + ) + def __init__(self, *args, **kwargs): super(NewDefaultNetworkAction, self).__init__(*args, **kwargs) @@ -54,33 +100,28 @@ class NewDefaultNetworkAction(BaseAction, ProjectMixin): self.add_note('Region: %s exists.' % self.region) return True - def _validate_defaults(self): - defaults = self.settings.get(self.region, {}) - - if not defaults: - self.add_note('ERROR: No default settings for region %s.' % - self.region) - return False - return True - def _validate(self): self.action.valid = validate_steps([ self._validate_region, self._validate_project_id, - self._validate_defaults, self._validate_keystone_user_project_id, ]) self.action.save() def _create_network(self): neutron = openstack_clients.get_neutronclient(region=self.region) - defaults = self.settings.get(self.region, {}) + try: + region_config = self.config.regions[self.region] + network_config = self.config.region_defaults.overlay( + region_config) + except KeyError: + network_config = self.config.region_defaults if not self.get_cache('network_id'): try: network_body = { "network": { - "name": defaults['network_name'], + "name": network_config.network_name, 'tenant_id': self.project_id, "admin_state_up": True } @@ -89,15 +130,15 @@ class NewDefaultNetworkAction(BaseAction, ProjectMixin): except Exception as e: self.add_note( "Error: '%s' while creating network: %s" % - (e, defaults['network_name'])) + (e, network_config.network_name)) raise self.set_cache('network_id', network['network']['id']) self.add_note("Network %s created for project %s" % - (defaults['network_name'], + (network_config.network_name, self.project_id)) else: self.add_note("Network %s already created for project %s" % - (defaults['network_name'], + (network_config.network_name, self.project_id)) if not self.get_cache('subnet_id'): @@ -107,8 +148,8 @@ class NewDefaultNetworkAction(BaseAction, ProjectMixin): "network_id": self.get_cache('network_id'), "ip_version": 4, 'tenant_id': self.project_id, - 'dns_nameservers': defaults['DNS_NAMESERVERS'], - "cidr": defaults['SUBNET_CIDR'] + 'dns_nameservers': network_config.dns_nameservers, + "cidr": network_config.subnet_cidr } } subnet = neutron.create_subnet(body=subnet_body) @@ -118,18 +159,18 @@ class NewDefaultNetworkAction(BaseAction, ProjectMixin): raise self.set_cache('subnet_id', subnet['subnet']['id']) self.add_note("Subnet created for network %s" % - defaults['network_name']) + network_config.network_name) else: self.add_note("Subnet already created for network %s" % - defaults['network_name']) + network_config.network_name) if not self.get_cache('router_id'): try: router_body = { "router": { - "name": defaults['router_name'], + "name": network_config.router_name, "external_gateway_info": { - "network_id": defaults['public_network'] + "network_id": network_config.public_network }, 'tenant_id': self.project_id, "admin_state_up": True @@ -139,7 +180,7 @@ class NewDefaultNetworkAction(BaseAction, ProjectMixin): except Exception as e: self.add_note( "Error: '%s' while creating router: %s" % - (e, defaults['router_name'])) + (e, network_config.router_name)) raise self.set_cache('router_id', router['router']['id']) self.add_note("Router created for project %s" % @@ -195,7 +236,6 @@ class NewProjectDefaultNetworkAction(NewDefaultNetworkAction): # Note: Don't check project here as it doesn't exist yet. self.action.valid = validate_steps([ self._validate_region, - self._validate_defaults, ]) self.action.save() @@ -203,7 +243,6 @@ class NewProjectDefaultNetworkAction(NewDefaultNetworkAction): self.action.valid = validate_steps([ self._validate_region, self._validate_project_id, - self._validate_defaults, ]) self.action.save() @@ -227,17 +266,26 @@ class UpdateProjectQuotasAction(BaseAction, QuotaMixin): 'regions', ] - default_days_between_autoapprove = 30 - - def __init__(self, *args, **kwargs): - super(UpdateProjectQuotasAction, self).__init__(*args, **kwargs) - self.size_difference_threshold = settings.TASK_SETTINGS.get( - self.action.task.task_type, {}).get( - 'size_difference_threshold') + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.FloatConfig( + "size_difference_threshold", + help_text="Precentage different allowed when matching quota sizes.", + default=0.1, + min=0, + max=1, + ), + fields.IntConfig( + "days_between_autoapprove", + help_text="The allowed number of days between auto approved quota changes.", + default=30, + ), + ] + ) def _get_email(self): - if settings.USERNAME_IS_EMAIL: + if CONF.identity.username_is_email: return self.action.task.keystone_user['username'] else: id_manager = user_store.IdentityManager() @@ -250,7 +298,7 @@ class UpdateProjectQuotasAction(BaseAction, QuotaMixin): return None def _validate_quota_size_exists(self): - size_list = settings.PROJECT_QUOTA_SIZES.keys() + size_list = CONF.quota.sizes.keys() if self.size not in size_list: self.add_note("Quota size: %s does not exist" % self.size) return False @@ -258,24 +306,23 @@ class UpdateProjectQuotasAction(BaseAction, QuotaMixin): def _set_region_quota(self, region_name, quota_size): # Set the quota for an individual region - quota_settings = settings.PROJECT_QUOTA_SIZES.get(quota_size, {}) - if not quota_settings: + quota_config = CONF.quota.sizes.get(quota_size, {}) + if not quota_config: self.add_note( "Project quota not defined for size '%s' in region %s." % ( quota_size, region_name)) return - quota_manager = QuotaManager(self.project_id, - self.size_difference_threshold) + quota_manager = QuotaManager( + self.project_id, self.config.size_difference_threshold) - quota_manager.set_region_quota(region_name, quota_settings) + quota_manager.set_region_quota(region_name, quota_config) self.add_note("Project quota for region %s set to %s" % ( region_name, quota_size)) def _can_auto_approve(self): - wait_days = self.settings.get('days_between_autoapprove', - self.default_days_between_autoapprove) + wait_days = self.config.days_between_autoapprove task_list = models.Task.objects.filter( completed_on__gte=timezone.now() - timedelta(days=wait_days), task_type__exact=self.action.task.task_type, @@ -294,8 +341,8 @@ class UpdateProjectQuotasAction(BaseAction, QuotaMixin): region_sizes = [] - quota_manager = QuotaManager(self.project_id, - self.size_difference_threshold) + quota_manager = QuotaManager( + self.project_id, self.config.size_difference_threshold) for region in self.regions: current_size = quota_manager.get_region_quota_data( @@ -382,6 +429,17 @@ class SetProjectQuotaAction(UpdateProjectQuotasAction): """ Updates quota for a given project to a configured quota level """ required = [] + config_group = UpdateProjectQuotasAction.config_group.extend( + children=[ + fields.DictConfig( + "region_sizes", + help_text="Which quota size to use for which region.", + default={}, + sample_default={"RegionOne": "small"}, + ), + ] + ) + def _get_email(self): return None @@ -406,10 +464,8 @@ class SetProjectQuotaAction(UpdateProjectQuotasAction): return # update quota for each openstack service - regions_dict = self.settings.get('regions', {}) - for region_name, region_settings in regions_dict.items(): - quota_size = region_settings.get('quota_size') - self._set_region_quota(region_name, quota_size) + for region_name, region_size in self.config.region_sizes.items(): + self._set_region_quota(region_name, region_size) self.action.state = "completed" self.action.save() diff --git a/adjutant/actions/v1/serializers.py b/adjutant/actions/v1/serializers.py index c2e7015..4f72048 100644 --- a/adjutant/actions/v1/serializers.py +++ b/adjutant/actions/v1/serializers.py @@ -13,12 +13,14 @@ # under the License. from rest_framework import serializers -from django.conf import settings + +from adjutant.config import CONF from adjutant.common import user_store -role_options = settings.DEFAULT_ACTION_SETTINGS.get("NewUserAction", {}).get( - "allowed_roles", []) +def get_role_choices(): + id_manager = user_store.IdentityManager() + return id_manager.get_manageable_roles() def get_region_choices(): @@ -37,7 +39,7 @@ class BaseUserNameSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): super(BaseUserNameSerializer, self).__init__(*args, **kwargs) - if settings.USERNAME_IS_EMAIL: + if CONF.identity.username_is_email: self.fields.pop('username') @@ -46,12 +48,16 @@ class BaseUserIdSerializer(serializers.Serializer): class NewUserSerializer(BaseUserNameSerializer): - roles = serializers.MultipleChoiceField( - choices=role_options, default=set) - inherited_roles = serializers.MultipleChoiceField( - choices=role_options, default=set) project_id = serializers.CharField(max_length=64) + def __init__(self, *args, **kwargs): + super(NewUserSerializer, self).__init__(*args, **kwargs) + # NOTE(adriant): This overide is mostly in use so that it can be tested + self.fields['roles'] = serializers.MultipleChoiceField( + choices=get_role_choices(), default=set) + self.fields['inherited_roles'] = serializers.MultipleChoiceField( + choices=get_role_choices(), default=set) + def validate(self, data): if not data['roles'] and not data['inherited_roles']: raise serializers.ValidationError( @@ -81,13 +87,17 @@ class ResetUserSerializer(BaseUserNameSerializer): class EditUserRolesSerializer(BaseUserIdSerializer): - roles = serializers.MultipleChoiceField( - choices=role_options, default=set) - inherited_roles = serializers.MultipleChoiceField( - choices=role_options, default=set) remove = serializers.BooleanField(default=False) project_id = serializers.CharField(max_length=64) + def __init__(self, *args, **kwargs): + super(EditUserRolesSerializer, self).__init__(*args, **kwargs) + # NOTE(adriant): This overide is mostly in use so that it can be tested + self.fields['roles'] = serializers.MultipleChoiceField( + choices=get_role_choices(), default=set) + self.fields['inherited_roles'] = serializers.MultipleChoiceField( + choices=get_role_choices(), default=set) + def validate(self, data): if not data['roles'] and not data['inherited_roles']: raise serializers.ValidationError( @@ -139,7 +149,7 @@ class UpdateProjectQuotasSerializer(serializers.Serializer): """ Check that the size exists in the conf. """ - size_list = settings.PROJECT_QUOTA_SIZES.keys() + size_list = CONF.quota.sizes.keys() if value not in size_list: raise serializers.ValidationError("Quota size: %s is not valid" % value) diff --git a/adjutant/actions/v1/tests/test_misc_actions.py b/adjutant/actions/v1/tests/test_misc_actions.py index a0f4256..3f4353f 100644 --- a/adjutant/actions/v1/tests/test_misc_actions.py +++ b/adjutant/actions/v1/tests/test_misc_actions.py @@ -13,15 +13,18 @@ # under the License. import mock +from smtplib import SMTPException from django.core import mail +from confspirator.tests import utils as conf_utils + from adjutant.actions.v1.misc import SendAdditionalEmailAction from adjutant.actions.utils import send_email from adjutant.api.models import Task from adjutant.common.tests.fake_clients import FakeManager -from adjutant.common.tests.utils import modify_dict_settings, AdjutantTestCase -from smtplib import SMTPException +from adjutant.common.tests.utils import AdjutantTestCase +from adjutant.config import CONF default_email_conf = { 'from': "adjutant@example.com", @@ -79,18 +82,17 @@ class MiscActionTests(AdjutantTestCase): @mock.patch('adjutant.actions.utils.EmailMultiAlternatives', FailEmail) - @modify_dict_settings(DEFAULT_ACTION_SETTINGS={ - 'operation': 'update', - 'key_list': ['SendAdditionalEmailAction', 'token'], - 'value': { - 'email_task_cache': True, - 'subject': 'Email Subject', - 'template': 'token.txt' - } - }, DEFAULT_TASK_SETTINGS={ - 'operation': 'delete', - 'key_list': ['notifications'], - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.action_defaults.SendAdditionalEmailAction.approve": [ + {'operation': 'overlay', 'value': { + 'email_task_cache': True, + 'subject': 'Email Subject', + 'template': 'token.txt' + }}, + ], + }) def test_send_additional_email_fail(self): """ Tests that a failure to send an additional email doesn't cause @@ -102,7 +104,6 @@ class MiscActionTests(AdjutantTestCase): task_type='edit_roles', ) - # setup settings action = SendAdditionalEmailAction({}, task=task, order=1) action.prepare() @@ -116,21 +117,23 @@ class MiscActionTests(AdjutantTestCase): self.assertEqual(len(mail.outbox), 0) self.assertTrue( - "Unable to send additional email. Stage: token" in + "Unable to send additional email. Stage: approve" in action.action.task.action_notes['SendAdditionalEmailAction'][1]) action.submit({}) self.assertEqual(action.valid, True) - @modify_dict_settings(DEFAULT_ACTION_SETTINGS={ - 'operation': 'update', - 'key_list': ['SendAdditionalEmailAction', 'token'], - 'value': { - 'email_task_cache': True, - 'subject': 'Email Subject', - 'template': 'token.txt' - } - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.action_defaults.SendAdditionalEmailAction.approve": [ + {'operation': 'overlay', 'value': { + 'email_task_cache': True, + 'subject': 'Email Subject', + 'template': 'token.txt' + }}, + ], + }) def test_send_additional_email_task_cache(self): """ Tests sending an additional email with the address placed in the @@ -141,7 +144,6 @@ class MiscActionTests(AdjutantTestCase): keystone_user={} ) - # setup settings action = SendAdditionalEmailAction({}, task=task, order=1) action.prepare() @@ -161,15 +163,17 @@ class MiscActionTests(AdjutantTestCase): self.assertEqual(action.valid, True) self.assertEqual(len(mail.outbox), 1) - @modify_dict_settings(DEFAULT_ACTION_SETTINGS={ - 'operation': 'update', - 'key_list': ['SendAdditionalEmailAction', 'token'], - 'value': { - 'email_task_cache': True, - 'subject': 'Email Subject', - 'template': 'token.txt' - } - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.action_defaults.SendAdditionalEmailAction.approve": [ + {'operation': 'overlay', 'value': { + 'email_task_cache': True, + 'subject': 'Email Subject', + 'template': 'token.txt' + }}, + ], + }) def test_send_additional_email_task_cache_none_set(self): """ Tests sending an additional email with 'email_task_cache' set but @@ -180,7 +184,6 @@ class MiscActionTests(AdjutantTestCase): keystone_user={} ) - # setup settings action = SendAdditionalEmailAction({}, task=task, order=1) action.prepare() @@ -194,16 +197,19 @@ class MiscActionTests(AdjutantTestCase): action.submit({}) self.assertEqual(action.valid, True) - @modify_dict_settings(DEFAULT_ACTION_SETTINGS={ - 'operation': 'update', - 'key_list': ['SendAdditionalEmailAction', 'token'], - 'value': { - 'email_additional_addresses': ['anadminwhocares@example.com'], - 'subject': 'Email Subject', - 'template': 'token.txt' - } - }) - def test_send_additional_email_email_in_settings(self): + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.action_defaults.SendAdditionalEmailAction.approve": [ + {'operation': 'overlay', 'value': { + 'email_additional_addresses': [ + 'anadminwhocares@example.com'], + 'subject': 'Email Subject', + 'template': 'token.txt' + }}, + ], + }) + def test_send_additional_email_email_in_config(self): """ Tests sending an additional email with the address placed in the task cache. @@ -213,7 +219,6 @@ class MiscActionTests(AdjutantTestCase): keystone_user={} ) - # setup settings action = SendAdditionalEmailAction({}, task=task, order=1) action.prepare() diff --git a/adjutant/actions/v1/tests/test_project_actions.py b/adjutant/actions/v1/tests/test_project_actions.py index 7f704f1..de17bb7 100644 --- a/adjutant/actions/v1/tests/test_project_actions.py +++ b/adjutant/actions/v1/tests/test_project_actions.py @@ -12,11 +12,10 @@ # License for the specific language governing permissions and limitations # under the License. -from django.test import TestCase -from django.test.utils import override_settings - import mock +from confspirator.tests import utils as conf_utils + from adjutant.actions.v1.projects import ( NewProjectWithUserAction, AddDefaultUsersToProjectAction, NewProjectAction) @@ -24,12 +23,31 @@ from adjutant.api.models import Task from adjutant.common.tests import fake_clients from adjutant.common.tests.fake_clients import ( FakeManager, setup_identity_cache) -from adjutant.common.tests.utils import modify_dict_settings +from adjutant.common.tests.utils import AdjutantTestCase +from adjutant.config import CONF @mock.patch('adjutant.common.user_store.IdentityManager', FakeManager) -class ProjectActionTests(TestCase): +@conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.action_defaults.NewProjectWithUserAction.default_roles": [ + {'operation': 'override', 'value': [ + 'member', 'heat_stack_owner', 'project_admin', 'project_mod']}, + ], + "adjutant.workflow.action_defaults.NewProjectAction.default_roles": [ + {'operation': 'override', 'value': [ + 'member', 'heat_stack_owner', 'project_admin', 'project_mod']}, + ], + "adjutant.workflow.action_defaults.AddDefaultUsersToProjectAction.default_users": [ + {'operation': 'override', 'value': ['admin']}, + ], + "adjutant.workflow.action_defaults.AddDefaultUsersToProjectAction.default_roles": [ + {'operation': 'override', 'value': ['admin']}, + ], + }) +class ProjectActionTests(AdjutantTestCase): def test_new_project(self): """ @@ -82,7 +100,7 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(new_user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) def test_new_project_reapprove(self): @@ -145,7 +163,7 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(new_user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) def test_new_project_reapprove_failure(self): @@ -218,7 +236,7 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(new_user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) def test_new_project_existing_user(self): @@ -271,10 +289,16 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_new_project_user_nonmatching_email(self): """ Attempts to create a new project and a new user, where there is @@ -447,7 +471,7 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) def test_new_project_user_disabled_during_signup(self): @@ -522,7 +546,7 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) def test_new_project_existing_project(self): @@ -583,7 +607,13 @@ class ProjectActionTests(TestCase): action.approve() self.assertEqual(action.valid, False) - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_new_project_email_not_username(self): """ Base case, no project, no user. @@ -636,20 +666,14 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(new_user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) - @modify_dict_settings(DEFAULT_ACTION_SETTINGS={ - 'key_list': ['AddDefaultUsersToProjectAction'], - 'operation': 'override', - 'value': {'default_users': ['admin', ], - 'default_roles': ['admin', ]}}) def test_add_default_users(self): """ Base case, adds admin user with admin role to project. - NOTE(adriant): both the lists of users, and the roles to add - come from test_settings. This test assumes the conf setting of: + NOTE(adriant): This test assumes the conf setting of: default_users = ['admin'] default_roles = ['admin'] """ @@ -699,11 +723,6 @@ class ProjectActionTests(TestCase): # Now the missing project should make the action invalid self.assertEqual(action.valid, False) - @modify_dict_settings(DEFAULT_ACTION_SETTINGS={ - 'key_list': ['AddDefaultUsersToProjectAction'], - 'operation': 'override', - 'value': {'default_users': ['admin', ], - 'default_roles': ['admin', ]}}) def test_add_default_users_reapprove(self): """ Ensure nothing happens or changes during rerun of approve. @@ -777,7 +796,7 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) action.submit({}) @@ -824,7 +843,7 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) action.approve() @@ -837,7 +856,7 @@ class ProjectActionTests(TestCase): roles = fake_client._get_roles_as_names(user, new_project) self.assertEqual( sorted(roles), - sorted(['_member_', 'project_admin', + sorted(['member', 'project_admin', 'project_mod', 'heat_stack_owner'])) action.submit({}) diff --git a/adjutant/actions/v1/tests/test_resource_actions.py b/adjutant/actions/v1/tests/test_resource_actions.py index 45ed145..18d2411 100644 --- a/adjutant/actions/v1/tests/test_resource_actions.py +++ b/adjutant/actions/v1/tests/test_resource_actions.py @@ -12,20 +12,20 @@ # License for the specific language governing permissions and limitations # under the License. -from django.test import TestCase -from django.test.utils import override_settings - import mock +from confspirator.tests import utils as conf_utils + from adjutant.actions.v1.resources import ( NewDefaultNetworkAction, NewProjectDefaultNetworkAction, SetProjectQuotaAction, UpdateProjectQuotasAction) from adjutant.api.models import Task -from adjutant.common.tests.utils import modify_dict_settings from adjutant.common.tests.fake_clients import ( FakeManager, setup_identity_cache, get_fake_neutron, get_fake_novaclient, get_fake_cinderclient, setup_neutron_cache, neutron_cache, cinder_cache, nova_cache, setup_mock_caches, get_fake_octaviaclient, octavia_cache) +from adjutant.common.tests.utils import AdjutantTestCase +from adjutant.config import CONF @mock.patch('adjutant.common.user_store.IdentityManager', @@ -42,7 +42,43 @@ from adjutant.common.tests.fake_clients import ( @mock.patch( 'adjutant.common.openstack_clients.get_cinderclient', get_fake_cinderclient) -class ProjectSetupActionTests(TestCase): +@conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.action_defaults.NewDefaultNetworkAction.regions": [ + { + "operation": "override", + "value": { + "RegionOne": { + "dns_nameservers": ["193.168.1.2", "193.168.1.3"], + "subnet_cidr": "192.168.1.0/24", + "network_name": "somenetwork", + "public_network": "3cb50f61-5bce-4c03-96e6-8e262e12bb35", + "router_name": "somerouter", + "subnet_name": "somesubnet", + } + }, + } + ], + "adjutant.quota.sizes": [ + { + "operation": "update", + "value": { + "large_cinder_only": { + "cinder": {"gigabytes": 50001, "volumes": 200, "snapshots": 600} + } + }, + } + ], + "adjutant.workflow.action_defaults.SetProjectQuotaAction.region_sizes": [ + { + "operation": "override", + "value": {'RegionOne': 'small', 'RegionThree': 'large_cinder_only'} + }, + ], + }, +) +class ProjectSetupActionTests(AdjutantTestCase): def test_network_setup(self): """ @@ -207,21 +243,11 @@ class ProjectSetupActionTests(TestCase): self.assertEqual(len( neutron_cache['RegionOne']['test_project_id']['subnets']), 1) - @modify_dict_settings(DEFAULT_ACTION_SETTINGS={ - 'operation': 'override', - 'key_list': ['NewDefaultNetworkAction'], - 'value': {'RegionOne': { - 'DNS_NAMESERVERS': ['193.168.1.2', '193.168.1.3'], - 'SUBNET_CIDR': '192.168.1.0/24', - 'network_name': 'somenetwork', - 'public_network': '3cb50f61-5bce-4c03-96e6-8e262e12bb35', - 'router_name': 'somerouter', - 'subnet_name': 'somesubnet' - }}}) def test_new_project_network_setup(self): """ Base case, setup network after a new project, no issues. """ + setup_identity_cache() setup_neutron_cache('RegionOne', 'test_project_id') task = Task.objects.create( keystone_user={'roles': ['admin']}) @@ -272,6 +298,7 @@ class ProjectSetupActionTests(TestCase): """ No project id given, should do nothing. """ + setup_identity_cache() setup_neutron_cache('RegionOne', 'test_project_id') task = Task.objects.create( keystone_user={'roles': ['admin']}) @@ -304,6 +331,7 @@ class ProjectSetupActionTests(TestCase): """ Told not to setup, should do nothing. """ + setup_identity_cache() setup_neutron_cache('RegionOne', 'test_project_id') task = Task.objects.create( keystone_user={'roles': ['admin']}) @@ -348,6 +376,7 @@ class ProjectSetupActionTests(TestCase): """ Should fail, but on re_approve will continue where it left off. """ + setup_identity_cache() setup_neutron_cache('RegionOne', 'test_project_id') global neutron_cache task = Task.objects.create( @@ -443,7 +472,6 @@ class ProjectSetupActionTests(TestCase): self.assertEqual(action.valid, True) # check the quotas were updated - # This relies on test_settings heavily. cinderquota = cinder_cache['RegionOne']['test_project_id']['quota'] self.assertEqual(cinderquota['gigabytes'], 5000) novaquota = nova_cache['RegionOne']['test_project_id']['quota'] @@ -453,8 +481,7 @@ class ProjectSetupActionTests(TestCase): # RegionThree, cinder only self.assertFalse('RegionThree' in nova_cache) - r2_cinderquota = cinder_cache['RegionThree'][ - 'test_project_id']['quota'] + r2_cinderquota = cinder_cache['RegionThree']['test_project_id']['quota'] self.assertEqual(r2_cinderquota['gigabytes'], 50001) self.assertEqual(r2_cinderquota['snapshots'], 600) self.assertEqual(r2_cinderquota['volumes'], 200) @@ -475,7 +502,7 @@ class ProjectSetupActionTests(TestCase): @mock.patch( 'adjutant.common.openstack_clients.get_octaviaclient', get_fake_octaviaclient) -class QuotaActionTests(TestCase): +class QuotaActionTests(AdjutantTestCase): def test_update_quota(self): """ @@ -495,7 +522,7 @@ class QuotaActionTests(TestCase): user.password = "test_password" setup_identity_cache(projects=[project], users=[user]) - setup_neutron_cache('RegionOne', 'test_project_id') + setup_mock_caches('RegionOne', 'test_project_id') # Test sending to only a single region task = Task.objects.create( @@ -517,7 +544,6 @@ class QuotaActionTests(TestCase): self.assertEqual(action.valid, True) # check the quotas were updated - # This relies on test_settings heavily. cinderquota = cinder_cache['RegionOne']['test_project_id']['quota'] self.assertEqual(cinderquota['gigabytes'], 10000) novaquota = nova_cache['RegionOne']['test_project_id']['quota'] @@ -566,7 +592,6 @@ class QuotaActionTests(TestCase): self.assertEqual(action.valid, True) # check the quotas were updated - # This relies on test_settings heavily. cinderquota = cinder_cache['RegionOne']['test_project_id']['quota'] self.assertEqual(cinderquota['gigabytes'], 50000) novaquota = nova_cache['RegionOne']['test_project_id']['quota'] @@ -581,7 +606,13 @@ class QuotaActionTests(TestCase): neutronquota = neutron_cache['RegionTwo']['test_project_id']['quota'] self.assertEqual(neutronquota['network'], 10) - @override_settings(QUOTA_SIZES_ASC=[]) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.quota.sizes_ascending": [ + {'operation': 'override', 'value': []}, + ], + }) def test_update_quota_not_in_sizes_asc(self): """ Tests that the quota will still update to a size even if it is not @@ -624,7 +655,6 @@ class QuotaActionTests(TestCase): self.assertEqual(action.valid, True) # check the quotas were updated - # This relies on test_settings heavily. cinderquota = cinder_cache['RegionOne']['test_project_id']['quota'] self.assertEqual(cinderquota['gigabytes'], 50000) novaquota = nova_cache['RegionOne']['test_project_id']['quota'] @@ -639,11 +669,17 @@ class QuotaActionTests(TestCase): neutronquota = neutron_cache['RegionTwo']['test_project_id']['quota'] self.assertEqual(neutronquota['network'], 10) - @modify_dict_settings(QUOTA_SERVICES={ - 'operation': 'append', - 'key_list': ['*'], - 'value': 'octavia' - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.quota.services": [ + { + "operation": "override", + "value": {"*": ["cinder", "neutron", "nova", "octavia"]}, + } + ] + }, + ) def test_update_quota_octavia(self): """Tests the quota update of the octavia service""" project = mock.Mock() @@ -681,7 +717,6 @@ class QuotaActionTests(TestCase): self.assertEqual(action.valid, True) # check the quotas were updated - # This relies on test_settings heavily. cinderquota = cinder_cache['RegionOne']['test_project_id']['quota'] self.assertEqual(cinderquota['gigabytes'], 50000) novaquota = nova_cache['RegionOne']['test_project_id']['quota'] @@ -691,11 +726,17 @@ class QuotaActionTests(TestCase): octaviaquota = octavia_cache['RegionOne']['test_project_id']['quota'] self.assertEqual(octaviaquota['load_balancer'], 10) - @modify_dict_settings(QUOTA_SERVICES={ - 'operation': 'append', - 'key_list': ['*'], - 'value': 'octavia' - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.quota.services": [ + { + "operation": "override", + "value": {"*": ["cinder", "neutron", "nova", "octavia"]}, + } + ] + }, + ) def test_update_quota_octavia_over_usage(self): """When octavia usage is higher than new quota it won't be changed""" project = mock.Mock() @@ -738,7 +779,6 @@ class QuotaActionTests(TestCase): self.assertEqual(action.valid, False) # check the quotas were updated - # This relies on test_settings heavily. octaviaquota = octavia_cache['RegionOne']['test_project_id']['quota'] # Still set to default self.assertEqual(octaviaquota['load_balancer'], 1) diff --git a/adjutant/actions/v1/tests/test_user_actions.py b/adjutant/actions/v1/tests/test_user_actions.py index 7ed4ba4..6670827 100644 --- a/adjutant/actions/v1/tests/test_user_actions.py +++ b/adjutant/actions/v1/tests/test_user_actions.py @@ -14,7 +14,7 @@ import mock -from django.test.utils import override_settings +from confspirator.tests import utils as conf_utils from adjutant.actions.v1.users import ( EditUserRolesAction, NewUserAction, ResetUserPasswordAction, @@ -22,11 +22,29 @@ from adjutant.actions.v1.users import ( from adjutant.api.models import Task from adjutant.common.tests import fake_clients from adjutant.common.tests.fake_clients import setup_identity_cache -from adjutant.common.tests.utils import modify_dict_settings, AdjutantTestCase +from adjutant.common.tests.utils import AdjutantTestCase +from adjutant.config import CONF @mock.patch('adjutant.common.user_store.IdentityManager', fake_clients.FakeManager) +@conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.role_mapping": [ + {'operation': 'override', 'value': { + 'admin': [ + 'project_admin', 'project_mod', 'member', 'heat_stack_owner' + ], + 'project_admin': [ + 'project_mod', 'member', 'heat_stack_owner', 'project_admin', + ], + 'project_mod': [ + 'member', 'heat_stack_owner', 'project_mod', + ], + }}, + ], + }) class UserActionTests(AdjutantTestCase): def test_new_user(self): @@ -48,7 +66,7 @@ class UserActionTests(AdjutantTestCase): data = { 'email': 'test@example.com', 'project_id': project.id, - 'roles': ['_member_'], + 'roles': ['member'], 'inherited_roles': [], 'domain_id': 'default', } @@ -75,7 +93,7 @@ class UserActionTests(AdjutantTestCase): self.assertEqual(user.password, '123456') roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(roles, ['_member_']) + self.assertEqual(roles, ['member']) def test_new_user_existing(self): """ @@ -98,7 +116,7 @@ class UserActionTests(AdjutantTestCase): data = { 'email': 'test@example.com', 'project_id': project.id, - 'roles': ['_member_'], + 'roles': ['member'], 'inherited_roles': [], 'domain_id': 'default', } @@ -118,7 +136,7 @@ class UserActionTests(AdjutantTestCase): fake_client = fake_clients.FakeManager() roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(roles, ['_member_']) + self.assertEqual(roles, ['member']) def test_new_user_disabled(self): """ @@ -143,7 +161,7 @@ class UserActionTests(AdjutantTestCase): data = { 'email': 'test@example.com', 'project_id': project.id, - 'roles': ['_member_'], + 'roles': ['member'], 'inherited_roles': [], 'domain_id': 'default', } @@ -170,7 +188,7 @@ class UserActionTests(AdjutantTestCase): self.assertTrue(user.enabled) roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(roles, ['_member_']) + self.assertEqual(roles, ['member']) def test_new_user_existing_role(self): """ @@ -187,7 +205,7 @@ class UserActionTests(AdjutantTestCase): assignment = fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ) @@ -204,7 +222,7 @@ class UserActionTests(AdjutantTestCase): data = { 'email': 'test@example.com', 'project_id': project.id, - 'roles': ['_member_'], + 'roles': ['member'], 'inherited_roles': [], 'domain_id': 'default', } @@ -225,7 +243,7 @@ class UserActionTests(AdjutantTestCase): fake_client = fake_clients.FakeManager() roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(roles, ['_member_']) + self.assertEqual(roles, ['member']) def test_new_user_no_tenant(self): """ @@ -244,7 +262,7 @@ class UserActionTests(AdjutantTestCase): data = { 'email': 'test@example.com', 'project_id': 'test_project_id', - 'roles': ['_member_'], + 'roles': ['member'], 'inherited_roles': [], 'domain_id': 'default', } @@ -285,7 +303,7 @@ class UserActionTests(AdjutantTestCase): data = { 'email': 'test@example.com', 'project_id': 'test_project_id_1', - 'roles': ['_member_'], + 'roles': ['member'], 'inherited_roles': [], 'domain_id': 'default', } @@ -311,7 +329,7 @@ class UserActionTests(AdjutantTestCase): task = Task.objects.create( keystone_user={ - 'roles': ['_member_'], + 'roles': ['member'], 'project_id': project.id, 'project_domain_id': 'default', }) @@ -319,7 +337,7 @@ class UserActionTests(AdjutantTestCase): data = { 'email': 'test@example.com', 'project_id': project.id, - 'roles': ['_member_'], + 'roles': ['member'], 'inherited_roles': [], 'domain_id': 'default', } @@ -343,7 +361,7 @@ class UserActionTests(AdjutantTestCase): assignment = fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ) @@ -360,7 +378,7 @@ class UserActionTests(AdjutantTestCase): data = { 'email': 'test@example.com', 'project_id': project.id, - 'roles': ['_member_'], + 'roles': ['member'], 'inherited_roles': [], 'domain_id': 'not_default', } @@ -504,7 +522,7 @@ class UserActionTests(AdjutantTestCase): 'domain_id': 'default', 'user_id': user.id, 'project_id': project.id, - 'roles': ['_member_', 'project_mod'], + 'roles': ['member', 'project_mod'], 'inherited_roles': [], 'remove': False } @@ -524,7 +542,7 @@ class UserActionTests(AdjutantTestCase): fake_client = fake_clients.FakeManager() roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(sorted(roles), sorted(['_member_', 'project_mod'])) + self.assertEqual(sorted(roles), sorted(['member', 'project_mod'])) def test_edit_user_roles_add_complete(self): """ @@ -538,7 +556,7 @@ class UserActionTests(AdjutantTestCase): assignments = [ fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ), fake_clients.FakeRoleAssignment( @@ -562,7 +580,7 @@ class UserActionTests(AdjutantTestCase): 'domain_id': 'default', 'user_id': user.id, 'project_id': project.id, - 'roles': ['_member_', 'project_mod'], + 'roles': ['member', 'project_mod'], 'inherited_roles': [], 'remove': False } @@ -583,7 +601,7 @@ class UserActionTests(AdjutantTestCase): fake_client = fake_clients.FakeManager() roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(roles, ['_member_', 'project_mod']) + self.assertEqual(roles, ['member', 'project_mod']) def test_edit_user_roles_remove(self): """ @@ -598,7 +616,7 @@ class UserActionTests(AdjutantTestCase): assignments = [ fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ), fake_clients.FakeRoleAssignment( @@ -642,7 +660,7 @@ class UserActionTests(AdjutantTestCase): fake_client = fake_clients.FakeManager() roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(roles, ['_member_']) + self.assertEqual(roles, ['member']) def test_edit_user_roles_remove_complete(self): """ @@ -656,7 +674,7 @@ class UserActionTests(AdjutantTestCase): assignment = fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ) @@ -695,7 +713,7 @@ class UserActionTests(AdjutantTestCase): fake_client = fake_clients.FakeManager() roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(roles, ['_member_']) + self.assertEqual(roles, ['member']) def test_edit_user_roles_can_manage_all(self): """ @@ -710,7 +728,7 @@ class UserActionTests(AdjutantTestCase): assignments = [ fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ), fake_clients.FakeRoleAssignment( @@ -747,11 +765,11 @@ class UserActionTests(AdjutantTestCase): fake_client = fake_clients.FakeManager() roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(roles, ['_member_', 'project_admin']) + self.assertEqual(roles, ['member', 'project_admin']) - def test_edit_user_roles_modified_settings(self): + def test_edit_user_roles_modified_config(self): """ - Tests that the role mappings do come from settings and that they + Tests that the role mappings do come from config and that they are enforced. """ project = fake_clients.FakeProject(name="test_project") @@ -789,11 +807,18 @@ class UserActionTests(AdjutantTestCase): action.prepare() self.assertEqual(action.valid, True) - # Change settings - with self.modify_dict_settings(ROLES_MAPPING={ - 'key_list': ['project_mod'], - 'operation': "remove", - 'value': 'heat_stack_owner'}): + # Change config + with conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.role_mapping": [ + {'operation': 'update', 'value': { + 'project_mod': [ + 'member', 'project_mod', + ], + }}, + ], + }): action.approve() self.assertEqual(action.valid, False) @@ -814,11 +839,20 @@ class UserActionTests(AdjutantTestCase): roles = fake_client._get_roles_as_names(user, project) self.assertEqual(roles, ['project_mod', 'heat_stack_owner']) - @modify_dict_settings(ROLES_MAPPING={'key_list': ['project_mod'], - 'operation': "append", 'value': 'new_role'}) - def test_edit_user_roles_modified_settings_add(self): + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.role_mapping": [ + {'operation': 'update', 'value': { + 'project_mod': [ + 'member', 'heat_stack_owner', 'project_mod', 'new_role', + ], + }}, + ], + }) + def test_edit_user_roles_modified_config_add(self): """ - Tests that the role mappings do come from settings and a new role + Tests that the role mappings do come from config and a new role added there will be allowed. """ project = fake_clients.FakeProject(name="test_project") @@ -873,7 +907,13 @@ class UserActionTests(AdjutantTestCase): self.assertEqual(roles, ['project_mod', 'new_role']) # Simple positive tests for when USERNAME_IS_EMAIL=False - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_create_user_email_not_username(self): """ Test the default case, all valid. @@ -895,7 +935,7 @@ class UserActionTests(AdjutantTestCase): 'username': 'test_user', 'email': 'test@example.com', 'project_id': project.id, - 'roles': ['_member_'], + 'roles': ['member'], 'inherited_roles': [], 'domain_id': 'default', } @@ -922,9 +962,15 @@ class UserActionTests(AdjutantTestCase): self.assertTrue(user.enabled) roles = fake_client._get_roles_as_names(user, project) - self.assertEqual(roles, ['_member_']) + self.assertEqual(roles, ['member']) - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_reset_user_email_not_username(self): """ Base case, existing user. @@ -968,7 +1014,13 @@ class UserActionTests(AdjutantTestCase): self.assertEqual(user.email, 'test@example.com') self.assertEqual(user.password, '123456') - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_reset_user_password_case_insensitive_not_username(self): """ Existing user, ensure action is case insensitive. @@ -1010,7 +1062,6 @@ class UserActionTests(AdjutantTestCase): fake_clients.identity_cache['users'][user.id].password, '123456') - @override_settings(USERNAME_IS_EMAIL=True) def test_update_email(self): """ Base test case for user updating email address. @@ -1054,7 +1105,6 @@ class UserActionTests(AdjutantTestCase): fake_clients.identity_cache['users'][user.id].name, 'new_test@example.com') - @override_settings(USERNAME_IS_EMAIL=True) def test_update_email_invalid_user(self): """ Test case for an invalid user being updated. @@ -1086,7 +1136,13 @@ class UserActionTests(AdjutantTestCase): action.submit(token_data) self.assertEqual(action.valid, False) - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_update_email_username_not_email(self): """ Test case for a user attempting to update with an invalid email. diff --git a/adjutant/actions/v1/users.py b/adjutant/actions/v1/users.py index 255cb98..7723b4f 100644 --- a/adjutant/actions/v1/users.py +++ b/adjutant/actions/v1/users.py @@ -12,9 +12,10 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf import settings -from django.db import models +from confspirator import groups +from confspirator import fields +from adjutant.config import CONF from adjutant.common import user_store from adjutant.actions.v1.base import ( UserNameAction, UserIdAction, UserMixin, ProjectMixin) @@ -58,7 +59,7 @@ class NewUserAction(UserNameAction, ProjectMixin, UserMixin): self.action.task.cache['user_state'] = "default" self.set_token_fields(["password"]) return True - if (not settings.USERNAME_IS_EMAIL + if (not CONF.identity.username_is_email and getattr(user, 'email', None) != self.email): self.add_note( 'Found matching username, but email did not match. ' @@ -174,18 +175,25 @@ class ResetUserPasswordAction(UserNameAction, UserMixin): Simple action to reset a password for a given user. """ - username = models.CharField(max_length=200) - email = models.EmailField() - required = [ 'domain_name', 'username', 'email' ] + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.ListConfig( + "blacklisted_roles", + help_text="Users with these roles cannot reset their passwords.", + default=[], + sample_default=['admin'], + ), + ], + ) + def __init__(self, *args, **kwargs): super(ResetUserPasswordAction, self).__init__(*args, **kwargs) - self.blacklist = self.settings.get("blacklisted_roles", []) def _validate_user_roles(self): id_manager = user_store.IdentityManager() @@ -196,7 +204,7 @@ class ResetUserPasswordAction(UserNameAction, UserMixin): for roles in roles.values(): user_roles.extend(role.name for role in roles) - if set(self.blacklist) & set(user_roles): + if set(self.config.blacklisted_roles) & set(user_roles): self.add_note('Cannot reset users with blacklisted roles.') return False @@ -205,7 +213,7 @@ class ResetUserPasswordAction(UserNameAction, UserMixin): def _validate_user_email(self): # NOTE(adriant): We only need to check the USERNAME_IS_EMAIL=False # case since '_validate_username_exists' will ensure the True case - if not settings.USERNAME_IS_EMAIL: + if not CONF.identity.username_is_email: if (self.user and ( getattr(self.user, 'email', None).lower() != self.email.lower())): @@ -316,13 +324,13 @@ class EditUserRolesAction(UserIdAction, ProjectMixin, UserMixin): user=self.user_id) current_user_roles = [role.name for role in current_user_roles] - current_roles_manageable = self.are_roles_managable( + current_roles_manageable = self.are_roles_manageable( self.action.task.keystone_user['roles'], current_user_roles) all_roles = set() all_roles.update(self.roles) all_roles.update(self.inherited_roles) - new_roles_manageable = self.are_roles_managable( + new_roles_manageable = self.are_roles_manageable( self.action.task.keystone_user['roles'], all_roles) return new_roles_manageable and current_roles_manageable @@ -414,7 +422,7 @@ class UpdateUserEmailAction(UserIdAction, UserMixin): return False def _validate_email_not_in_use(self): - if settings.USERNAME_IS_EMAIL: + if CONF.identity.username_is_email: self.domain_id = self.action.task.keystone_user[ 'project_domain_id'] @@ -445,7 +453,7 @@ class UpdateUserEmailAction(UserIdAction, UserMixin): self.old_username = str(self.user.name) self.update_email(self.new_email, user=self.user) - if settings.USERNAME_IS_EMAIL: + if CONF.identity.username_is_email: self.update_user_name(self.new_email, user=self.user) self.add_note('The email for user %s has been changed to %s.' diff --git a/adjutant/api/__init__.py b/adjutant/api/__init__.py index e69de29..912a762 100644 --- a/adjutant/api/__init__.py +++ b/adjutant/api/__init__.py @@ -0,0 +1,17 @@ +# 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. + +# Dict of DelegateAPIs and their url_paths. +# - This is populated by registering DelegateAPIs. +DELEGATE_API_CLASSES = {} diff --git a/adjutant/api/exception_handler.py b/adjutant/api/exception_handler.py index 16ed2a2..3ab576e 100644 --- a/adjutant/api/exception_handler.py +++ b/adjutant/api/exception_handler.py @@ -20,7 +20,7 @@ from django.utils import timezone from rest_framework.response import Response from adjutant import exceptions -from adjutant.api.v1.utils import create_notification +from adjutant.notifications.utils import create_notification LOG = getLogger('adjutant') diff --git a/adjutant/api/v1/base.py b/adjutant/api/v1/base.py index bb3bf9e..07fc32d 100644 --- a/adjutant/api/v1/base.py +++ b/adjutant/api/v1/base.py @@ -14,9 +14,20 @@ from adjutant.api.v1.views import APIViewWithLogger +from adjutant.config import CONF + -# TODO(adriant): Decide what this class does now other than just being a -# namespace for plugin views. class BaseDelegateAPI(APIViewWithLogger): """Base Class for Adjutant's deployer configurable APIs.""" - pass + + config_group = None + + def __init__(self, *args, **kwargs): + super(BaseDelegateAPI, self).__init__(*args, **kwargs) + # NOTE(adriant): This is only used at registration, + # so lets not expose it: + self.config_group = None + + @property + def config(self): + return CONF.api.delegate_apis.get(self.__class__.__name__) diff --git a/adjutant/api/v1/models.py b/adjutant/api/v1/models.py index ae6721b..332ac40 100644 --- a/adjutant/api/v1/models.py +++ b/adjutant/api/v1/models.py @@ -12,25 +12,32 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf import settings - +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): +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__ + % api_class.__name__ ) data = {} - data[API_class.__name__] = { - 'class': API_class, + data[api_class.__name__] = { + 'class': api_class, 'url': url} - settings.DELEGATE_API_CLASSES.update(data) + 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( diff --git a/adjutant/api/v1/openstack.py b/adjutant/api/v1/openstack.py index ab9a1f0..8df2021 100644 --- a/adjutant/api/v1/openstack.py +++ b/adjutant/api/v1/openstack.py @@ -12,34 +12,47 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf import settings from django.utils import timezone - from rest_framework.response import Response +from confspirator import groups +from confspirator import fields + from adjutant.common import user_store from adjutant.api import models from adjutant.api import utils from adjutant.api.v1 import tasks from adjutant.api.v1.base import BaseDelegateAPI from adjutant.common.quota import QuotaManager +from adjutant.config import CONF class UserList(tasks.InviteUser): + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.ListConfig( + 'blacklisted_roles', + help_text="Users with any of these roles will be hidden from the user list.", + default=[], + sample_default=['admin'] + ), + ] + ) + @utils.mod_or_admin def get(self, request): """Get a list of all users who have been added to a project""" - class_conf = settings.TASK_SETTINGS.get( - 'edit_user_roles', settings.DEFAULT_TASK_SETTINGS) - role_blacklist = class_conf.get('role_blacklist', []) + class_conf = self.config + blacklisted_roles = class_conf.blacklisted_roles + user_list = [] id_manager = user_store.IdentityManager() project_id = request.keystone_user['project_id'] project = id_manager.get_project(project_id) - can_manage_roles = user_store.get_managable_roles( + can_manage_roles = id_manager.get_manageable_roles( request.keystone_user['roles']) active_emails = set() @@ -47,7 +60,7 @@ class UserList(tasks.InviteUser): skip = False roles = [] for role in user.roles: - if role.name in role_blacklist: + if role.name in blacklisted_roles: skip = True continue roles.append(role.name) @@ -55,7 +68,7 @@ class UserList(tasks.InviteUser): continue inherited_roles = [] for role in user.inherited_roles: - if role.name in role_blacklist: + if role.name in blacklisted_roles: skip = True continue inherited_roles.append(role.name) @@ -81,7 +94,7 @@ class UserList(tasks.InviteUser): skip = False roles = [] for role in user.roles: - if role.name in role_blacklist: + if role.name in blacklisted_roles: skip = True continue roles.append(role.name) @@ -145,7 +158,7 @@ class UserList(tasks.InviteUser): 'cohort': 'Invited', 'status': task['status'] } - if not settings.USERNAME_IS_EMAIL: + if not CONF.identity.username_is_email: user['name'] = task['task_data']['username'] user_list.append(user) @@ -154,7 +167,17 @@ class UserList(tasks.InviteUser): class UserDetail(BaseDelegateAPI): - task_type = 'edit_user_roles' + + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.ListConfig( + 'blacklisted_roles', + help_text="User with these roles will return not found.", + default=[], + sample_default=['admin'] + ), + ] + ) @utils.mod_or_admin def get(self, request, user_id): @@ -170,18 +193,18 @@ class UserDetail(BaseDelegateAPI): if not user: return Response(no_user, status=404) - class_conf = settings.TASK_SETTINGS.get( - self.task_type, settings.DEFAULT_TASK_SETTINGS) - role_blacklist = class_conf.get('role_blacklist', []) + class_conf = self.config + blacklisted_roles = class_conf.blacklisted_roles + project_id = request.keystone_user['project_id'] project = id_manager.get_project(project_id) roles = [role.name for role in id_manager.get_roles(user, project)] - roles_blacklisted = set(role_blacklist) & set(roles) + roles_blacklisted = set(blacklisted_roles) & set(roles) inherited_roles = [ role.name for role in id_manager.get_roles(user, project, True)] inherited_roles_blacklisted = ( - set(role_blacklist) & set(inherited_roles)) + set(blacklisted_roles) & set(inherited_roles)) if not roles or roles_blacklisted or inherited_roles_blacklisted: return Response(no_user, status=404) @@ -221,7 +244,18 @@ class UserDetail(BaseDelegateAPI): class UserRoles(BaseDelegateAPI): - task_type = 'edit_user_roles' + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.ListConfig( + 'blacklisted_roles', + help_text="User with these roles will return not found.", + default=[], + sample_default=['admin'] + ), + ] + ) + + task_type = "edit_user_roles" @utils.mod_or_admin def get(self, request, user_id): @@ -236,16 +270,15 @@ class UserRoles(BaseDelegateAPI): project_id = request.keystone_user['project_id'] project = id_manager.get_project(project_id) - class_conf = settings.TASK_SETTINGS.get( - self.task_type, settings.DEFAULT_TASK_SETTINGS) - role_blacklist = class_conf.get('role_blacklist', []) + class_conf = self.config + blacklisted_roles = class_conf.blacklisted_roles roles = [role.name for role in id_manager.get_roles(user, project)] - roles_blacklisted = set(role_blacklist) & set(roles) + roles_blacklisted = set(blacklisted_roles) & set(roles) inherited_roles = [ role.name for role in id_manager.get_roles(user, project, True)] inherited_roles_blacklisted = ( - set(role_blacklist) & set(inherited_roles)) + set(blacklisted_roles) & set(inherited_roles)) if not roles or roles_blacklisted or inherited_roles_blacklisted: return Response(no_user, status=404) @@ -290,18 +323,18 @@ class RoleList(BaseDelegateAPI): # get roles for this user on the project user_roles = request.keystone_user['roles'] - managable_role_names = user_store.get_managable_roles(user_roles) id_manager = user_store.IdentityManager() + manageable_role_names = id_manager.get_manageable_roles(user_roles) # look up role names and form output dict of valid roles - managable_roles = [] - for role_name in managable_role_names: + manageable_roles = [] + for role_name in manageable_role_names: role = id_manager.find_role(role_name) if role: - managable_roles.append(role.to_dict()) + manageable_roles.append(role.to_dict()) - return Response({'roles': managable_roles}) + return Response({'roles': manageable_roles}) class UserResetPassword(tasks.ResetPassword): @@ -387,8 +420,8 @@ class UpdateProjectQuotas(BaseDelegateAPI): as well as the current status of a specified region's quotas. """ - quota_settings = settings.PROJECT_QUOTA_SIZES - size_order = settings.QUOTA_SIZES_ASC + quota_sizes = CONF.quota.sizes + size_order = CONF.quota.sizes_ascending self.project_id = request.keystone_user['project_id'] regions = request.query_params.get('regions', None) @@ -416,7 +449,7 @@ class UpdateProjectQuotas(BaseDelegateAPI): response_tasks = self.get_active_quota_tasks() return Response({'regions': region_quotas, - "quota_sizes": quota_settings, + "quota_sizes": quota_sizes, "quota_size_order": size_order, "active_quota_tasks": response_tasks}) diff --git a/adjutant/api/v1/tasks.py b/adjutant/api/v1/tasks.py index c3758dc..dc423c3 100644 --- a/adjutant/api/v1/tasks.py +++ b/adjutant/api/v1/tasks.py @@ -12,11 +12,13 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf import settings from django.utils import timezone from rest_framework.response import Response +from confspirator import groups +from confspirator import fields + from adjutant import exceptions from adjutant.api import utils from adjutant.api.v1.base import BaseDelegateAPI @@ -27,6 +29,29 @@ from adjutant.api.v1.base import BaseDelegateAPI class CreateProjectAndUser(BaseDelegateAPI): + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.StrConfig( + 'default_region', + help_text="Default region in which any potential resources may be created.", + required=True, + default="RegionOne", + ), + fields.StrConfig( + "default_domain_id", + help_text="Domain in which project and users will be created.", + default="default", + required=True, + ), + fields.StrConfig( + "default_parent_id", + help_text="Parent id under which this project will be created. " + "Default is None, and will create under default domain.", + default=None, + ) + ] + ) + task_type = "create_project_and_user" def post(self, request, format=None): @@ -37,20 +62,19 @@ class CreateProjectAndUser(BaseDelegateAPI): incoming data and create a task to be approved later. """ - self.logger.info("(%s) - Starting new project task." % - timezone.now()) + self.logger.info( + "(%s) - Starting new project task." % timezone.now()) - class_conf = settings.TASK_SETTINGS.get(self.task_type, {}) + class_conf = self.config # we need to set the region the resources will be created in: - request.data['region'] = class_conf.get('default_region') + request.data['region'] = class_conf.default_region # domain - request.data['domain_id'] = class_conf.get( - 'default_domain_id', 'default') + request.data['domain_id'] = class_conf.default_domain_id # parent_id for new project, if null defaults to domain: - request.data['parent_id'] = class_conf.get('default_parent_id') + request.data['parent_id'] = class_conf.default_parent_id self.task_manager.create_from_request(self.task_type, request) diff --git a/adjutant/api/v1/tests/test_api_admin.py b/adjutant/api/v1/tests/test_api_admin.py index cd20e84..7d089f7 100644 --- a/adjutant/api/v1/tests/test_api_admin.py +++ b/adjutant/api/v1/tests/test_api_admin.py @@ -12,25 +12,24 @@ # License for the specific language governing permissions and limitations # under the License. -import json - from datetime import timedelta - +import json +import mock from unittest import skip from django.utils import timezone from django.core import mail -import mock - from rest_framework import status from rest_framework.test import APITestCase +from confspirator.tests import utils as conf_utils + from adjutant.api.models import Task, Token, Notification from adjutant.common.tests import fake_clients from adjutant.common.tests.fake_clients import ( FakeManager, setup_identity_cache) -from adjutant.common.tests.utils import modify_dict_settings +from adjutant.config import CONF @mock.patch('adjutant.common.user_store.IdentityManager', @@ -77,7 +76,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -240,7 +239,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -271,7 +270,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -303,7 +302,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -366,7 +365,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -413,7 +412,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -441,7 +440,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -454,10 +453,13 @@ class AdminAPITests(APITestCase): self.assertEqual(response.json(), {"errors": ["No notification with this id."]}) - @modify_dict_settings(TASK_SETTINGS={ - 'key_list': ['create_project_and_user', 'notifications'], - 'operation': 'delete', - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.task_defaults.notifications.standard_handlers": [ + {'operation': 'override', 'value': []}, + ], + }) def test_notification_acknowledge(self): """ Test that you can acknowledge a notification. @@ -474,7 +476,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -514,7 +516,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -527,10 +529,13 @@ class AdminAPITests(APITestCase): {'errors': ['No notification with this id.']}) - @modify_dict_settings(TASK_SETTINGS={ - 'key_list': ['create_project_and_user', 'notifications'], - 'operation': 'delete', - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.task_defaults.notifications.standard_handlers": [ + {'operation': 'override', 'value': []}, + ], + }) def test_notification_re_acknowledge(self): """ Test that you cant reacknowledge a notification. @@ -545,7 +550,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -564,10 +569,13 @@ class AdminAPITests(APITestCase): self.assertEqual(response.json(), {'notes': ['Notification already acknowledged.']}) - @modify_dict_settings(TASK_SETTINGS={ - 'key_list': ['create_project_and_user', 'notifications'], - 'operation': 'delete', - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.task_defaults.notifications.standard_handlers": [ + {'operation': 'override', 'value': []}, + ], + }) def test_notification_acknowledge_no_data(self): """ Test that you have to include 'acknowledged': True to the request. @@ -582,7 +590,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -613,7 +621,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -650,7 +658,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -667,35 +675,38 @@ class AdminAPITests(APITestCase): {u'notifications': [u'this field is required and needs to be a list.']}) - @modify_dict_settings(DEFAULT_TASK_SETTINGS={ - 'key_list': ['notifications'], - 'operation': 'override', - 'value': { - 'EmailNotification': { - 'standard': { - 'emails': ['example@example.com'], - 'reply': 'no-reply@example.com', - 'template': 'notification.txt' - }, - 'error': { - 'emails': ['example@example.com'], - 'reply': 'no-reply@example.com', - 'template': 'notification.txt' - } - } - } - }, TASK_SETTINGS={ - 'key_list': ['create_project_and_user', 'emails'], - 'operation': 'override', - 'value': { - 'initial': None, - 'token': None, - 'completed': None - } - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.create_project_and_user.notifications": [ + {'operation': 'override', 'value': { + "standard_handlers": ["EmailNotification"], + "error_handlers": ["EmailNotification"], + "standard_handler_config": { + "EmailNotification": { + 'emails': ['example@example.com'], + 'reply': 'no-reply@example.com', + } + }, + "error_handler_config": { + "EmailNotification": { + 'emails': ['example@example.com'], + 'reply': 'no-reply@example.com', + } + }, + }}, + ], + "adjutant.workflow.tasks.create_project_and_user.emails": [ + {'operation': 'override', 'value': { + 'initial': None, + 'token': None, + 'completed': None + }}, + ], + }) def test_notification_email(self): """ - Tests the email notification engine + Tests the email notification handler """ setup_identity_cache() @@ -709,7 +720,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -769,7 +780,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -807,7 +818,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -836,12 +847,12 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -897,7 +908,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -936,7 +947,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -968,7 +979,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -996,7 +1007,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -1030,7 +1041,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -1068,7 +1079,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -1113,7 +1124,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -1145,12 +1156,12 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -1184,12 +1195,12 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -1214,20 +1225,20 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - data = {'email': "test2@example.com", 'roles': ["_member_"], + data = {'email': "test2@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - data = {'email': "test3@example.com", 'roles': ["_member_"], + data = {'email': "test3@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -1235,7 +1246,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -1258,20 +1269,20 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - data = {'email': "test2@example.com", 'roles': ["_member_"], + data = {'email': "test2@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - data = {'email': "test3@example.com", 'roles': ["_member_"], + data = {'email': "test3@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -1279,7 +1290,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -1306,16 +1317,16 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - data = {'email': "test2@example.com", 'roles': ["_member_"], + data = {'email': "test2@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -1328,7 +1339,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -1385,12 +1396,12 @@ class AdminAPITests(APITestCase): headers = { 'project_name': project.name, 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "owner@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': 'test_project_id'} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1398,7 +1409,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': project2.name, 'project_id': project2.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -1425,7 +1436,7 @@ class AdminAPITests(APITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -1485,12 +1496,13 @@ class AdminAPITests(APITestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - @modify_dict_settings(TASK_SETTINGS={ - 'key_list': ['reset_user_password', 'action_settings', - 'ResetUserPasswordAction', 'blacklisted_roles'], - 'operation': 'append', - 'value': ['admin'] - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.action_defaults.ResetUserPasswordAction.blacklisted_roles": [ + {'operation': 'append', 'value': "admin"}, + ], + }) def test_reset_admin(self): """ Ensure that you cannot issue a password reset for an @@ -1539,13 +1551,12 @@ class AdminAPITests(APITestCase): new_task = Task.objects.all()[0] url = "/v1/tasks/" + new_task.uuid data = { - 'project_name': "test_project", 'email': "test@example.com", - 'region': 'test' + 'project_name': "test_project2", 'email': "test@example.com", } headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True diff --git a/adjutant/api/v1/tests/test_api_openstack.py b/adjutant/api/v1/tests/test_api_openstack.py index 7fe0ba7..c879b98 100644 --- a/adjutant/api/v1/tests/test_api_openstack.py +++ b/adjutant/api/v1/tests/test_api_openstack.py @@ -12,15 +12,15 @@ # License for the specific language governing permissions and limitations # under the License. +from datetime import timedelta import mock from rest_framework import status -from django.conf import settings -from django.test.utils import modify_settings -from django.test.utils import override_settings from django.utils import timezone +from confspirator.tests import utils as conf_utils + from adjutant.api.models import Token, Task from adjutant.common.tests import fake_clients from adjutant.common.tests.fake_clients import ( @@ -28,10 +28,8 @@ from adjutant.common.tests.fake_clients import ( get_fake_cinderclient, get_fake_octaviaclient, cinder_cache, nova_cache, neutron_cache, octavia_cache, setup_mock_caches, setup_quota_cache, FakeResource) -from adjutant.common.tests.utils import ( - modify_dict_settings, AdjutantAPITestCase) - -from datetime import timedelta +from adjutant.common.tests.utils import AdjutantAPITestCase +from adjutant.config import CONF @mock.patch('adjutant.common.user_store.IdentityManager', @@ -57,12 +55,12 @@ class OpenstackAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -86,13 +84,13 @@ class OpenstackAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -105,7 +103,7 @@ class OpenstackAPITests(AdjutantAPITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) url = "/v1/openstack/users" - data = {'email': "test2@example.com", 'roles': ["_member_"], + data = {'email': "test2@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -152,12 +150,12 @@ class OpenstackAPITests(AdjutantAPITestCase): ), fake_clients.FakeRoleAssignment( scope={'project': {'id': project3.id}}, - role_name="_member_", + role_name="member", user={'id': user3.id} ), fake_clients.FakeRoleAssignment( scope={'project': {'id': project3.id}}, - role_name="_member_", + role_name="member", user={'id': user3.id}, inherited=True, ), @@ -176,7 +174,7 @@ class OpenstackAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project3.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -202,8 +200,8 @@ class OpenstackAPITests(AdjutantAPITestCase): self.assertEqual(u['roles'], ['project_mod']) normal_user = project_users[0] - self.assertEqual(normal_user['roles'], ['_member_', 'project_mod']) - self.assertEqual(normal_user['inherited_roles'], ['_member_']) + self.assertEqual(normal_user['roles'], ['member', 'project_mod']) + self.assertEqual(normal_user['inherited_roles'], ['member']) def test_user_detail(self): """ @@ -218,13 +216,13 @@ class OpenstackAPITests(AdjutantAPITestCase): assignments = [ fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id}, inherited=True, ), fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ), ] @@ -235,7 +233,7 @@ class OpenstackAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -245,10 +243,10 @@ class OpenstackAPITests(AdjutantAPITestCase): response = self.client.get(url, headers=headers) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()['username'], 'test@example.com') - self.assertEqual(response.json()['roles'], ["_member_"]) - self.assertEqual(response.json()['inherited_roles'], ["_member_"]) + self.assertEqual(response.json()['roles'], ["member"]) + self.assertEqual(response.json()['inherited_roles'], ["member"]) - def test_user_list_managable(self): + def test_user_list_manageable(self): """ Confirm that the manageable value is set correctly. """ @@ -265,7 +263,7 @@ class OpenstackAPITests(AdjutantAPITestCase): assignments = [ fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ), fake_clients.FakeRoleAssignment( @@ -275,7 +273,7 @@ class OpenstackAPITests(AdjutantAPITestCase): ), fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user2.id} ), fake_clients.FakeRoleAssignment( @@ -293,7 +291,7 @@ class OpenstackAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "_member_,project_mod", + 'roles': "member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -319,7 +317,7 @@ class OpenstackAPITests(AdjutantAPITestCase): assignment = fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ) @@ -329,7 +327,7 @@ class OpenstackAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -337,13 +335,19 @@ class OpenstackAPITests(AdjutantAPITestCase): # admins removes role from the test user url = "/v1/openstack/users/%s/roles" % user.id - data = {'roles': ["_member_"]} + data = {'roles': ["member"]} response = self.client.delete(url, data, format='json', headers=admin_headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(response.json(), {'notes': ['task created']}) - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_new_user_username_not_email(self): """ Ensure the new user workflow goes as expected. @@ -357,12 +361,12 @@ class OpenstackAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id, 'username': 'user_name'} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -407,20 +411,20 @@ class QuotaAPITests(AdjutantAPITestCase): extra_services = [] cinderquota = cinder_cache[region_name][project_id]['quota'] - gigabytes = settings.PROJECT_QUOTA_SIZES[size]['cinder']['gigabytes'] + gigabytes = CONF.quota.sizes[size]['cinder']['gigabytes'] self.assertEqual(cinderquota['gigabytes'], gigabytes) novaquota = nova_cache[region_name][project_id]['quota'] - ram = settings.PROJECT_QUOTA_SIZES[size]['nova']['ram'] + ram = CONF.quota.sizes[size]['nova']['ram'] self.assertEqual(novaquota['ram'], ram) neutronquota = neutron_cache[region_name][project_id]['quota'] - network = settings.PROJECT_QUOTA_SIZES[size]['neutron']['network'] + network = CONF.quota.sizes[size]['neutron']['network'] self.assertEqual(neutronquota['network'], network) if 'octavia' in extra_services: octaviaquota = octavia_cache[region_name][project_id]['quota'] - load_balancer = settings.PROJECT_QUOTA_SIZES.get( + load_balancer = CONF.quota.sizes.get( size)['octavia']['load_balancer'] self.assertEqual(octaviaquota['load_balancer'], load_balancer) @@ -438,7 +442,7 @@ class QuotaAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -474,7 +478,7 @@ class QuotaAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -506,7 +510,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "admin_project", 'project_id': project.id, - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "admin", 'user_id': "admin_id", 'authenticated': True @@ -542,7 +546,7 @@ class QuotaAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -587,7 +591,7 @@ class QuotaAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -640,7 +644,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -661,7 +665,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "second_project", 'project_id': project2.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test2@example.com", 'user_id': user.id, 'authenticated': True @@ -693,7 +697,7 @@ class QuotaAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -717,7 +721,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "admin_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "admin", 'user_id': "admin_id", 'authenticated': True @@ -752,7 +756,7 @@ class QuotaAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': user.id, 'authenticated': True @@ -773,23 +777,29 @@ class QuotaAPITests(AdjutantAPITestCase): self.assertEqual( response.data['regions'][0]['current_quota_size'], 'small') - @modify_dict_settings(PROJECT_QUOTA_SIZES=[ - {'key_list': ['zero'], - 'operation': 'override', - 'value': - {'nova': { - 'instances': 0, 'cores': 0, 'ram': 0, 'floating_ips': 0, - 'fixed_ips': 0, 'metadata_items': 0, 'injected_files': 0, - 'injected_file_content_bytes': 0, 'key_pairs': 50, - 'security_groups': 0, 'security_group_rules': 0, }, - 'cinder': { - 'gigabytes': 0, 'snapshots': 0, 'volumes': 0, }, - 'neutron': { - 'floatingip': 0, 'network': 0, 'port': 0, 'router': 0, - 'security_group': 0, 'security_group_rule': 0} - } - }]) - @modify_settings(QUOTA_SIZES_ASC={'prepend': 'zero'}) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.quota.sizes": [ + {'operation': 'update', 'value': { + "zero": { + 'nova': { + 'instances': 0, 'cores': 0, 'ram': 0, 'floating_ips': 0, + 'fixed_ips': 0, 'metadata_items': 0, 'injected_files': 0, + 'injected_file_content_bytes': 0, 'key_pairs': 50, + 'security_groups': 0, 'security_group_rules': 0, }, + 'cinder': { + 'gigabytes': 0, 'snapshots': 0, 'volumes': 0, }, + 'neutron': { + 'floatingip': 0, 'network': 0, 'port': 0, 'router': 0, + 'security_group': 0, 'security_group_rule': 0} + } + }}, + ], + "adjutant.quota.sizes_ascending": [ + {'operation': 'prepend', 'value': "zero"}, + ], + }) def test_calculate_quota_size_zero(self): """ Ensures that a zero quota enabled picks up @@ -806,7 +816,7 @@ class QuotaAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -866,7 +876,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -905,7 +915,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -938,7 +948,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -970,7 +980,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "admin_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "admin", 'user_id': "admin_id", 'authenticated': True @@ -1007,7 +1017,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -1040,7 +1050,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "admin_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "admin", 'user_id': "admin_id", 'authenticated': True @@ -1073,7 +1083,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -1148,7 +1158,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -1163,11 +1173,13 @@ class QuotaAPITests(AdjutantAPITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - @modify_dict_settings(TASK_SETTINGS=[ - {'key_list': ['update_quota', 'allow_auto_approve'], - 'operation': 'override', - 'value': False, - }]) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.update_quota.allow_auto_approve": [ + {'operation': 'override', 'value': False}, + ], + }) def test_no_auto_approved_quota_change(self): """ Test allow_auto_approve config setting on a task.""" @@ -1182,7 +1194,7 @@ class QuotaAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True @@ -1215,7 +1227,7 @@ class QuotaAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': user.id, 'authenticated': True @@ -1230,13 +1242,13 @@ class QuotaAPITests(AdjutantAPITestCase): response.data['regions'][0]['quota_change_options'], ['medium']) cinder_cache['RegionOne'][project.id][ - 'quota'] = settings.PROJECT_QUOTA_SIZES['large']['cinder'] + 'quota'] = CONF.quota.sizes['large']['cinder'] nova_cache['RegionOne'][project.id][ - 'quota'] = settings.PROJECT_QUOTA_SIZES['large']['nova'] + 'quota'] = CONF.quota.sizes['large']['nova'] neutron_cache['RegionOne'][project.id][ - 'quota'] = settings.PROJECT_QUOTA_SIZES['large']['neutron'] + 'quota'] = CONF.quota.sizes['large']['neutron'] response = self.client.get(url, headers=admin_headers) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -1246,11 +1258,14 @@ class QuotaAPITests(AdjutantAPITestCase): response.data['regions'][0]['quota_change_options'], ['small', 'medium']) - @modify_dict_settings(QUOTA_SERVICES={ - 'operation': 'append', - 'key_list': ['*'], - 'value': 'octavia' - }) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.quota.services": [ + {'operation': 'override', 'value': { + '*': ['cinder', 'neutron', 'nova', 'octavia']}}, + ], + }) def test_update_quota_no_history_with_octavia(self): """ Update quota for octavia.""" @@ -1265,7 +1280,7 @@ class QuotaAPITests(AdjutantAPITestCase): admin_headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "user_id", 'authenticated': True diff --git a/adjutant/api/v1/tests/test_api_taskview.py b/adjutant/api/v1/tests/test_api_taskview.py index 1e1bb59..119db81 100644 --- a/adjutant/api/v1/tests/test_api_taskview.py +++ b/adjutant/api/v1/tests/test_api_taskview.py @@ -14,20 +14,20 @@ import mock -from django.test.utils import override_settings -from django.conf import settings from django.core import mail from rest_framework import status +from confspirator.tests import utils as conf_utils + from adjutant.api.models import Token, Notification from adjutant.tasks.models import Task from adjutant.tasks.v1.projects import CreateProjectAndUser from adjutant.common.tests.fake_clients import ( FakeManager, setup_identity_cache) from adjutant.common.tests import fake_clients -from adjutant.common.tests.utils import (AdjutantAPITestCase, - modify_dict_settings) +from adjutant.common.tests.utils import AdjutantAPITestCase +from adjutant.config import CONF @mock.patch('adjutant.common.user_store.IdentityManager', @@ -53,12 +53,12 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'wrong_email_field': "test@example.com", 'roles': ["_member_"], + data = {'wrong_email_field': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -76,6 +76,14 @@ class DelegateAPITests(AdjutantAPITestCase): 'email': ['Enter a valid email address.'], 'roles': ['"not_a_valid_role" is not a valid choice.']}}) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.invite_user_to_project.emails": [ + {'operation': 'update', 'value': { + "initial": None, "token": {"subject": "invite_user_to_project"}}}, + ], + }) def test_new_user(self): """ Ensure the new user workflow goes as expected. @@ -89,12 +97,12 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -123,12 +131,12 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': 'test_project_id'} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -145,12 +153,12 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "_member_", + 'roles': "member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': 'test_project_id'} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -164,7 +172,7 @@ class DelegateAPITests(AdjutantAPITestCase): url = "/v1/actions/InviteUser" headers = {} - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': 'test_project_id'} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) @@ -188,12 +196,12 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -219,7 +227,7 @@ class DelegateAPITests(AdjutantAPITestCase): assignment = fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ) @@ -230,12 +238,12 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -262,7 +270,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -286,6 +294,20 @@ class DelegateAPITests(AdjutantAPITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.create_project_and_user.notifications": [ + {'operation': 'override', 'value': { + "standard_handler_config": { + "EmailNotification": { + 'emails': ['example_notification@example.com'], + 'reply': 'no-reply@example.com', + } + } + }}, + ], + }) def test_new_project_invalid_on_submit(self): """ Ensures that when a project becomes invalid at the submit stage @@ -302,7 +324,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -345,7 +367,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -380,7 +402,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "admin_project", 'project_id': "admin_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "admin", 'user_id': "admin_id", 'authenticated': True @@ -415,7 +437,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "admin_project", 'project_id': "admin_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "admin", 'user_id': "admin_id", 'authenticated': True @@ -544,7 +566,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True @@ -587,12 +609,12 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -600,7 +622,7 @@ class DelegateAPITests(AdjutantAPITestCase): response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) - data = {'email': "test2@example.com", 'roles': ["_member_"], + data = {'email': "test2@example.com", 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -623,7 +645,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': user.id, 'authenticated': True @@ -643,21 +665,29 @@ class DelegateAPITests(AdjutantAPITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(user.name, 'new_test@example.com') - @modify_dict_settings(TASK_SETTINGS=[ - {'key_list': ['update_user_email', 'additional_actions'], - 'operation': 'append', - 'value': ['SendAdditionalEmailAction']}, - {'key_list': ['update_user_email', 'action_settings', - 'SendAdditionalEmailAction', 'initial'], - 'operation': 'update', - 'value': { - 'subject': 'update_user_email_additional', - 'template': 'update_user_email_started.txt', - 'email_roles': [], - 'email_current_user': True, - } - } - ]) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.update_user_email.additional_actions": [ + {'operation': 'append', 'value': "SendAdditionalEmailAction"}, + ], + "adjutant.workflow.tasks.update_user_email.emails": [ + {'operation': 'update', 'value': { + "initial": None, "token": {"subject": "update_user_email_token"}}}, + ], + "adjutant.workflow.tasks.update_user_email.actions": [ + {'operation': 'update', 'value': { + "SendAdditionalEmailAction": { + "prepare": { + 'subject': 'update_user_email_additional', + 'template': 'update_user_email_started.txt', + 'email_roles': [], + 'email_current_user': True, + } + } + }}, + ], + }) def test_update_email_task_send_email_to_current_user(self): """ Tests the email update workflow, and ensures that when setup @@ -673,7 +703,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': user.id, 'authenticated': True @@ -686,6 +716,7 @@ class DelegateAPITests(AdjutantAPITestCase): self.assertEqual(response.data, {'notes': ['task created']}) self.assertEqual(len(mail.outbox), 2) + self.assertEqual(mail.outbox[0].to, ['test@example.com']) self.assertEqual( mail.outbox[0].subject, 'update_user_email_additional') @@ -703,21 +734,32 @@ class DelegateAPITests(AdjutantAPITestCase): self.assertEqual(len(mail.outbox), 3) - @modify_dict_settings(TASK_SETTINGS=[ - {'key_list': ['update_user_email', 'additional_actions'], - 'operation': 'append', - 'value': ['SendAdditionalEmailAction']}, - {'key_list': ['update_user_email', 'action_settings', - 'SendAdditionalEmailAction', 'initial'], - 'operation': 'update', - 'value': { - 'subject': 'update_user_email_additional', - 'template': 'update_user_email_started.txt', - 'email_roles': [], - 'email_current_user': True} - } - ]) - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.update_user_email.additional_actions": [ + {'operation': 'append', 'value': "SendAdditionalEmailAction"}, + ], + "adjutant.workflow.tasks.update_user_email.emails": [ + {'operation': 'update', 'value': { + "initial": None, "token": {"subject": "update_user_email_token"}}}, + ], + "adjutant.workflow.tasks.update_user_email.actions": [ + {'operation': 'update', 'value': { + "SendAdditionalEmailAction": { + "prepare": { + 'subject': 'update_user_email_additional', + 'template': 'update_user_email_started.txt', + 'email_roles': [], + 'email_current_user': True, + } + } + }}, + ], + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_update_email_task_send_email_current_name_not_email(self): """ Tests the email update workflow when USERNAME_IS_EMAIL=False, and @@ -734,7 +776,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "nkdfslnkls", 'user_id': user.id, 'authenticated': True, @@ -775,7 +817,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': user.id, 'authenticated': True @@ -789,7 +831,16 @@ class DelegateAPITests(AdjutantAPITestCase): response.json(), {'errors': {'new_email': [u'Enter a valid email address.']}}) - @override_settings(USERNAME_IS_EMAIL=True) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + "adjutant.workflow.tasks.update_user_email.emails": [ + {'operation': 'update', 'value': {"initial": None}}, + ], + }) def test_update_email_pre_existing_user_with_email(self): user = fake_clients.FakeUser( @@ -805,7 +856,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True, @@ -821,7 +872,16 @@ class DelegateAPITests(AdjutantAPITestCase): self.assertEqual(len(mail.outbox), 0) - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + "adjutant.workflow.tasks.update_user_email.emails": [ + {'operation': 'update', 'value': {"initial": None}}, + ], + }) def test_update_email_user_with_email_username_not_email(self): user = fake_clients.FakeUser( @@ -837,7 +897,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': user.id, 'authenticated': True @@ -878,7 +938,13 @@ class DelegateAPITests(AdjutantAPITestCase): self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_update_email_task_username_not_email(self): user = fake_clients.FakeUser( @@ -890,7 +956,7 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test_user", 'user_id': user.id, 'authenticated': True @@ -912,7 +978,17 @@ class DelegateAPITests(AdjutantAPITestCase): self.assertEqual(user.email, 'new_test@example.com') # Tests for USERNAME_IS_EMAIL=False - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + "adjutant.workflow.tasks.invite_user_to_project.emails": [ + {'operation': 'update', 'value': { + "initial": None, "token": {"subject": "invite_user_to_project"}}}, + ], + }) def test_invite_user_to_project_email_not_username(self): """ Invites a user where the email is different to the username. @@ -925,13 +1001,13 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "user", 'user_id': "test_user_id", 'authenticated': True } data = {'username': 'new_user', 'email': "new@example.com", - 'roles': ["_member_"], 'project_id': project.id} + 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(response.json(), {'notes': ['task created']}) @@ -951,7 +1027,17 @@ class DelegateAPITests(AdjutantAPITestCase): fake_clients.identity_cache['new_users'][0].name, 'new_user') - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + "adjutant.workflow.tasks.reset_user_password.emails": [ + {'operation': 'update', 'value': { + "initial": None, "token": {"subject": "Password Reset for OpenStack"}}}, + ], + }) def test_reset_user_username_not_email(self): """ Ensure the reset user workflow goes as expected. @@ -991,7 +1077,13 @@ class DelegateAPITests(AdjutantAPITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(user.password, 'new_test_password') - @override_settings(USERNAME_IS_EMAIL=False) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.username_is_email": [ + {'operation': 'override', 'value': False}, + ], + }) def test_new_project_username_not_email(self): setup_identity_cache() @@ -1030,22 +1122,27 @@ class DelegateAPITests(AdjutantAPITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - @modify_dict_settings( - TASK_SETTINGS=[ - {'key_list': ['invite_user_to_project', 'additional_actions'], - 'operation': 'append', - 'value': ['SendAdditionalEmailAction']}, - {'key_list': ['invite_user_to_project', 'action_settings', - 'SendAdditionalEmailAction', 'initial'], - 'operation': 'update', - 'value': { - 'subject': 'update_user_email_additional', - 'template': 'update_user_email_started.txt', - 'email_roles': ['project_admin'], - 'email_current_user': False, - } - } - ]) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.invite_user_to_project.additional_actions": [ + {'operation': 'append', 'value': "SendAdditionalEmailAction"}, + ], + "adjutant.workflow.tasks.invite_user_to_project.emails": [ + {'operation': 'update', 'value': {"initial": None}}, + ], + "adjutant.workflow.tasks.invite_user_to_project.actions": [ + {'operation': 'update', 'value': { + "SendAdditionalEmailAction": { + "prepare": { + 'subject': 'invite_user_to_project_additional', + 'template': 'update_user_email_started.txt', + 'email_roles': ['project_admin'], + } + } + }}, + ], + }) def test_additional_emails_roles(self): """ Tests the sending of additional emails to a set of roles in a project @@ -1072,7 +1169,7 @@ class DelegateAPITests(AdjutantAPITestCase): assignments = [ fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ), fake_clients.FakeRoleAssignment( @@ -1082,7 +1179,7 @@ class DelegateAPITests(AdjutantAPITestCase): ), fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user2.id} ), fake_clients.FakeRoleAssignment( @@ -1092,7 +1189,7 @@ class DelegateAPITests(AdjutantAPITestCase): ), fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user3.id} ), fake_clients.FakeRoleAssignment( @@ -1110,14 +1207,14 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } data = {'email': "new_test@example.com", - 'roles': ['_member_'], 'project_id': project.id} + 'roles': ['member'], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(response.json(), {'notes': ['task created']}) @@ -1128,7 +1225,7 @@ class DelegateAPITests(AdjutantAPITestCase): self.assertEqual(set(mail.outbox[0].to), set([user.email, user2.email])) self.assertEqual( - mail.outbox[0].subject, 'update_user_email_additional') + mail.outbox[0].subject, 'invite_user_to_project_additional') # Test that the token email gets sent to the other addresses self.assertEqual(mail.outbox[1].to[0], 'new_test@example.com') @@ -1140,22 +1237,28 @@ class DelegateAPITests(AdjutantAPITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - @modify_dict_settings( - TASK_SETTINGS=[ - {'key_list': ['invite_user_to_project', 'additional_actions'], - 'operation': 'append', - 'value': ['SendAdditionalEmailAction']}, - {'key_list': ['invite_user_to_project', 'action_settings', - 'SendAdditionalEmailAction', 'initial'], - 'operation': 'update', - 'value': { - 'subject': 'update_user_email_additional', - 'template': 'update_user_email_started.txt', - 'email_roles': ['project_admin'], - 'email_current_user': False, - } - } - ]) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.invite_user_to_project.additional_actions": [ + {'operation': 'append', 'value': "SendAdditionalEmailAction"}, + ], + "adjutant.workflow.tasks.invite_user_to_project.emails": [ + {'operation': 'update', 'value': { + "initial": None, "token": {"subject": "invite_user_to_project_token"}}}, + ], + "adjutant.workflow.tasks.invite_user_to_project.actions": [ + {'operation': 'update', 'value': { + "SendAdditionalEmailAction": { + "prepare": { + 'subject': 'invite_user_to_project_additional', + 'template': 'update_user_email_started.txt', + 'email_roles': ['project_admin'], + } + } + }}, + ], + }) def test_additional_emails_role_no_email(self): """ Tests that setting email roles to something that has no people to @@ -1169,7 +1272,7 @@ class DelegateAPITests(AdjutantAPITestCase): assignment = fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ) @@ -1180,14 +1283,14 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } data = {'email': "new_test@example.com", - 'roles': ['_member_']} + 'roles': ['member']} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(response.data, {'notes': ['task created']}) @@ -1204,22 +1307,27 @@ class DelegateAPITests(AdjutantAPITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - @modify_dict_settings( - TASK_SETTINGS=[ - {'key_list': ['invite_user_to_project', 'additional_actions'], - 'operation': 'override', - 'value': ['SendAdditionalEmailAction']}, - {'key_list': ['invite_user_to_project', 'action_settings', - 'SendAdditionalEmailAction', 'initial'], - 'operation': 'update', - 'value':{ - 'subject': 'invite_user_to_project_additional', - 'template': 'update_user_email_started.txt', - 'email_additional_addresses': ['admin@example.com'], - 'email_current_user': False, - } - } - ]) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.invite_user_to_project.additional_actions": [ + {'operation': 'append', 'value': "SendAdditionalEmailAction"}, + ], + "adjutant.workflow.tasks.invite_user_to_project.emails": [ + {'operation': 'update', 'value': {"initial": None}}, + ], + "adjutant.workflow.tasks.invite_user_to_project.actions": [ + {'operation': 'update', 'value': { + "SendAdditionalEmailAction": { + "prepare": { + 'subject': 'invite_user_to_project_additional', + 'template': 'update_user_email_started.txt', + 'email_additional_addresses': ['admin@example.com'], + } + } + }}, + ], + }) def test_email_additional_addresses(self): """ Tests the sending of additional emails an admin email set in @@ -1233,7 +1341,7 @@ class DelegateAPITests(AdjutantAPITestCase): assignments = [ fake_clients.FakeRoleAssignment( scope={'project': {'id': project.id}}, - role_name="_member_", + role_name="member", user={'id': user.id} ), fake_clients.FakeRoleAssignment( @@ -1250,13 +1358,13 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "new_test@example.com", 'roles': ['_member_']} + data = {'email': "new_test@example.com", 'roles': ['member']} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) @@ -1278,22 +1386,28 @@ class DelegateAPITests(AdjutantAPITestCase): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - @modify_dict_settings( - TASK_SETTINGS=[ - {'key_list': ['invite_user_to_project', 'additional_actions'], - 'operation': 'override', - 'value': ['SendAdditionalEmailAction']}, - {'key_list': ['invite_user_to_project', 'action_settings', - 'SendAdditionalEmailAction', 'initial'], - 'operation': 'update', - 'value':{ - 'subject': 'invite_user_to_project_additional', - 'template': 'update_user_email_started.txt', - 'email_additional_addresses': ['admin@example.com'], - 'email_current_user': False, - } - } - ]) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.invite_user_to_project.additional_actions": [ + {'operation': 'append', 'value': "SendAdditionalEmailAction"}, + ], + "adjutant.workflow.tasks.invite_user_to_project.emails": [ + {'operation': 'update', 'value': { + "initial": None, "token": {"subject": "invite_user_to_project_token"}}}, + ], + "adjutant.workflow.tasks.invite_user_to_project.actions": [ + {'operation': 'update', 'value': { + "SendAdditionalEmailAction": { + "prepare": { + 'subject': 'invite_user_to_project_additional', + 'template': 'update_user_email_started.txt', + 'email_additional_addresses': ['admin@example.com'], + } + } + }}, + ], + }) def test_email_additional_action_invalid(self): """ The additional email actions should not send an email if the @@ -1306,12 +1420,12 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - data = {'email': "test@example.com", 'roles': ["_member_"], + data = {'email': "test@example.com", 'roles': ["member"], 'project_id': 'test_project_id'} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -1339,12 +1453,9 @@ class DelegateAPITests(AdjutantAPITestCase): new_task = Task.objects.all()[0] - class_conf = settings.TASK_SETTINGS.get( - CreateProjectAndUser.task_type, settings.DEFAULT_TASK_SETTINGS) - expected_action_names = ( - class_conf.get('default_actions', []) - or CreateProjectAndUser.default_actions[:]) - expected_action_names += class_conf.get('additional_actions', []) + class_conf = new_task.config + expected_action_names = CreateProjectAndUser.default_actions[:] + expected_action_names += class_conf.additional_actions actions = new_task.actions observed_action_names = [a.action_name for a in actions] @@ -1381,7 +1492,13 @@ class DelegateAPITests(AdjutantAPITestCase): "task. See task itself for details."]}) self.assertEqual(new_notification.task, new_task) - @override_settings(KEYSTONE={'can_edit_users': False}) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.can_edit_users": [ + {'operation': 'override', 'value': False}, + ], + }) def test_user_invite_cant_edit_users(self): """ When can_edit_users is false, and a new user is invited, @@ -1396,18 +1513,24 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "user", 'user_id': "test_user_id", 'authenticated': True } data = {'username': 'new_user', 'email': "new@example.com", - 'roles': ["_member_"], 'project_id': project.id} + 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), {'errors': ['actions invalid']}) - @override_settings(KEYSTONE={'can_edit_users': False}) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.can_edit_users": [ + {'operation': 'override', 'value': False}, + ], + }) def test_user_invite_cant_edit_users_existing_user(self): """ When can_edit_users is false, and a new user is invited, @@ -1423,18 +1546,24 @@ class DelegateAPITests(AdjutantAPITestCase): headers = { 'project_name': "test_project", 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", + 'roles': "project_admin,member,project_mod", 'username': "user", 'user_id': "test_user_id", 'authenticated': True } data = {'username': 'new_user', 'email': "test@example.com", - 'roles': ["_member_"], 'project_id': project.id} + 'roles': ["member"], 'project_id': project.id} response = self.client.post(url, data, format='json', headers=headers) self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) self.assertEqual(response.json(), {'notes': ['task created']}) - @override_settings(KEYSTONE={'can_edit_users': False}) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.can_edit_users": [ + {'operation': 'override', 'value': False}, + ], + }) def test_project_create_cant_edit_users(self): """ When can_edit_users is false, and a new signup comes in, @@ -1456,7 +1585,13 @@ class DelegateAPITests(AdjutantAPITestCase): actions = [act.get_action() for act in action_models] self.assertFalse(all([act.valid for act in actions])) - @override_settings(KEYSTONE={'can_edit_users': False}) + @conf_utils.modify_conf( + CONF, + operations={ + "adjutant.identity.can_edit_users": [ + {'operation': 'override', 'value': False}, + ], + }) def test_project_create_cant_edit_users_existing_user(self): """ When can_edit_users is false, and a new signup comes in, diff --git a/adjutant/api/v1/urls.py b/adjutant/api/v1/urls.py index 3bcfc70..57a33bb 100644 --- a/adjutant/api/v1/urls.py +++ b/adjutant/api/v1/urls.py @@ -14,8 +14,9 @@ from django.conf.urls import url from adjutant.api.v1 import views -from django.conf import settings +from adjutant import api +from adjutant.config import CONF urlpatterns = [ url(r'^status/?$', views.StatusView.as_view()), @@ -28,8 +29,8 @@ urlpatterns = [ url(r'^notifications/?$', views.NotificationList.as_view()), ] -for active_view in settings.ACTIVE_DELEGATE_APIS: - delegate_api = settings.DELEGATE_API_CLASSES[active_view] +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()) diff --git a/adjutant/api/v1/utils.py b/adjutant/api/v1/utils.py index 821c032..1793db2 100644 --- a/adjutant/api/v1/utils.py +++ b/adjutant/api/v1/utils.py @@ -16,44 +16,10 @@ import json from decorator import decorator -from django.conf import settings from django.core.exceptions import FieldError from rest_framework.response import Response -from adjutant.api.models import Notification - - -# TODO(adriant): move this to 'adjutant.notifications.utils' -def create_notification(task, notes, error=False, engines=True): - notification = Notification.objects.create( - task=task, - notes=notes, - error=error - ) - notification.save() - - if not engines: - return notification - - class_conf = settings.TASK_SETTINGS.get( - task.task_type, settings.DEFAULT_TASK_SETTINGS) - - notification_conf = class_conf.get('notifications', {}) - - if notification_conf: - for note_engine, conf in notification_conf.items(): - if error: - conf = conf.get('error', {}) - else: - conf = conf.get('standard', {}) - if not conf: - continue - engine = settings.NOTIFICATION_ENGINES[note_engine](conf) - engine.notify(task, notification) - - return notification - # "{'filters': {'fieldname': { 'operation': 'value'}} @decorator @@ -92,13 +58,3 @@ def parse_filters(func, *args, **kwargs): return func(*args, **kwargs) except FieldError as e: return Response({'errors': [str(e)]}, status=400) - - -def add_task_id_for_roles(request, processed, response_dict, req_roles): - if request.keystone_user.get('authenticated', False): - - req_roles = set(req_roles) - roles = set(request.keystone_user.get('roles', [])) - - if roles & req_roles: - response_dict['task'] = processed['task'].uuid diff --git a/adjutant/commands/__init__.py b/adjutant/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adjutant/commands/management/__init__.py b/adjutant/commands/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adjutant/commands/management/commands/__init__.py b/adjutant/commands/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adjutant/commands/management/commands/exampleconfig.py b/adjutant/commands/management/commands/exampleconfig.py new file mode 100644 index 0000000..c0fc767 --- /dev/null +++ b/adjutant/commands/management/commands/exampleconfig.py @@ -0,0 +1,89 @@ +import yaml + +from django.core.management.base import BaseCommand + +from confspirator import groups + +from adjutant import config + + +def make_yaml_lines(val, depth, comment=False): + new_lines = [] + line_prefix = " " * (depth + 1) + for line in yaml.dump(val).split('\n'): + if line == '': + continue + if comment: + new_lines.append(line_prefix + "# %s" % line) + else: + new_lines.append(line_prefix + line) + return new_lines + + +def make_field_lines(field, depth): + field_lines = [] + line_prefix = " " * (depth + 1) + field_type = field.type.__class__.__name__ + field_lines.append(line_prefix + "# %s" % field_type) + field_help_text = "# %s" % field.help_text + field_lines.append(line_prefix + field_help_text) + + default = '' + if field.default is not None: + default = field.default + + if not default and field.sample_default is not None: + default = field.sample_default + + if field_type == "Dict": + if default: + field_lines.append(line_prefix + "%s:" % field.name) + field_lines += make_yaml_lines(default, depth + 1) + else: + field_lines.append(line_prefix + "# %s:" % field.name) + elif field_type == "List": + if default: + field_lines.append(line_prefix + "%s:" % field.name) + field_lines += make_yaml_lines(default, depth + 1) + else: + field_lines.append(line_prefix + "# %s:" % field.name) + else: + if default == '': + field_lines.append(line_prefix + "# %s: " % field.name) + else: + default_str = " " + str(default) + field_lines.append(line_prefix + "%s:%s" % (field.name, default_str)) + return field_lines + + +def make_group_lines(group, depth=0): + group_lines = [] + line_prefix = " " * depth + group_lines.append(line_prefix + "%s:" % group.name) + + for child in group: + if isinstance(child, groups.ConfigGroup): + group_lines += make_group_lines(child, depth=depth + 1) + else: + group_lines += make_field_lines(child, depth) + return group_lines + + +class Command(BaseCommand): + help = '' + + def add_arguments(self, parser): + parser.add_argument('--output-file', default="adjutant.yaml") + + def handle(self, *args, **options): + print("Generating example file to: '%s'" % options['output_file']) + + base_lines = [] + for group in config._root_config: + base_lines += make_group_lines(group) + base_lines.append("") + + with open(options['output_file'], "w") as f: + for line in base_lines: + f.write(line) + f.write("\n") diff --git a/adjutant/common/constants.py b/adjutant/common/constants.py index 528fa10..1ec7ff3 100644 --- a/adjutant/common/constants.py +++ b/adjutant/common/constants.py @@ -15,3 +15,5 @@ # Date formats to use when storing time data we expect to parse. DATE_FORMAT = "%Y-%m-%dT%H:%M:%S" DATE_FORMAT_MS = "%Y-%m-%dT%H:%M:%S.%f" +EMAIL_REGEX = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" +EMAIL_WITH_TEMPLATE_REGEX = r"(^[%()a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" diff --git a/adjutant/common/openstack_clients.py b/adjutant/common/openstack_clients.py index 0ff7bbc..853daae 100644 --- a/adjutant/common/openstack_clients.py +++ b/adjutant/common/openstack_clients.py @@ -13,8 +13,6 @@ # under the License. -from django.conf import settings - from keystoneauth1.identity import v3 from keystoneauth1 import session from keystoneclient import client as ks_client @@ -24,6 +22,8 @@ from neutronclient.v2_0 import client as neutronclient from novaclient import client as novaclient from octaviaclient.api.v2 import octavia +from adjutant.config import CONF + # Defined for use locally DEFAULT_COMPUTE_VERSION = "2" DEFAULT_IDENTITY_VERSION = "3" @@ -43,12 +43,12 @@ def get_auth_session(): if not client_auth_session: auth = v3.Password( - username=settings.KEYSTONE['username'], - password=settings.KEYSTONE['password'], - project_name=settings.KEYSTONE['project_name'], - auth_url=settings.KEYSTONE['auth_url'], - user_domain_id=settings.KEYSTONE.get('domain_id', "default"), - project_domain_id=settings.KEYSTONE.get('domain_id', "default"), + username=CONF.identity.auth.username, + password=CONF.identity.auth.password, + project_name=CONF.identity.auth.project_name, + auth_url=CONF.identity.auth.auth_url, + user_domain_id=CONF.identity.auth.user_domain_id, + project_domain_id=CONF.identity.auth.project_domain_id, ) client_auth_session = session.Session(auth=auth) diff --git a/adjutant/common/quota.py b/adjutant/common/quota.py index 1ab6542..cca6ed3 100644 --- a/adjutant/common/quota.py +++ b/adjutant/common/quota.py @@ -12,11 +12,9 @@ # License for the specific language governing permissions and limitations # under the License. - +from adjutant.config import CONF from adjutant.common import openstack_clients -from django.conf import settings - class QuotaManager(object): """ @@ -182,23 +180,22 @@ class QuotaManager(object): self.default_helpers = dict(self._quota_updaters) self.helpers = {} - if settings.QUOTA_SERVICES: - quota_services = dict(settings.QUOTA_SERVICES) + quota_services = dict(CONF.quota.services) - all_regions = quota_services.pop('*', None) - if all_regions: - self.default_helpers = {} - for service in all_regions: - if service in self._quota_updaters: - self.default_helpers[service] = \ - self._quota_updaters[service] + all_regions = quota_services.pop('*', None) + if all_regions: + self.default_helpers = {} + for service in all_regions: + if service in self._quota_updaters: + self.default_helpers[service] = \ + self._quota_updaters[service] - for region, services in quota_services.items(): - self.helpers[region] = {} - for service in services: - if service in self._quota_updaters: - self.helpers[region][service] = \ - self._quota_updaters[service] + for region, services in quota_services.items(): + self.helpers[region] = {} + for service in services: + if service in self._quota_updaters: + self.helpers[region][service] = \ + self._quota_updaters[service] self.project_id = project_id self.size_diff_threshold = (size_difference_threshold @@ -217,7 +214,7 @@ class QuotaManager(object): def get_quota_differences(self, current_quota): """ Gets the closest matching quota size for a given quota """ quota_differences = {} - for size, setting in settings.PROJECT_QUOTA_SIZES.items(): + for size, setting in CONF.quota.sizes.items(): match_percentages = [] for service_name, values in setting.items(): if service_name not in current_quota: @@ -268,7 +265,7 @@ class QuotaManager(object): def get_quota_change_options(self, quota_size): """ Get's the pre-approved quota change options for a given size """ - quota_list = settings.QUOTA_SIZES_ASC + quota_list = CONF.quota.sizes_ascending try: list_position = quota_list.index(quota_size) except ValueError: @@ -283,7 +280,7 @@ class QuotaManager(object): def get_smaller_quota_options(self, quota_size): """ Get the quota sizes smaller than the current size.""" - quota_list = settings.QUOTA_SIZES_ASC + quota_list = CONF.quota.sizes_ascending try: list_position = quota_list.index(quota_size) except ValueError: diff --git a/adjutant/common/tests/fake_clients.py b/adjutant/common/tests/fake_clients.py index 976b633..190e0f3 100644 --- a/adjutant/common/tests/fake_clients.py +++ b/adjutant/common/tests/fake_clients.py @@ -14,10 +14,10 @@ from uuid import uuid4 -from django.conf import settings - import mock +from adjutant.config import CONF + identity_cache = {} neutron_cache = {} @@ -102,7 +102,6 @@ def setup_identity_cache(projects=None, users=None, role_assignments=None, credentials=None, extra_roles=None): if extra_roles is None: extra_roles = [] - if not projects: projects = [] if not users: @@ -125,7 +124,7 @@ def setup_identity_cache(projects=None, users=None, role_assignments=None, users.append(admin_user) roles = [ - FakeRole(name="_member_"), + FakeRole(name="member"), FakeRole(name="admin"), FakeRole(name="project_admin"), FakeRole(name="project_mod"), @@ -164,7 +163,7 @@ class FakeManager(object): def __init__(self): # TODO(adriant): decide if we want to have some function calls # throw errors if this is false. - self.can_edit_users = settings.KEYSTONE.get('can_edit_users', True) + self.can_edit_users = CONF.identity.can_edit_users def _project_from_id(self, project): if isinstance(project, FakeProject): @@ -482,6 +481,31 @@ class FakeManager(object): for cred in found: identity_cache['credentials'].remove(cred) + # TODO(adriant): Move this to a BaseIdentityManager class when + # it exists. + def get_manageable_roles(self, user_roles=None): + """Get roles which can be managed + + Given a list of user role names, returns a list of names + that the user is allowed to manage. + + If user_roles is not given, returns all possible roles. + """ + roles_mapping = CONF.identity.role_mapping + if user_roles is None: + all_roles = [] + for options in roles_mapping.values(): + all_roles += options + return list(set(all_roles)) + + # merge mapping lists to form a flat permitted roles list + manageable_role_names = [mrole for role_name in user_roles + if role_name in roles_mapping + for mrole in roles_mapping[role_name]] + # a set has unique items + manageable_role_names = set(manageable_role_names) + return manageable_role_names + class FakeOpenstackClient(object): class Quotas(object): @@ -662,7 +686,7 @@ class FakeOctaviaClient(object): self.cache[project_id] = { name: [] for name in self.resource_dict.keys()} self.cache[project_id]['quota'] = dict( - settings.PROJECT_QUOTA_SIZES['small']['octavia']) + CONF.quota.sizes['small']['octavia']) def __getattr__(self, name): # NOTE(amelia): This is out of pure laziness @@ -748,7 +772,7 @@ def setup_neutron_cache(region, project_id): } neutron_cache[region][project_id]['quota'] = dict( - settings.PROJECT_QUOTA_SIZES['small']['neutron']) + CONF.quota.sizes['small']['neutron']) def setup_cinder_cache(region, project_id): @@ -764,7 +788,7 @@ def setup_cinder_cache(region, project_id): } cinder_cache[region][project_id]['quota'] = dict( - settings.PROJECT_QUOTA_SIZES['small']['cinder']) + CONF.quota.sizes['small']['cinder']) def setup_nova_cache(region, project_id): @@ -785,7 +809,7 @@ def setup_nova_cache(region, project_id): } } nova_cache[region][project_id]['quota'] = dict( - settings.PROJECT_QUOTA_SIZES['small']['nova']) + CONF.quota.sizes['small']['nova']) def setup_quota_cache(region_name, project_id, size='small'): @@ -801,7 +825,7 @@ def setup_quota_cache(region_name, project_id, size='small'): } cinder_cache[region_name][project_id]['quota'] = dict( - settings.PROJECT_QUOTA_SIZES[size]['cinder']) + CONF.quota.sizes[size]['cinder']) global nova_cache if region_name not in nova_cache: @@ -813,7 +837,7 @@ def setup_quota_cache(region_name, project_id, size='small'): } nova_cache[region_name][project_id]['quota'] = dict( - settings.PROJECT_QUOTA_SIZES[size]['nova']) + CONF.quota.sizes[size]['nova']) global neutron_cache if region_name not in neutron_cache: @@ -825,7 +849,7 @@ def setup_quota_cache(region_name, project_id, size='small'): } neutron_cache[region_name][project_id]['quota'] = dict( - settings.PROJECT_QUOTA_SIZES[size]['neutron']) + CONF.quota.sizes[size]['neutron']) def setup_mock_caches(region, project_id): diff --git a/adjutant/common/tests/test_utils.py b/adjutant/common/tests/test_utils.py deleted file mode 100644 index 8895826..0000000 --- a/adjutant/common/tests/test_utils.py +++ /dev/null @@ -1,263 +0,0 @@ -# Copyright (C) 2017 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. - -import mock - -from rest_framework import status - -from adjutant.api.models import Token -from adjutant.common.tests import fake_clients -from adjutant.common.tests.fake_clients import ( - FakeManager, setup_identity_cache) -from adjutant.common.tests.utils import (AdjutantAPITestCase, - modify_dict_settings) - -from django.core import mail - - -@mock.patch('adjutant.common.user_store.IdentityManager', - FakeManager) -class ModifySettingsTests(AdjutantAPITestCase): - """ - Tests designed to test the modify_dict_settings decorator. - This is a bit weird to test because it's hard to directly test - a lot of this stuff (especially in cases where dicts are updated rather - than overridden). - """ - - # NOTE(amelia): Assumes the default settings for ResetUserPasswordAction - # are that blacklisted roles are ['admin'] - - def test_modify_settings_override_password(self): - """ - Test override reset, by changing the reset password blacklisted roles - """ - - user = fake_clients.FakeUser( - name="test@example.com", password="test_password", - email="test@example.com") - - user2 = fake_clients.FakeUser( - name="admin@example.com", password="admin_password", - email="admin@example.com") - - project = fake_clients.FakeProject(name="test_project") - - test_role = fake_clients.FakeRole("test_role") - - assignments = [ - fake_clients.FakeRoleAssignment( - scope={'project': {'id': project.id}}, - role_name="test_role", - user={'id': user.id} - ), - fake_clients.FakeRoleAssignment( - scope={'project': {'id': project.id}}, - role_name="admin", - user={'id': user2.id} - ), - ] - - setup_identity_cache( - projects=[project], users=[user, user2], - role_assignments=assignments, extra_roles=[test_role]) - - url = "/v1/actions/ResetPassword" - data = {'email': "test@example.com"} - admin_data = {'email': 'admin@example.com'} - - override = { - 'key_list': ['reset_user_password', 'action_settings', - 'ResetUserPasswordAction', 'blacklisted_roles'], - 'operation': 'override', - 'value': ['test_role']} - - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(1, Token.objects.count()) - - # NOTE(amelia): This next bit relies on the default settings being - # that admins can't reset their own password - with self.modify_dict_settings(TASK_SETTINGS=override): - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(0, Token.objects.count()) - - response2 = self.client.post(url, admin_data, format='json') - self.assertEqual(response2.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(1, Token.objects.count()) - - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(2, Token.objects.count()) - - response = self.client.post(url, admin_data, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(1, Token.objects.count()) - - def test_modify_settings_remove_password(self): - """ - Test override reset, by changing the reset password blacklisted roles - """ - - user = fake_clients.FakeUser( - name="admin@example.com", password="admin_password", - email="admin@example.com") - - project = fake_clients.FakeProject(name="test_project") - - assignment = fake_clients.FakeRoleAssignment( - scope={'project': {'id': project.id}}, - role_name="admin", - user={'id': user.id} - ) - - setup_identity_cache( - projects=[project], users=[user], role_assignments=[assignment]) - - url = "/v1/actions/ResetPassword" - data = {'email': 'admin@example.com'} - - override = { - 'key_list': ['reset_user_password', 'action_settings', - 'ResetUserPasswordAction', 'blacklisted_roles'], - 'operation': 'remove', - 'value': ['admin']} - - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(0, Token.objects.count()) - - with self.modify_dict_settings(TASK_SETTINGS=override): - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(1, Token.objects.count()) - - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(0, Token.objects.count()) - - @modify_dict_settings(TASK_SETTINGS={ - 'key_list': ['reset_user_password', 'action_settings', - 'ResetUserPasswordAction', 'blacklisted_roles'], - 'operation': 'append', - 'value': ['test_role']}) - def test_modify_settings_append_password(self): - """ - Test override reset, by changing the reset password blacklisted roles - """ - - user = fake_clients.FakeUser( - name="test@example.com", password="test_password", - email="test@example.com") - - user2 = fake_clients.FakeUser( - name="admin@example.com", password="admin_password", - email="admin@example.com") - - project = fake_clients.FakeProject(name="test_project") - - test_role = fake_clients.FakeRole("test_role") - - assignments = [ - fake_clients.FakeRoleAssignment( - scope={'project': {'id': project.id}}, - role_name="test_role", - user={'id': user.id} - ), - fake_clients.FakeRoleAssignment( - scope={'project': {'id': project.id}}, - role_name="admin", - user={'id': user2.id} - ), - ] - - setup_identity_cache( - projects=[project], users=[user, user2], - role_assignments=assignments, extra_roles=[test_role]) - - url = "/v1/actions/ResetPassword" - data = {'email': "test@example.com"} - - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(0, Token.objects.count()) - - admin_data = {'email': 'admin@example.com'} - response2 = self.client.post(url, admin_data, format='json') - self.assertEqual(response2.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(0, Token.objects.count()) - - def test_modify_settings_update_email(self): - """ - Tests the update operator using email sending - """ - - user = fake_clients.FakeUser( - name="test@example.com", password="test_password", - email="test@example.com") - - project = fake_clients.FakeProject(name="test_project") - - assignment = fake_clients.FakeRoleAssignment( - scope={'project': {'id': project.id}}, - role_name="project_admin", - user={'id': user.id} - ) - - setup_identity_cache( - projects=[project], users=[user], role_assignments=[assignment]) - - url = "/v1/actions/UpdateEmail" - data = {'new_email': "new_test@example.com"} - - headers = { - 'project_name': "test_project", - 'project_id': project.id, - 'roles': "project_admin,_member_,project_mod", - 'username': "test@example.com", - 'user_id': user.id, - 'authenticated': True - } - - override = [ - {'key_list': ['update_user_email', 'emails', 'token'], - 'operation': 'update', - 'value': { - 'subject': 'modified_token_email', - 'template': 'update_user_email_token.txt'} - } - ] - - response = self.client.post(url, data, headers=headers, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(len(mail.outbox), 1) - self.assertNotEqual(mail.outbox[0].subject, 'modified_token_email') - - with self.modify_dict_settings(TASK_SETTINGS=override): - data = {'new_email': "test2@example.com"} - - response = self.client.post(url, data, - headers=headers, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertEqual(len(mail.outbox), 2) - self.assertEqual(mail.outbox[1].subject, 'modified_token_email') - - data = {'new_email': "test3@example.com"} - - response = self.client.post(url, data, headers=headers, format='json') - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - - self.assertEqual(len(mail.outbox), 3) - self.assertNotEqual(mail.outbox[2].subject, 'modified_token_email') diff --git a/adjutant/common/tests/utils.py b/adjutant/common/tests/utils.py index 58bd77c..7f43651 100644 --- a/adjutant/common/tests/utils.py +++ b/adjutant/common/tests/utils.py @@ -12,194 +12,13 @@ # License for the specific language governing permissions and limitations # under the License. - -import copy - -from django.conf import settings -from django.test.utils import override_settings from django.test import TestCase from rest_framework.test import APITestCase from adjutant.common.tests import fake_clients -class modify_dict_settings(override_settings): - """ - A decorator like djangos modify_settings and override_settings, but makes - it possible to do those same operations on dict based settings. - - The decorator will act after both override_settings and modify_settings. - - Can be applied to test functions or AdjutantTestCase, - AdjutantAPITestCase classes. In those two classes settings can also - be modified using: - - with self.modify_dict_settings(...): - # code - - Example Usage: - @modify_dict_settings(ROLES_MAPPING=[ - {'key_list': ['project_mod'], - 'operation': 'remove', - 'value': 'heat_stack_owner'}, - {'key_list': ['project_admin'], - 'operation': 'append', - 'value': 'heat_stack_owner'}, - ]) - or - @modify_dict_settings(PROJECT_QUOTA_SIZES={ - 'key_list': ['small', 'nova', 'instances'], - 'operations': 'override', - 'value': 11 - }) - - Available operations: - Standard operations: - - 'update': A dict on dict operation to update final dict with value. - - 'override': Either overrides or adds the value to the dictionary. - - 'delete': Removes the value from the dictionary. - - List operations: - List operations expect that the accessed value in the dictionary is a list. - - 'append': Add the specified values to the end of the list - - 'prepend': Add the specifed values to the start of the list - - 'remove': Remove the specified values from the list - """ - - def __init__(self, *args, **kwargs): - if args: - # Hack used when instantiating from SimpleTestCase.setUpClass. - assert not kwargs - self.operations = args[0] - else: - assert not args - self.operations = list(kwargs.items()) - super(override_settings, self).__init__() - - def save_options(self, test_func): - if getattr(test_func, "_modified_dict_settings", None) is None: - test_func._modified_dict_settings = self.operations - else: - # Duplicate list to prevent subclasses from altering their parent. - test_func._modified_dict_settings = list( - test_func._modified_dict_settings) + self.operations - - def disable(self): - self.wrapped = self._wrapped - for update_dict in self.update_dicts: - update_dict['pointer'].clear() - update_dict['pointer'].update(update_dict['copy']) - - super(modify_dict_settings, self).disable() - - def enable(self): - self.options = {} - - self.update_dicts = [] - self._wrapped = copy.deepcopy(settings._wrapped) - - for name, operation_list in self.operations: - try: - value = self.options[name] - except KeyError: - value = getattr(settings, name, []) - - if not isinstance(value, dict): - raise ValueError("Initial setting not dictionary.") - - if not isinstance(operation_list, list): - operation_list = [operation_list] - - for operation in operation_list: - op_type = operation['operation'] - - holding_dict = value - - # Recursively find the dict we want - key_len = len(operation['key_list']) - final_key = operation['key_list'][0] - - for i in range(key_len): - current_key = operation['key_list'][i] - if i == (key_len - 1): - final_key = current_key - else: - try: - holding_dict = holding_dict[current_key] - except KeyError: - holding_dict[current_key] = {} - holding_dict = holding_dict[current_key] - - if op_type == "override": - holding_dict[final_key] = operation['value'] - elif op_type == "delete": - del holding_dict[final_key] - elif op_type == "update": - # Needs to be saved seperately and update re-used on - # disable due to pointers - self.update_dicts.append( - {'pointer': holding_dict[final_key], - 'copy': copy.deepcopy(holding_dict[final_key])}) - holding_dict[final_key].update(operation['value']) - else: - val = holding_dict.get(final_key, []) - items = operation['value'] - - if not isinstance(items, list): - items = [items] - - if op_type == 'append': - holding_dict[final_key] = val + [ - item for item in items if item not in val] - elif op_type == 'prepend': - holding_dict[final_key] = ([item for item in items if - item not in val] + val) - elif op_type == 'remove': - holding_dict[final_key] = [ - item for item in val if item not in items] - else: - raise ValueError("Unsupported action: %s" % op_type) - self.options[name] = value - super(modify_dict_settings, self).enable() - - -class TestCaseMixin(object): - """ Mixin to add modify_dict_settings functions to test classes """ - - @classmethod - def _apply_settings_changes(cls): - if getattr(cls, '_modified_dict_settings', None): - operations = {} - for key, value in cls._modified_dict_settings: - operations[key] = value - cls._cls_modified_dict_context = modify_dict_settings( - **operations) - cls._cls_modified_dict_context.enable() - - @classmethod - def _remove_settings_changes(cls): - if hasattr(cls, '_cls_modified_dict_context'): - cls._cls_modified_dict_context.disable() - delattr(cls, '_cls_modified_dict_context') - - def modify_dict_settings(self, **kwargs): - return modify_dict_settings(**kwargs) - - -class AdjutantTestCase(TestCase, TestCaseMixin): - """ - TestCase override that has support for @modify_dict_settings as a - class decorator and internal function - """ - @classmethod - def setUpClass(cls): - super(AdjutantTestCase, cls).setUpClass() - cls._apply_settings_changes() - - @classmethod - def tearDownClass(cls): - cls._remove_settings_changes() - super(AdjutantTestCase, cls).tearDownClass() +class AdjutantTestCase(TestCase): def tearDown(self): fake_clients.identity_cache.clear() @@ -208,20 +27,7 @@ class AdjutantTestCase(TestCase, TestCaseMixin): fake_clients.cinder_cache.clear() -class AdjutantAPITestCase(APITestCase, TestCaseMixin): - """ - APITestCase override that has support for @modify_dict_settings as a - class decorator, and internal function - """ - @classmethod - def setUpClass(cls): - super(AdjutantAPITestCase, cls).setUpClass() - cls._apply_settings_changes() - - @classmethod - def tearDownClass(cls): - cls._remove_settings_changes() - super(AdjutantAPITestCase, cls).tearDownClass() +class AdjutantAPITestCase(APITestCase): def tearDown(self): fake_clients.identity_cache.clear() diff --git a/adjutant/common/user_store.py b/adjutant/common/user_store.py index 550a63b..90b2623 100644 --- a/adjutant/common/user_store.py +++ b/adjutant/common/user_store.py @@ -14,32 +14,15 @@ from collections import defaultdict -from django.conf import settings - from keystoneclient import exceptions as ks_exceptions +from adjutant.config import CONF from adjutant.common.openstack_clients import get_keystoneclient -def get_managable_roles(user_roles): - """ - Given a list of user role names, returns a list of names - that the user is allowed to manage. - """ - manage_mapping = settings.ROLES_MAPPING - # merge mapping lists to form a flat permitted roles list - managable_role_names = [mrole for role_name in user_roles - if role_name in manage_mapping - for mrole in manage_mapping[role_name]] - # a set has unique items - managable_role_names = set(managable_role_names) - return managable_role_names - - def subtree_ids_list(subtree, id_list=None): if id_list is None: id_list = [] - if not subtree: return id_list for key in subtree.keys(): @@ -64,7 +47,7 @@ class IdentityManager(object): # pragma: no cover # TODO(adriant): decide if we want to have some function calls # throw errors if this is false. - self.can_edit_users = settings.KEYSTONE.get('can_edit_users', True) + self.can_edit_users = CONF.identity.can_edit_users def find_user(self, name, domain): try: @@ -355,3 +338,28 @@ class IdentityManager(object): # pragma: no cover for cred in credentials: if cred.user_id == user_id and cred.type == cred_type: self.ks_client.credentials.delete(cred) + + # TODO(adriant): Move this to a BaseIdentityManager class when + # it exists. + def get_manageable_roles(self, user_roles=None): + """Get roles which can be managed + + Given a list of user role names, returns a list of names + that the user is allowed to manage. + + If user_roles is not given, returns all possible roles. + """ + roles_mapping = CONF.identity.role_mapping + if user_roles is None: + all_roles = [] + for options in roles_mapping.values(): + all_roles += options + return list(set(all_roles)) + + # merge mapping lists to form a flat permitted roles list + manageable_role_names = [mrole for role_name in user_roles + if role_name in roles_mapping + for mrole in roles_mapping[role_name]] + # a set has unique items + manageable_role_names = set(manageable_role_names) + return manageable_role_names diff --git a/adjutant/config/__init__.py b/adjutant/config/__init__.py new file mode 100644 index 0000000..7a70f54 --- /dev/null +++ b/adjutant/config/__init__.py @@ -0,0 +1,105 @@ +# 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 os +import sys +import yaml + +from confspirator import load +from confspirator import groups + +from adjutant.config import api +from adjutant.config import django +from adjutant.config import identity +from adjutant.config import notification +from adjutant.config import quota +from adjutant.config import workflow + +_root_config = groups.ConfigGroup("adjutant") +_root_config.register_child_config(django.config_group) +_root_config.register_child_config(identity.config_group) +_root_config.register_child_config(api.config_group) +_root_config.register_child_config(notification.config_group) +_root_config.register_child_config(workflow.config_group) +_root_config.register_child_config(quota.config_group) + +_config_file = "/etc/adjutant/adjutant.yaml" +_old_config_file = "/etc/adjutant/conf.yaml" + + +_test_mode_commands = [ + # Adjutant commands: + 'exampleconfig', + # Django commands: + 'check', + 'makemigrations', + 'squashmigrations', + 'test', + 'testserver', +] + + +def _load_config(): + if "adjutant-api" in sys.argv[0] and sys.argv[1] in _test_mode_commands: + test_mode = True + else: + test_mode = False + + config_file_locations = [_config_file, _old_config_file] + + conf_file = os.environ.get("ADJUTANT_CONFIG_FILE", None) + + if conf_file: + config_file_locations.insert(0, conf_file) + + conf_dict = None + used_config_loc = None + for conf_file_loc in config_file_locations: + try: + with open(conf_file_loc) as f: + # NOTE(adriant): we print because we don't yet know + # where to log to + print("Loading config from '%s'" % conf_file_loc) + conf_dict = yaml.load(f, Loader=yaml.FullLoader) + used_config_loc = conf_file_loc + break + except IOError: + if not test_mode: + print( + "Conf file not found at '%s', trying next possible location." + % conf_file_loc + ) + + if used_config_loc != conf_file and used_config_loc == _old_config_file and not test_mode: + print( + "DEPRECATED: Using the old default config location '%s' is deprecated " + "in favor of '%s', or setting a config location via the environment " + "variable 'ADJUTANT_CONFIG_FILE'." % (_old_config_file, _config_file) + ) + + if conf_dict is None: + if not test_mode: + print( + "No valid conf file not found, will rely on defaults and " + "environment variables.\n" + "Config should be placed at '%s' or a location defined via the " + "environment variable 'ADJUTANT_CONFIG_FILE'." % _config_file + ) + conf_dict = {} + + conf_dict = {"adjutant": conf_dict} + return load(_root_config, conf_dict, test_mode=test_mode) + + +CONF = _load_config() diff --git a/adjutant/config/api.py b/adjutant/config/api.py new file mode 100644 index 0000000..2d15b62 --- /dev/null +++ b/adjutant/config/api.py @@ -0,0 +1,52 @@ +# 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 groups +from confspirator import fields + + +config_group = groups.ConfigGroup("api") + +config_group.register_child_config( + fields.ListConfig( + "active_delegate_apis", + help_text="List of Active Delegate APIs.", + required=True, + default=[ + 'UserRoles', + 'UserDetail', + 'UserResetPassword', + 'UserList', + 'RoleList', + ], + # NOTE(adriant): for testing purposes we include ALL default APIs + test_default=[ + 'UserRoles', + 'UserDetail', + 'UserResetPassword', + 'UserList', + 'RoleList', + 'SignUp', + 'UpdateProjectQuotas', + 'CreateProjectAndUser', + 'InviteUser', + 'ResetPassword', + 'EditUser', + 'UpdateEmail', + ], + ) +) + +delegate_apis_group = groups.ConfigGroup("delegate_apis", lazy_load=True) +config_group.register_child_config(delegate_apis_group) diff --git a/adjutant/config/django.py b/adjutant/config/django.py new file mode 100644 index 0000000..4958ea1 --- /dev/null +++ b/adjutant/config/django.py @@ -0,0 +1,121 @@ +# 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 groups +from confspirator import fields + + +config_group = groups.ConfigGroup("django") + +config_group.register_child_config( + fields.StrConfig( + "secret_key", + help_text="The Django secret key.", + required=True, + default="Do not ever use this awful secret in prod!!!!", + secret=True, + unsafe_default=True, + ) +) +config_group.register_child_config( + fields.BoolConfig( + "debug", + help_text="Django debug mode is turned on.", + default=False, + unsafe_default=True, + ) +) +config_group.register_child_config( + fields.ListConfig( + "allowed_hosts", + help_text="The Django allowed hosts", + required=True, + default=["*"], + 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", + help_text="Django databases config.", + default={ + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "db.sqlite3"} + }, + is_json=True, + unsafe_default=True, + ) +) +config_group.register_child_config( + fields.DictConfig( + "logging", + help_text="A full override of the Django logging config for more customised logging.", + is_json=True, + ) +) +config_group.register_child_config( + fields.StrConfig( + "log_file", + help_text="The name and location of the Adjutant log file, " + "superceded by 'adjutant.django.logging'.", + default="adjutant.log", + ) +) + +_email_group = groups.ConfigGroup("email") +_email_group.register_child_config( + fields.StrConfig( + "email_backend", + help_text="Django email backend to use.", + default="django.core.mail.backends.console.EmailBackend", + required=True, + ) +) +_email_group.register_child_config( + fields.IntConfig("timeout", help_text="Email backend timeout.") +) +_email_group.register_child_config( + fields.HostNameConfig("host", help_text="Email backend server location.") +) +_email_group.register_child_config( + fields.PortConfig("port", help_text="Email backend server port.") +) +_email_group.register_child_config( + fields.StrConfig("host_user", help_text="Email backend user.") +) +_email_group.register_child_config( + fields.StrConfig("host_password", help_text="Email backend user password.") +) +_email_group.register_child_config( + fields.BoolConfig( + "use_tls", + help_text="Whether to use TLS for email. Mutually exclusive with 'use_ssl'.", + default=False, + ) +) +_email_group.register_child_config( + fields.BoolConfig( + "use_ssl", + help_text="Whether to use SSL for email. Mutually exclusive with 'use_tls'.", + default=False, + ) +) + +config_group.register_child_config(_email_group) diff --git a/adjutant/config/identity.py b/adjutant/config/identity.py new file mode 100644 index 0000000..74e59f7 --- /dev/null +++ b/adjutant/config/identity.py @@ -0,0 +1,137 @@ +# 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 groups +from confspirator import fields +from confspirator import types + + +config_group = groups.ConfigGroup("identity") + +config_group.register_child_config( + fields.IntConfig( + "token_cache_time", + help_text="Cache time for Keystone Tokens in the Keystone Middleware.", + default=-1, + required=True, + required_for_tests=False, + ) +) +config_group.register_child_config( + fields.BoolConfig( + "can_edit_users", + help_text="Is Adjutant allowed (or able) to edit users in Keystone.", + default=True, + ) +) +config_group.register_child_config( + fields.BoolConfig( + "username_is_email", + help_text="Should Adjutant assume and treat all usernames as emails.", + default=True, + ) +) +config_group.register_child_config( + fields.DictConfig( + "role_mapping", + help_text="A mapping from held role to roles it is allowed to manage.", + value_type=types.List(), + check_value_type=True, + is_json=True, + default={ + 'admin': [ + 'project_admin', + 'project_mod', + 'heat_stack_owner', + 'member', + ], + 'project_admin': [ + 'project_admin', + 'project_mod', + 'heat_stack_owner', + 'member', + ], + 'project_mod': [ + 'project_mod', + 'heat_stack_owner', + 'member', + ], + }, + test_default={ + "admin": ["project_admin", "project_mod", "member", "heat_stack_owner"], + "project_admin": [ + "project_mod", + "member", + "heat_stack_owner", + "project_admin", + ], + "project_mod": ["member", "heat_stack_owner", "project_mod"], + }, + ) +) + +_auth_group = groups.ConfigGroup("auth") +_auth_group.register_child_config( + fields.StrConfig( + "username", + help_text="Username for Adjutant Keystone admin user.", + required=True, + required_for_tests=False, + ) +) +_auth_group.register_child_config( + fields.StrConfig( + "password", + help_text="Password for Adjutant Keystone admin user.", + required=True, + secret=True, + required_for_tests=False, + ) +) +_auth_group.register_child_config( + fields.StrConfig( + "project_name", + help_text="Project name for Adjutant Keystone admin user.", + required=True, + required_for_tests=False, + ) +) +_auth_group.register_child_config( + fields.StrConfig( + "project_domain_id", + help_text="Project domain id for Adjutant Keystone admin user.", + default="default", + required=True, + required_for_tests=False, + ) +) +_auth_group.register_child_config( + fields.StrConfig( + "user_domain_id", + help_text="User domain id for Adjutant Keystone admin user.", + default="default", + required=True, + required_for_tests=False, + ) +) +_auth_group.register_child_config( + fields.URIConfig( + "auth_url", + help_text="Keystone auth url that Adjutant will use.", + schemes=["https", "http"], + required=True, + required_for_tests=False, + ) +) +config_group.register_child_config(_auth_group) diff --git a/adjutant/config/notification.py b/adjutant/config/notification.py new file mode 100644 index 0000000..8a28f56 --- /dev/null +++ b/adjutant/config/notification.py @@ -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. + +from confspirator import groups + + +config_group = groups.ConfigGroup("notifications") + +handler_defaults_group = groups.ConfigGroup("handler_defaults", lazy_load=True) +config_group.register_child_config(handler_defaults_group) diff --git a/adjutant/config/plugin.py b/adjutant/config/plugin.py new file mode 100644 index 0000000..69ec036 --- /dev/null +++ b/adjutant/config/plugin.py @@ -0,0 +1,18 @@ +# 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 groups + + +config_group = groups.ConfigGroup("plugin") diff --git a/adjutant/config/quota.py b/adjutant/config/quota.py new file mode 100644 index 0000000..5222b12 --- /dev/null +++ b/adjutant/config/quota.py @@ -0,0 +1,160 @@ +# 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 groups +from confspirator import fields +from confspirator import types + + +DEFAULT_QUOTA_SIZES = { + 'small': { + 'nova': { + 'instances': 10, + 'cores': 20, + 'ram': 65536, + 'floating_ips': 10, + 'fixed_ips': 0, + 'metadata_items': 128, + 'injected_files': 5, + 'injected_file_content_bytes': 10240, + 'key_pairs': 50, + 'security_groups': 20, + 'security_group_rules': 100, + }, + 'cinder': { + 'gigabytes': 5000, + 'snapshots': 50, + 'volumes': 20, + }, + 'neutron': { + 'floatingip': 10, + 'network': 3, + 'port': 50, + 'router': 3, + 'security_group': 20, + 'security_group_rule': 100, + 'subnet': 3, + }, + "octavia": { + 'health_monitor': 5, + "listener": 1, + "load_balancer": 1, + "member": 2, + "pool": 1, + }, + }, + "medium": { + "cinder": { + "gigabytes": 10000, + "volumes": 100, + "snapshots": 300 + }, + "nova": { + "metadata_items": 128, + "injected_file_content_bytes": 10240, + "ram": 327680, + "floating_ips": 25, + "key_pairs": 50, + "instances": 50, + "security_group_rules": 400, + "injected_files": 5, + "cores": 100, + "fixed_ips": 0, + "security_groups": 50 + }, + "neutron": { + "security_group_rule": 400, + "subnet": 5, + "network": 5, + "floatingip": 25, + "security_group": 50, + "router": 5, + "port": 250 + }, + "octavia": { + 'health_monitor': 50, + "listener": 5, + "load_balancer": 5, + "member": 5, + "pool": 5, + }, + }, + "large": { + "cinder": { + "gigabytes": 50000, + "volumes": 200, + "snapshots": 600 + }, + "nova": { + "metadata_items": 128, + "injected_file_content_bytes": 10240, + "ram": 655360, + "floating_ips": 50, + "key_pairs": 50, + "instances": 100, + "security_group_rules": 800, + "injected_files": 5, + "cores": 200, + "fixed_ips": 0, + "security_groups": 100 + }, + "neutron": { + "security_group_rule": 800, + "subnet": 10, + "network": 10, + "floatingip": 50, + "security_group": 100, + "router": 10, + "port": 500 + }, + "octavia": { + 'health_monitor': 100, + "listener": 10, + "load_balancer": 10, + "member": 10, + "pool": 10, + }, + }, +} + + +config_group = groups.ConfigGroup("quota") + +config_group.register_child_config( + fields.DictConfig( + "sizes", + help_text="A definition of the quota size groups that Adjutant should use.", + value_type=types.Dict(value_type=types.Dict()), + check_value_type=True, + is_json=True, + default=DEFAULT_QUOTA_SIZES, + ) +) +config_group.register_child_config( + fields.ListConfig( + "sizes_ascending", + help_text="An ascending list of all the quota size names, " + "so that Adjutant knows their relative sizes/order.", + default=['small', 'medium', 'large'], + ) +) +config_group.register_child_config( + fields.DictConfig( + "services", + help_text="A per region definition of what services Adjutant should manage " + "quotas for. '*' means all or default region.", + value_type=types.List(), + default={'*': ['cinder', 'neutron', 'nova']}, + ) +) diff --git a/adjutant/config/workflow.py b/adjutant/config/workflow.py new file mode 100644 index 0000000..9f88f0e --- /dev/null +++ b/adjutant/config/workflow.py @@ -0,0 +1,170 @@ +# 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 groups +from confspirator import fields + + +config_group = groups.ConfigGroup("workflow") + +config_group.register_child_config( + fields.URIConfig( + "horizon_url", + help_text="The base Horizon url for Adjutant to use when producing links to Horizon.", + schemes=["https", "http"], + required=True, + sample_default="http://localhost/", + test_default="http://localhost/", + ) +) +config_group.register_child_config( + fields.IntConfig( + "default_token_expiry", + help_text="The default token expiry time for Task tokens.", + default=24 * 60 * 60, # 24hrs in seconds + ) +) + + +def _build_default_email_group( + group_name, + email_subject, + email_from, + email_reply, + email_template, + email_html_template, +): + email_group = groups.ConfigGroup(group_name) + email_group.register_child_config( + fields.StrConfig( + "subject", + help_text="Default email subject for this stage", + default=email_subject) + ) + email_group.register_child_config( + fields.StrConfig( + "from", + help_text="Default from email for this stage", + default=email_from) + ) + email_group.register_child_config( + fields.StrConfig( + "reply", + help_text="Default reply-to email for this stage", + default=email_reply) + ) + email_group.register_child_config( + fields.StrConfig( + "template", + help_text="Default email template for this stage", + default=email_template) + ) + email_group.register_child_config( + fields.StrConfig( + "html_template", + help_text="Default email html template for this stage", + default=email_html_template) + ) + return email_group + + +_task_defaults_group = groups.ConfigGroup("task_defaults") +config_group.register_child_config(_task_defaults_group) + +_email_defaults_group = groups.ConfigGroup("emails") +_task_defaults_group.register_child_config(_email_defaults_group) +_email_defaults_group.register_child_config( + _build_default_email_group( + group_name="initial", + email_subject="Task Confirmation", + email_reply="no-reply@example.com", + email_from="bounce+%(task_uuid)s@example.com", + email_template="initial.txt", + email_html_template=None, + ) +) +_email_defaults_group.register_child_config( + _build_default_email_group( + group_name="token", + email_subject="Task Token", + email_reply="no-reply@example.com", + email_from="bounce+%(task_uuid)s@example.com", + email_template="token.txt", + email_html_template=None, + ) +) +_email_defaults_group.register_child_config( + _build_default_email_group( + group_name="completed", + email_subject="Task Completed", + email_reply="no-reply@example.com", + email_from="bounce+%(task_uuid)s@example.com", + email_template="completed.txt", + email_html_template=None, + ) +) + +_notifications_defaults_group = groups.ConfigGroup("notifications") +_task_defaults_group.register_child_config(_notifications_defaults_group) + +_notifications_defaults_group.register_child_config( + fields.ListConfig( + "standard_handlers", + help_text="Handlers to use for standard notifications.", + required=True, + default=[ + 'EmailNotification', + ], + ) +) +_notifications_defaults_group.register_child_config( + fields.ListConfig( + "error_handlers", + help_text="Handlers to use for error notifications.", + required=True, + default=[ + 'EmailNotification', + ], + ) +) +_notifications_defaults_group.register_child_config( + fields.DictConfig( + "standard_handler_config", + help_text="Settings for standard notification handlers.", + default={}, + is_json=True, + ) +) +_notifications_defaults_group.register_child_config( + fields.DictConfig( + "error_handler_config", + help_text="Settings for error notification handlers.", + default={}, + is_json=True, + ) +) +_notifications_defaults_group.register_child_config( + fields.ListConfig( + "safe_errors", + help_text="Error types which are safe to acknowledge automatically.", + required=True, + default=['SMTPException'], + ) +) + +action_defaults_group = groups.ConfigGroup("action_defaults", lazy_load=True) +tasks_group = groups.ConfigGroup("tasks", lazy_load=True) + +config_group.register_child_config(action_defaults_group) +config_group.register_child_config(tasks_group) diff --git a/adjutant/notifications/__init__.py b/adjutant/notifications/__init__.py index e69de29..84e62f3 100644 --- a/adjutant/notifications/__init__.py +++ b/adjutant/notifications/__init__.py @@ -0,0 +1,15 @@ +# 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. + +NOTIFICATION_HANDLERS = {} diff --git a/adjutant/notifications/models.py b/adjutant/notifications/models.py index e54cef1..9dff617 100644 --- a/adjutant/notifications/models.py +++ b/adjutant/notifications/models.py @@ -15,20 +15,57 @@ from logging import getLogger from smtplib import SMTPException -from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template import loader from django.utils import timezone +from confspirator import groups +from confspirator import fields +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 -class NotificationEngine(object): +class BaseNotificationHandler(object): """""" - def __init__(self, conf): - self.conf = conf - self.logger = getLogger('adjutant') + 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) @@ -37,64 +74,77 @@ class NotificationEngine(object): raise NotImplementedError -class EmailNotification(NotificationEngine): +class EmailNotification(BaseNotificationHandler): """ - Basic email notification engine. Will + Basic email notification handler. Will send an email with the given templates. - - Example conf: - : - notifications: - EmailNotification: - standard: - emails: - - example@example.com - reply: no-reply@example.com - template: notification.txt - html_template: completed.txt - error: - emails: - - errors@example.com - reply: no-reply@example.com - template: notification.txt - html_template: completed.txt - : - ... """ + config_group = groups.DynamicNameConfigGroup( + children=[ + fields.ListConfig( + "emails", + help_text="List of email addresses to send this notification to.", + item_type=types.String(regex=constants.EMAIL_REGEX), + default=[], + ), + fields.StrConfig( + "from", + help_text="From email for this notification.", + regex=constants.EMAIL_WITH_TEMPLATE_REGEX, + sample_default="bounce+%(task_uuid)s@example.com", + ), + fields.StrConfig( + "reply", + help_text="Reply-to email for this notification.", + regex=constants.EMAIL_REGEX, + sample_default="no-reply@example.com", + ), + fields.StrConfig( + "template", + help_text="Email template for this notification. " + "No template will cause the email not to send.", + default="notification.txt", + ), + fields.StrConfig( + "html_template", + help_text="Email html template for this notification.", + ), + ] + ) + def _notify(self, task, notification): - if not self.conf or not self.conf['emails']: + conf = self.config(task, notification) + if not conf or not conf["emails"]: # Log that we did this!! note = ( - "Skipped sending notification for task: %s " - "as notification engine conf is None, or no emails " - "were configured." % task.uuid + "Skipped sending notification for task: %s (%s) " + "as notification handler conf is None, or no emails " + "were configured." % (task.task_type, task.uuid) ) self.logger.info("(%s) - %s" % (timezone.now(), note)) return - template = loader.get_template( - self.conf['template'], - using='include_etc_templates') - html_template = self.conf.get('html_template', None) + template = loader.get_template(conf["template"], using="include_etc_templates") + html_template = conf["html_template"] if html_template: html_template = loader.get_template( - html_template, - using='include_etc_templates') + html_template, using="include_etc_templates" + ) - context = { - 'task': task, 'notification': notification} + context = {"task": task, "notification": notification} - if settings.HORIZON_URL: - task_url = settings.HORIZON_URL - notification_url = settings.HORIZON_URL - if not task_url.endswith('/'): - task_url += '/' - task_url += 'management/tasks/%s' % task.uuid - notification_url += ( - 'management/notifications/%s' % notification.uuid) - context['task_url'] = task_url - context['notification_url'] = notification_url + if CONF.workflow.horizon_url: + task_url = CONF.workflow.horizon_url + notification_url = CONF.workflow.horizon_url + if not task_url.endswith("/"): + task_url += "/" + if not notification_url.endswith("/"): + notification_url += "/" + task_url += "management/tasks/%s" % task.uuid + notification_url += "management/notifications/%s" % notification.uuid + context["task_url"] = task_url + context["notification_url"] = notification_url if notification.error: subject = "Error - %s notification" % task.task_type @@ -105,52 +155,54 @@ class EmailNotification(NotificationEngine): # from_email is the return-path and is distinct from the # message headers - from_email = self.conf.get('from') + from_email = conf["from"] if not from_email: - from_email = self.conf['reply'] + from_email = conf["reply"] elif "%(task_uuid)s" in from_email: - from_email = from_email % {'task_uuid': task.uuid} + from_email = from_email % {"task_uuid": task.uuid} # these are the message headers which will be visible to # the email client. headers = { - 'X-Adjutant-Task-UUID': task.uuid, + "X-Adjutant-Task-UUID": task.uuid, # From needs to be set to be disctinct from return-path - 'From': self.conf['reply'], - 'Reply-To': self.conf['reply'], + "From": conf["reply"], + "Reply-To": conf["reply"], } email = EmailMultiAlternatives( - subject, - message, - from_email, - self.conf['emails'], - headers=headers, + subject, message, from_email, conf["emails"], headers=headers ) if html_template: - email.attach_alternative( - html_template.render(context), "text/html") + email.attach_alternative(html_template.render(context), "text/html") email.send(fail_silently=False) - if not notification.error: - notification.acknowledged = True - notification.save() + notification.acknowledged = True + notification.save() except SMTPException as e: - notes = { - 'errors': - [("Error: '%s' while sending email notification") % e] - } + notes = {"errors": [("Error: '%s' while sending email notification") % e]} error_notification = Notification.objects.create( - task=notification.task, - notes=notes, - error=True + task=notification.task, notes=notes, error=True ) error_notification.save() -notification_engines = { - 'EmailNotification': EmailNotification, -} +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) -settings.NOTIFICATION_ENGINES.update(notification_engines) + +register_notification_handler(EmailNotification) diff --git a/adjutant/notifications/tests/test_notifications.py b/adjutant/notifications/tests/test_notifications.py index af8d03a..889e946 100644 --- a/adjutant/notifications/tests/test_notifications.py +++ b/adjutant/notifications/tests/test_notifications.py @@ -18,64 +18,93 @@ from django.core import mail from rest_framework import status +from confspirator.tests import utils as conf_utils + from adjutant.api.models import Task, Notification from adjutant.common.tests.fake_clients import ( FakeManager, setup_identity_cache) -from adjutant.common.tests.utils import ( - AdjutantAPITestCase, modify_dict_settings) +from adjutant.common.tests.utils import AdjutantAPITestCase +from adjutant.config import CONF +from adjutant import exceptions @mock.patch('adjutant.common.user_store.IdentityManager', FakeManager) +@conf_utils.modify_conf( + CONF, + operations={ + "adjutant.workflow.tasks.create_project_and_user.notifications": [ + {'operation': 'override', 'value': { + "standard_handlers": ["EmailNotification"], + "error_handlers": ["EmailNotification"], + "standard_handler_config": { + "EmailNotification": { + 'emails': ['example_notification@example.com'], + 'reply': 'no-reply@example.com', + } + }, + "error_handler_config": { + "EmailNotification": { + 'emails': ['example_error_notification@example.com'], + 'reply': 'no-reply@example.com', + } + }, + }}, + ], + }) class NotificationTests(AdjutantAPITestCase): - @modify_dict_settings(TASK_SETTINGS={ - 'key_list': ['create_project', 'notifications'], - 'operation': 'override', - 'value': { - 'EmailNotification': { - 'standard': { - 'emails': ['example@example.com'], - 'reply': 'no-reply@example.com', - 'template': 'notification.txt' - }, - 'error': { - 'emails': ['example@example.com'], - 'reply': 'no-reply@example.com', - 'template': 'notification.txt' - } - } - } - }) def test_new_project_sends_notification(self): """ - Confirm that the email notification engine correctly acknowledges + Confirm that the email notification handler correctly acknowledges notifications it sends out. - """ + This tests standard and error notifications. + """ setup_identity_cache() - url = "/v1/actions/CreateProjectAndUser" + url = "/v1/openstack/sign-up" data = {'project_name': "test_project", 'email': "test@example.com"} response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + new_task = Task.objects.all()[0] + self.assertEqual(Notification.objects.count(), 1) + self.assertEqual(len(mail.outbox), 2) + self.assertEqual(mail.outbox[1].subject, "create_project_and_user notification") + self.assertEqual(mail.outbox[1].to, ['example_notification@example.com']) + + notif = Notification.objects.all()[0] + self.assertEqual(notif.task.uuid, new_task.uuid) + self.assertFalse(notif.error) + self.assertTrue(notif.acknowledged) + headers = { 'project_name': "test_project", 'project_id': "test_project_id", - 'roles': "admin,_member_", + 'roles': "admin,member", 'username': "test@example.com", 'user_id': "test_user_id", 'authenticated': True } - new_task = Task.objects.all()[0] url = "/v1/tasks/" + new_task.uuid - response = self.client.post(url, {'approved': True}, format='json', - headers=headers) + with mock.patch( + "adjutant.common.tests.fake_clients.FakeManager.find_project" + ) as mocked_find: + mocked_find.side_effect = exceptions.ServiceUnavailable( + "Forced key error for testing." + ) + response = self.client.post( + url, {"approved": True}, format="json", headers=headers + ) - self.assertEqual(Notification.objects.count(), 1) + # should send token email, but no new notification + self.assertEqual(Notification.objects.count(), 2) self.assertEqual(len(mail.outbox), 3) + self.assertEqual(mail.outbox[2].subject, "Error - create_project_and_user notification") + self.assertEqual(mail.outbox[2].to, ['example_error_notification@example.com']) - notif = Notification.objects.all()[0] + notif = Notification.objects.all()[1] self.assertEqual(notif.task.uuid, new_task.uuid) + self.assertTrue(notif.error) self.assertTrue(notif.acknowledged) diff --git a/adjutant/notifications/utils.py b/adjutant/notifications/utils.py new file mode 100644 index 0000000..adf2629 --- /dev/null +++ b/adjutant/notifications/utils.py @@ -0,0 +1,42 @@ +# 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 notifications +from adjutant.api.models import Notification + + +def create_notification(task, notes, error=False, handlers=True): + notification = Notification.objects.create( + task=task, + notes=notes, + error=error + ) + notification.save() + + if not handlers: + return notification + + notif_conf = task.config.notifications + + if error: + notif_handlers = notif_conf.error_handlers + else: + notif_handlers = notif_conf.standard_handlers + + if notif_handlers: + for notif_handler in notif_handlers: + handler = notifications.NOTIFICATION_HANDLERS[notif_handler]() + handler.notify(task, notification) + + return notification diff --git a/adjutant/plugins.py b/adjutant/plugins.py new file mode 100644 index 0000000..f7b7fdc --- /dev/null +++ b/adjutant/plugins.py @@ -0,0 +1,46 @@ +# 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) diff --git a/adjutant/settings.py b/adjutant/settings.py index 3ee13b0..a0b26ad 100644 --- a/adjutant/settings.py +++ b/adjutant/settings.py @@ -25,9 +25,9 @@ https://docs.djangoproject.com/en/1.11/ref/settings/ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import os import sys -import yaml -from adjutant.utils import setup_task_settings -from adjutant.exceptions import ConfigurationException + +from adjutant.config import CONF as adj_conf + BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # Application definition @@ -41,10 +41,17 @@ INSTALLED_APPS = ( 'django.contrib.staticfiles', 'rest_framework', 'rest_framework_swagger', + + 'adjutant.commands', 'adjutant.actions', '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', ) MIDDLEWARE_CLASSES = ( @@ -106,114 +113,63 @@ REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': [], } -# Setup of local settings data -if 'test' in sys.argv: - from adjutant import test_settings - CONFIG = test_settings.conf_dict -else: - config_file = "/etc/adjutant/conf.yaml" - if not os.path.isfile(config_file): - print("%s does not exist. Reverting to default config file." % - config_file) - config_file = "conf/conf.yaml" - with open(config_file) as f: - CONFIG = yaml.load(f, Loader=yaml.FullLoader) - -SECRET_KEY = CONFIG['SECRET_KEY'] +SECRET_KEY = adj_conf.django.secret_key # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = CONFIG.get('DEBUG', False) - +DEBUG = adj_conf.django.debug if DEBUG: REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append( 'rest_framework.renderers.BrowsableAPIRenderer') -ALLOWED_HOSTS = CONFIG.get('ALLOWED_HOSTS', []) - -for app in CONFIG['ADDITIONAL_APPS']: - INSTALLED_APPS = list(INSTALLED_APPS) - INSTALLED_APPS.append(app) +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.append("adjutant.startup") +INSTALLED_APPS = _INSTALLED_APPS -DATABASES = CONFIG['DATABASES'] +DATABASES = adj_conf.django.databases -LOGGING = CONFIG['LOGGING'] +if adj_conf.django.logging: + LOGGING = adj_conf.django.logging +else: + LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': adj_conf.django.log_file, + }, + }, + 'loggers': { + 'adjutant': { + 'handlers': ['file'], + 'level': 'INFO', + 'propagate': False, + }, + 'django': { + 'handlers': ['file'], + 'level': 'INFO', + 'propagate': False, + }, + 'keystonemiddleware': { + 'handlers': ['file'], + 'level': 'INFO', + 'propagate': False, + }, + }, + } -EMAIL_BACKEND = CONFIG['EMAIL_SETTINGS']['EMAIL_BACKEND'] -EMAIL_TIMEOUT = 60 +EMAIL_BACKEND = adj_conf.django.email.email_backend +EMAIL_TIMEOUT = adj_conf.django.email.timeout -EMAIL_HOST = CONFIG['EMAIL_SETTINGS'].get('EMAIL_HOST') -EMAIL_PORT = CONFIG['EMAIL_SETTINGS'].get('EMAIL_PORT') -EMAIL_HOST_USER = CONFIG['EMAIL_SETTINGS'].get('EMAIL_HOST_USER') -EMAIL_HOST_PASSWORD = CONFIG['EMAIL_SETTINGS'].get('EMAIL_HOST_PASSWORD') -EMAIL_USE_TLS = CONFIG['EMAIL_SETTINGS'].get('EMAIL_USE_TLS', False) -EMAIL_USE_SSL = CONFIG['EMAIL_SETTINGS'].get('EMAIL_USE_SSL', False) - -# setting to control if user name and email are allowed -# to have different values. -USERNAME_IS_EMAIL = CONFIG['USERNAME_IS_EMAIL'] - -# Keystone admin credentials: -KEYSTONE = CONFIG['KEYSTONE'] - -TOKEN_SUBMISSION_URL = CONFIG.get('TOKEN_SUBMISSION_URL') -if TOKEN_SUBMISSION_URL: - print("'TOKEN_SUBMISSION_URL' is deprecated, use 'HORIZON_URL' instead") - -HORIZON_URL = CONFIG.get('HORIZON_URL') - -if not HORIZON_URL and not TOKEN_SUBMISSION_URL: - raise ConfigurationException("Must supply 'HORIZON_URL'") - -TOKEN_EXPIRE_TIME = CONFIG['TOKEN_EXPIRE_TIME'] - -DEFAULT_ACTION_SETTINGS = CONFIG['DEFAULT_ACTION_SETTINGS'] - -TASK_SETTINGS = setup_task_settings( - CONFIG['DEFAULT_TASK_SETTINGS'], - CONFIG['DEFAULT_ACTION_SETTINGS'], - CONFIG['TASK_SETTINGS']) - -DEFAULT_TASK_SETTINGS = CONFIG['DEFAULT_TASK_SETTINGS'] - -PLUGIN_SETTINGS = CONFIG.get('PLUGIN_SETTINGS', {}) - -ROLES_MAPPING = CONFIG['ROLES_MAPPING'] - -TOKEN_CACHE_TIME = CONFIG.get('TOKEN_CACHE_TIME', 60) - -PROJECT_QUOTA_SIZES = CONFIG.get('PROJECT_QUOTA_SIZES') - -QUOTA_SIZES_ASC = CONFIG.get('QUOTA_SIZES_ASC', []) - -ACTIVE_DELEGATE_APIS = CONFIG.get( - 'ACTIVE_DELEGATE_APIS', - [ - 'UserRoles', - 'UserDetail', - 'UserResetPassword', - 'UserList', - 'RoleList' - ]) - -# Default services for which to check and update quotas for -QUOTA_SERVICES = CONFIG.get( - 'QUOTA_SERVICES', - {'*': ['cinder', 'neutron', 'nova']}) - - -# Dict of DelegateAPIs and their url_paths. -# - This is populated by registering DelegateAPIs. -DELEGATE_API_CLASSES = {} - -# Dict of actions and their serializers. -# - This is populated from the various model modules at startup: -ACTION_CLASSES = {} - -TASK_CLASSES = {} - -NOTIFICATION_ENGINES = {} +EMAIL_HOST = adj_conf.django.email.host +EMAIL_PORT = adj_conf.django.email.port +EMAIL_HOST_USER = adj_conf.django.email.host_user +EMAIL_HOST_PASSWORD = adj_conf.django.email.host_password +EMAIL_USE_TLS = adj_conf.django.email.use_tls +EMAIL_USE_SSL = adj_conf.django.email.use_ssl diff --git a/adjutant/startup/checks.py b/adjutant/startup/checks.py index cba33ab..f1d87dd 100644 --- a/adjutant/startup/checks.py +++ b/adjutant/startup/checks.py @@ -1,13 +1,14 @@ from django.apps import AppConfig -from django.conf import settings +from adjutant.config import CONF +from adjutant import actions, api, tasks from adjutant.exceptions import ActionNotRegistered, DelegateAPINotRegistered def check_expected_delegate_apis(): missing_delegate_apis = list( - set(settings.ACTIVE_DELEGATE_APIS) - - set(settings.DELEGATE_API_CLASSES.keys())) + set(CONF.api.active_delegate_apis) + - set(api.DELEGATE_API_CLASSES.keys())) if missing_delegate_apis: raise DelegateAPINotRegistered( @@ -20,15 +21,15 @@ def check_configured_actions(): """Check that all the expected actions have been registered.""" configured_actions = [] - for task in settings.TASK_CLASSES: - task_class = settings.TASK_CLASSES.get(task) + for task in tasks.TASK_CLASSES: + task_class = tasks.TASK_CLASSES.get(task) configured_actions += task_class.default_actions - configured_actions += settings.TASK_SETTINGS.get( - task_class.task_type, {}).get('additional_actions', []) + configured_actions += CONF.workflow.tasks.get( + task_class.task_type).additional_actions missing_actions = list( - set(configured_actions) - set(settings.ACTION_CLASSES.keys())) + set(configured_actions) - set(actions.ACTION_CLASSES.keys())) if missing_actions: raise ActionNotRegistered( diff --git a/adjutant/tasks/__init__.py b/adjutant/tasks/__init__.py index e69de29..7386357 100644 --- a/adjutant/tasks/__init__.py +++ b/adjutant/tasks/__init__.py @@ -0,0 +1,15 @@ +# Copyright (C) 2019 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. + +TASK_CLASSES = {} diff --git a/adjutant/tasks/models.py b/adjutant/tasks/models.py index 31c00f8..ead2ff9 100644 --- a/adjutant/tasks/models.py +++ b/adjutant/tasks/models.py @@ -12,12 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf import settings from django.db import models from uuid import uuid4 from django.utils import timezone from jsonfield import JSONField +from adjutant.config import CONF +from adjutant import tasks + def hex_uuid(): return uuid4().hex @@ -75,7 +77,15 @@ class Task(models.Model): def get_task(self): """Returns self as the appropriate task wrapper type.""" - return settings.TASK_CLASSES[self.task_type](task_model=self) + return tasks.TASK_CLASSES[self.task_type](task_model=self) + + @property + def config(self): + try: + task_conf = CONF.workflow.tasks[self.task_type] + except KeyError: + task_conf = {} + return CONF.workflow.task_defaults.overlay(task_conf) @property def actions(self): diff --git a/adjutant/tasks/v1/base.py b/adjutant/tasks/v1/base.py index 645cbba..22b9545 100644 --- a/adjutant/tasks/v1/base.py +++ b/adjutant/tasks/v1/base.py @@ -15,16 +15,103 @@ import hashlib from logging import getLogger -from django.conf import settings +from confspirator import groups +from confspirator import fields +from adjutant import actions as adj_actions from adjutant.api.models import Task +from adjutant.config import CONF from django.utils import timezone -from adjutant.api.v1.utils import create_notification +from adjutant.notifications.utils import create_notification from adjutant.tasks.v1.utils import ( send_stage_email, create_token, handle_task_error) from adjutant import exceptions +def make_task_config(task_class): + + config_group = groups.DynamicNameConfigGroup() + config_group.register_child_config( + fields.BoolConfig( + "allow_auto_approve", + help_text="Override if this task allows auto_approval. " + "Otherwise uses task default.", + default=task_class.allow_auto_approve, + ) + ) + config_group.register_child_config( + fields.ListConfig( + "additional_actions", + help_text="Additional actions to be run as part of the task " + "after default actions.", + default=task_class.additional_actions or [], + ) + ) + config_group.register_child_config( + fields.IntConfig( + "token_expiry", + help_text="Override for the task token expiry. " + "Otherwise uses task default.", + default=task_class.token_expiry, + ) + ) + config_group.register_child_config( + fields.DictConfig( + "actions", + help_text="Action config overrides over the action defaults. " + "See 'adjutant.workflow.action_defaults'.", + is_json=True, + default=task_class.action_config or {}, + sample_default={ + "SomeCustomAction": { + "some_action_setting": "" + } + }, + ) + ) + config_group.register_child_config( + fields.DictConfig( + "emails", + help_text="Email config overrides for this task over task defaults." + "See 'adjutant.workflow.emails'.", + is_json=True, + default=task_class.email_config or {}, + sample_default={ + "initial": None, + "token": { + "subject": "Some custom subject", + }, + }, + ) + ) + config_group.register_child_config( + fields.DictConfig( + "notifications", + help_text="Notification config overrides for this task over task defaults." + "See 'adjutant.workflow.notifications'.", + is_json=True, + default=task_class.notification_config or {}, + sample_default={ + "standard_handlers": ["EmailNotification"], + "error_handlers": ["EmailNotification"], + "standard_handler_config": { + "EmailNotification": { + 'emails': ['example@example.com'], + 'reply': 'no-reply@example.com', + } + }, + "error_handler_config": { + "EmailNotification": { + 'emails': ['example@example.com'], + 'reply': 'no-reply@example.com', + } + }, + }, + ) + ) + return config_group + + class BaseTask(object): """ Base class for in memory task representation. @@ -37,23 +124,28 @@ class BaseTask(object): logic here, and includes some wrapper logic to help deal with workflows. """ - # default values to optionally override - duplicate_policy = "cancel" - allow_auto_approve = True - send_approval_notification = True - # required values in custom task task_type = None default_actions = None - # optional values + # default values to optionally override in task definition deprecated_task_types = None + duplicate_policy = "cancel" + send_approval_notification = True + + # 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 def __init__(self, task_model=None, task_data=None, action_data=None): - + self._config = None self.logger = getLogger('adjutant') if task_model: @@ -99,7 +191,7 @@ class BaseTask(object): actions = self.actions else: actions = self.default_actions[:] - actions += self.settings.get('additional_actions', []) + actions += self.config.additional_actions # instantiate all action serializers and check validity valid = True @@ -110,7 +202,7 @@ class BaseTask(object): action_name = action action_class, serializer_class = \ - settings.ACTION_CLASSES[action_name] + adj_actions.ACTION_CLASSES[action_name] if use_existing_actions: action_class = action @@ -152,7 +244,7 @@ class BaseTask(object): hashable_list.append( action['serializer'].validated_data[field]) except KeyError: - if field == "username" and settings.USERNAME_IS_EMAIL: + if field == "username" and CONF.identity.username_is_email: continue else: raise @@ -188,12 +280,13 @@ class BaseTask(object): def _create_token(self): self.clear_tokens() - token = create_token(self.task) + token_expiry = self.config.token_expiry or self.token_expiry + token = create_token(self.task, token_expiry) self.add_note("Token created for task.") try: # will throw a key error if the token template has not # been specified - email_conf = self.settings['emails']['token'] + email_conf = self.config.emails.token send_stage_email(self.task, email_conf, token) except KeyError as e: handle_task_error(e, self.task, error_text='while sending token') @@ -209,15 +302,18 @@ class BaseTask(object): self.task.add_task_note(note) @property - def settings(self): - """Get my settings. + def config(self): + """Get my config. - Returns a dict of the settings for this task. + Returns a dict of the config for this task. """ - try: - return settings.TASK_SETTINGS[self.task_type] - except KeyError: - return settings.DEFAULT_TASK_SETTINGS + if self._config is None: + try: + task_conf = CONF.workflow.tasks[self.task_type] + except KeyError: + task_conf = {} + self._config = CONF.workflow.task_defaults.overlay(task_conf) + return self._config def is_valid(self, internal_message=None): self._refresh_actions() @@ -301,7 +397,7 @@ class BaseTask(object): e, self.task, error_text='while setting up task') # send initial confirmation email: - email_conf = self.settings.get('emails', {}).get('initial', None) + email_conf = self.config.emails.initial send_stage_email(self.task, email_conf) approve_list = [act.auto_approve for act in self.actions] @@ -316,8 +412,8 @@ class BaseTask(object): else: can_auto_approve = False - if self.settings.get('allow_auto_approve') is not None: - allow_auto_approve = self.settings.get('allow_auto_approve') + if self.config.allow_auto_approve is not None: + allow_auto_approve = self.config.allow_auto_approve else: allow_auto_approve = self.allow_auto_approve @@ -427,8 +523,7 @@ class BaseTask(object): token.delete() # Sending confirmation email: - email_conf = self.settings.get( - 'emails', {}).get('completed', None) + email_conf = self.config.emails.completed send_stage_email(self.task, email_conf) def cancel(self): diff --git a/adjutant/tasks/v1/manager.py b/adjutant/tasks/v1/manager.py index 2693eb3..fb4a925 100644 --- a/adjutant/tasks/v1/manager.py +++ b/adjutant/tasks/v1/manager.py @@ -16,9 +16,8 @@ from logging import getLogger from six import string_types -from django.conf import settings - from adjutant import exceptions +from adjutant import tasks from adjutant.tasks.models import Task from adjutant.tasks.v1.base import BaseTask @@ -35,9 +34,9 @@ class TaskManager(object): otherwise if it is a valid task class, will return it. """ try: - return settings.TASK_CLASSES[task_type] + return tasks.TASK_CLASSES[task_type] except KeyError: - if task_type in settings.TASK_CLASSES.values(): + if task_type in tasks.TASK_CLASSES.values(): return task_type raise exceptions.TaskNotRegistered( "Unknown task type: '%s'" % task_type) @@ -69,7 +68,7 @@ class TaskManager(object): "Task not found with uuid of: '%s'" % task) if isinstance(task, Task): try: - return settings.TASK_CLASSES[task.task_type](task) + return tasks.TASK_CLASSES[task.task_type](task) except KeyError: # TODO(adriant): Maybe we should handle this better # for older deprecated tasks: diff --git a/adjutant/tasks/v1/models.py b/adjutant/tasks/v1/models.py index 73c3822..65247bb 100644 --- a/adjutant/tasks/v1/models.py +++ b/adjutant/tasks/v1/models.py @@ -12,15 +12,15 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf import settings - from adjutant import exceptions -from adjutant.tasks.v1.base import BaseTask +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, BaseTask): + if not issubclass(task_class, base.BaseTask): raise exceptions.InvalidTaskClass( "'%s' is not a built off the BaseTask class." % task_class.__name__ @@ -30,7 +30,11 @@ def register_task_class(task_class): if task_class.deprecated_task_types: for old_type in task_class.deprecated_task_types: data[old_type] = task_class - settings.TASK_CLASSES.update(data) + 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) diff --git a/adjutant/tasks/v1/projects.py b/adjutant/tasks/v1/projects.py index 44ea748..4d9f43f 100644 --- a/adjutant/tasks/v1/projects.py +++ b/adjutant/tasks/v1/projects.py @@ -22,3 +22,18 @@ class CreateProjectAndUser(BaseTask): default_actions = [ "NewProjectWithUserAction", ] + + email_config = { + 'initial': { + 'template': 'create_project_and_user_initial.txt', + 'subject': 'signup received' + }, + 'token': { + 'template': 'create_project_and_user_token.txt', + 'subject': 'signup approved' + }, + 'completed': { + 'template': 'create_project_and_user_completed.txt', + 'subject': 'signup completed' + } + } diff --git a/adjutant/tasks/v1/resources.py b/adjutant/tasks/v1/resources.py index 0f95e38..e9fe78e 100644 --- a/adjutant/tasks/v1/resources.py +++ b/adjutant/tasks/v1/resources.py @@ -20,3 +20,12 @@ class UpdateProjectQuotas(BaseTask): default_actions = [ "UpdateProjectQuotasAction", ] + + email_config = { + 'initial': None, + 'token': None, + 'completed': { + 'template': 'create_project_and_user_completed.txt', + 'subject': 'signup completed' + } + } diff --git a/adjutant/tasks/v1/users.py b/adjutant/tasks/v1/users.py index 4f53128..69e8a0e 100644 --- a/adjutant/tasks/v1/users.py +++ b/adjutant/tasks/v1/users.py @@ -23,6 +23,18 @@ class InviteUser(BaseTask): "NewUserAction", ] + email_config = { + 'initial': None, + 'token': { + 'template': 'invite_user_to_project_token.txt', + 'subject': 'invite_user_to_project' + }, + 'completed': { + 'template': 'invite_user_to_project_completed.txt', + 'subject': 'invite_user_to_project' + } + } + class ResetUserPassword(BaseTask): task_type = "reset_user_password" @@ -31,6 +43,18 @@ class ResetUserPassword(BaseTask): "ResetUserPasswordAction", ] + email_config = { + 'initial': None, + 'token': { + 'template': 'reset_user_password_token.txt', + 'subject': 'Password Reset for OpenStack' + }, + 'completed': { + 'template': 'reset_user_password_completed.txt', + 'subject': 'Password Reset for OpenStack' + } + } + class EditUserRoles(BaseTask): task_type = "edit_user_roles" @@ -39,6 +63,12 @@ class EditUserRoles(BaseTask): "EditUserRolesAction", ] + email_config = { + 'initial': None, + 'token': None, + 'completed': None + } + class UpdateUserEmail(BaseTask): task_type = "update_user_email" @@ -46,3 +76,26 @@ class UpdateUserEmail(BaseTask): default_actions = [ "UpdateUserEmailAction", ] + additional_actions = [ + 'SendAdditionalEmailAction', + ] + action_config = { + 'SendAdditionalEmailAction': { + 'initial': { + 'subject': 'OpenStack Email Update Requested', + 'template': 'update_user_email_started.txt', + 'email_current_user': True, + }, + }, + } + email_config = { + 'initial': None, + 'token': { + 'subject': 'update_user_email_token', + 'template': 'update_user_email_token.txt' + }, + 'completed': { + 'subject': 'Email Update Complete', + 'template': 'update_user_email_completed.txt' + } + } diff --git a/adjutant/tasks/v1/utils.py b/adjutant/tasks/v1/utils.py index 5c22843..f1f2806 100644 --- a/adjutant/tasks/v1/utils.py +++ b/adjutant/tasks/v1/utils.py @@ -15,17 +15,16 @@ from logging import getLogger from datetime import timedelta -from smtplib import SMTPException from uuid import uuid4 -from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template import loader from django.utils import timezone -from adjutant import exceptions from adjutant.api.models import Token -from adjutant.api.v1.utils import create_notification +from adjutant.notifications.utils import create_notification +from adjutant.config import CONF +from adjutant import exceptions LOG = getLogger('adjutant') @@ -44,8 +43,10 @@ def handle_task_error(e, task, error_text="while running task"): raise exceptions.TaskActionsFailed(task, internal_message=notes) -def create_token(task): - expire = timezone.now() + timedelta(hours=settings.TOKEN_EXPIRE_TIME) +def create_token(task, expiry_time=None): + if not expiry_time: + expiry_time = CONF.workflow.default_token_expiry + expire = timezone.now() + timedelta(seconds=expiry_time) uuid = uuid4().hex token = Token.objects.create( @@ -64,7 +65,7 @@ def send_stage_email(task, email_conf, token=None): text_template = loader.get_template( email_conf['template'], using='include_etc_templates') - html_template = email_conf.get('html_template', None) + html_template = email_conf['html_template'] if html_template: html_template = loader.get_template( html_template, @@ -97,15 +98,10 @@ def send_stage_email(task, email_conf, token=None): 'actions': actions } if token: - if settings.HORIZON_URL: - tokenurl = settings.HORIZON_URL - if not tokenurl.endswith('/'): - tokenurl += '/' - tokenurl += 'token/' - else: - tokenurl = settings.TOKEN_SUBMISSION_URL - if not tokenurl.endswith('/'): - tokenurl += '/' + tokenurl = CONF.workflow.horizon_url + if not tokenurl.endswith('/'): + tokenurl += '/' + tokenurl += 'token/' context.update({ 'tokenurl': tokenurl, 'token': token.token @@ -116,7 +112,7 @@ def send_stage_email(task, email_conf, token=None): # from_email is the return-path and is distinct from the # message headers - from_email = email_conf.get('from') + from_email = email_conf['from'] if not from_email: from_email = email_conf['reply'] elif "%(task_uuid)s" in from_email: @@ -145,24 +141,20 @@ def send_stage_email(task, email_conf, token=None): email.send(fail_silently=False) - except SMTPException as e: + except Exception as e: notes = { 'errors': ("Error: '%s' while emailing update for task: %s" % (e, task.uuid)) } - errors_conf = settings.TASK_SETTINGS.get( - task.task_type, settings.DEFAULT_TASK_SETTINGS).get( - 'errors', {}).get("SMTPException", {}) + notif_conf = task.config.notifications - if errors_conf: + if e.__class__.__name__ in notif_conf.safe_errors: notification = create_notification( task, notes, error=True, - engines=errors_conf.get('engines', True)) - - if errors_conf.get('notification') == "acknowledge": - notification.acknowledged = True - notification.save() + handlers=False) + notification.acknowledged = True + notification.save() else: create_notification(task, notes, error=True) diff --git a/adjutant/test_settings.py b/adjutant/test_settings.py deleted file mode 100644 index a42c783..0000000 --- a/adjutant/test_settings.py +++ /dev/null @@ -1,422 +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. - -SECRET_KEY = '+er!4olta#17a=n%uotcazg2ncpl==yjog%1*o-(cr%zys-)!' - -ADDITIONAL_APPS = [ - 'adjutant.api.v1', - 'adjutant.actions.v1', - 'adjutant.tasks.v1', -] - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'db.sqlite3' - } -} - -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'file': { - 'level': 'INFO', - 'class': 'logging.FileHandler', - 'filename': 'reg_log.log', - }, - }, - 'loggers': { - 'adjutant': { - 'handlers': ['file'], - 'level': 'INFO', - 'propagate': False, - }, - 'django': { - 'handlers': ['file'], - 'level': 'INFO', - 'propagate': False, - }, - 'keystonemiddleware': { - 'handlers': ['file'], - 'level': 'INFO', - 'propagate': False, - }, - }, -} - -EMAIL_SETTINGS = { - "EMAIL_BACKEND": "django.core.mail.backends.console.EmailBackend" -} - -# setting to control if user name and email are allowed -# to have different values. -USERNAME_IS_EMAIL = True - -# Keystone admin credentials: -KEYSTONE = { - 'username': 'admin', - 'password': 'openstack', - 'project_name': 'admin', - 'auth_url': "http://localhost:5000/v3", -} - -HORIZON_URL = 'http://localhost:8080/' - -TOKEN_EXPIRE_TIME = 24 - -ACTIVE_DELEGATE_APIS = [ - 'UserRoles', - 'UserDetail', - 'UserResetPassword', - 'UserList', - 'RoleList', - 'CreateProjectAndUser', - 'InviteUser', - 'ResetPassword', - 'EditUser', - 'UpdateEmail', - 'UpdateProjectQuotas', -] - -DEFAULT_TASK_SETTINGS = { - 'emails': { - 'token': { - 'reply': 'no-reply@example.com', - 'template': 'token.txt', - 'subject': 'Your Token' - }, - 'initial': { - 'reply': 'no-reply@example.com', - 'template': 'initial.txt', - 'subject': 'Initial Confirmation' - }, - 'completed': { - 'reply': 'no-reply@example.com', - 'template': 'completed.txt', - 'subject': 'Task completed' - } - }, - 'notifications': { - 'EmailNotification': { - 'standard': { - 'emails': ['example@example.com'], - 'reply': 'no-reply@example.com', - 'template': 'notification.txt' - }, - 'error': { - 'emails': ['example@example.com'], - 'reply': 'no-reply@example.com', - 'template': 'notification.txt' - } - } - }, -} - -DEFAULT_ACTION_SETTINGS = { - 'NewProjectAction': { - 'default_roles': { - "project_admin", "project_mod", "_member_", "heat_stack_owner" - }, - }, - 'NewProjectWithUserAction': { - 'default_roles': { - "project_admin", "project_mod", "_member_", "heat_stack_owner" - }, - }, - 'NewUserAction': { - 'allowed_roles': ['project_mod', 'project_admin', "_member_"] - }, - 'NewDefaultNetworkAction': { - 'RegionOne': { - 'DNS_NAMESERVERS': ['193.168.1.2', '193.168.1.3'], - 'SUBNET_CIDR': '192.168.1.0/24', - 'network_name': 'somenetwork', - 'public_network': '3cb50f61-5bce-4c03-96e6-8e262e12bb35', - 'router_name': 'somerouter', - 'subnet_name': 'somesubnet' - }, - }, - 'NewProjectDefaultNetworkAction': { - 'RegionOne': { - 'DNS_NAMESERVERS': ['193.168.1.2', '193.168.1.3'], - 'SUBNET_CIDR': '192.168.1.0/24', - 'network_name': 'somenetwork', - 'public_network': '3cb50f61-5bce-4c03-96e6-8e262e12bb35', - 'router_name': 'somerouter', - 'subnet_name': 'somesubnet' - }, - }, - 'SetProjectQuotaAction': { - 'regions': { - 'RegionOne': { - 'quota_size': 'small' - }, - 'RegionThree': { - 'quota_size': 'large_cinder_only' - } - }, - }, - 'SendAdditionalEmailAction': { - 'initial': { - 'reply': 'no-reply@example.com', - 'from': 'bounce+%(task_uuid)s@example.com' - }, - 'token': { - 'reply': 'no-reply@example.com', - 'from': 'bounce+%(task_uuid)s@example.com' - }, - 'completed': { - 'reply': 'no-reply@example.com', - 'from': 'bounce+%(task_uuid)s@example.com' - }, - }, - 'ResetUserPasswordAction': { - 'blacklisted_roles': ['admin'], - }, -} - -TASK_SETTINGS = { - 'invite_user_to_project': { - 'emails': { - 'initial': None, - 'token': { - 'template': 'invite_user_to_project_token.txt', - 'subject': 'invite_user_to_project' - }, - 'completed': { - 'template': 'invite_user_to_project_completed.txt', - 'subject': 'invite_user_to_project' - } - } - }, - 'create_project_and_user': { - 'emails': { - 'initial': { - 'template': 'create_project_and_user_initial.txt', - 'subject': 'signup received' - }, - 'token': { - 'template': 'create_project_and_user_token.txt', - 'subject': 'signup approved' - }, - 'completed': { - 'template': 'create_project_and_user_completed.txt', - 'subject': 'signup completed' - } - }, - 'additional_actions': [ - 'AddDefaultUsersToProjectAction', - 'NewProjectDefaultNetworkAction' - ], - 'default_region': 'RegionOne', - 'default_parent_id': None, - }, - 'reset_user_password': { - 'duplicate_policy': 'cancel', - 'emails': { - 'initial': None, - 'token': { - 'template': 'reset_user_password_token.txt', - 'subject': 'Password Reset for OpenStack' - }, - 'completed': { - 'template': 'reset_user_password_completed.txt', - 'subject': 'Password Reset for OpenStack' - } - } - }, - 'update_user_email': { - 'emails': { - 'initial': None, - 'token': { - 'subject': 'update_user_email_token', - 'template': 'update_user_email_token.txt' - }, - 'completed': { - 'subject': 'Email Update Complete', - 'template': 'update_user_email_completed.txt' - } - }, - }, - 'edit_user_roles': { - 'role_blacklist': ['admin'] - }, - 'update_quota': { - 'duplicate_policy': 'cancel', - 'days_between_autoapprove': 30, - }, -} - -ROLES_MAPPING = { - 'admin': [ - 'project_admin', 'project_mod', '_member_', 'heat_stack_owner' - ], - 'project_admin': [ - 'project_mod', '_member_', 'heat_stack_owner', 'project_admin', - ], - 'project_mod': [ - '_member_', 'heat_stack_owner', 'project_mod', - ], -} - -PROJECT_QUOTA_SIZES = { - 'small': { - 'nova': { - 'instances': 10, - 'cores': 20, - 'ram': 65536, - 'floating_ips': 10, - 'fixed_ips': 0, - 'metadata_items': 128, - 'injected_files': 5, - 'injected_file_content_bytes': 10240, - 'key_pairs': 50, - 'security_groups': 20, - 'security_group_rules': 100, - }, - 'cinder': { - 'gigabytes': 5000, - 'snapshots': 50, - 'volumes': 20, - }, - 'neutron': { - 'floatingip': 10, - 'network': 3, - 'port': 50, - 'router': 3, - 'security_group': 20, - 'security_group_rule': 100, - 'subnet': 3, - }, - "octavia": { - 'health_monitor': 5, - "listener": 1, - "load_balancer": 1, - "member": 2, - "pool": 1, - }, - }, - "medium": { - "cinder": { - "gigabytes": 10000, - "volumes": 100, - "snapshots": 300 - }, - "nova": { - "metadata_items": 128, - "injected_file_content_bytes": 10240, - "ram": 327680, - "floating_ips": 25, - "key_pairs": 50, - "instances": 50, - "security_group_rules": 400, - "injected_files": 5, - "cores": 100, - "fixed_ips": 0, - "security_groups": 50 - }, - "neutron": { - "security_group_rule": 400, - "subnet": 5, - "network": 5, - "floatingip": 25, - "security_group": 50, - "router": 5, - "port": 250 - }, - "octavia": { - 'health_monitor': 50, - "listener": 5, - "load_balancer": 5, - "member": 5, - "pool": 5, - }, - }, - "large": { - "cinder": { - "gigabytes": 50000, - "volumes": 200, - "snapshots": 600 - }, - "nova": { - "metadata_items": 128, - "injected_file_content_bytes": 10240, - "ram": 655360, - "floating_ips": 50, - "key_pairs": 50, - "instances": 100, - "security_group_rules": 800, - "injected_files": 5, - "cores": 200, - "fixed_ips": 0, - "security_groups": 100 - }, - "neutron": { - "security_group_rule": 800, - "subnet": 10, - "network": 10, - "floatingip": 50, - "security_group": 100, - "router": 10, - "port": 500 - }, - "octavia": { - 'health_monitor': 100, - "listener": 10, - "load_balancer": 10, - "member": 10, - "pool": 10, - }, - }, - "large_cinder_only": { - "cinder": { - "gigabytes": 50001, - "volumes": 200, - "snapshots": 600 - }, - }, - -} - -QUOTA_SIZES_ASC = ['small', 'medium', 'large'] - -QUOTA_SERVICES = {'*': ['cinder', 'neutron', 'nova']} - -SHOW_ACTION_ENDPOINTS = True - -TOKEN_CACHE_TIME = 60 - -conf_dict = { - "DEBUG": True, - "SECRET_KEY": SECRET_KEY, - "ADDITIONAL_APPS": ADDITIONAL_APPS, - "DATABASES": DATABASES, - "LOGGING": LOGGING, - "EMAIL_SETTINGS": EMAIL_SETTINGS, - "USERNAME_IS_EMAIL": USERNAME_IS_EMAIL, - "KEYSTONE": KEYSTONE, - "ACTIVE_DELEGATE_APIS": ACTIVE_DELEGATE_APIS, - "DEFAULT_TASK_SETTINGS": DEFAULT_TASK_SETTINGS, - "TASK_SETTINGS": TASK_SETTINGS, - "DEFAULT_ACTION_SETTINGS": DEFAULT_ACTION_SETTINGS, - "HORIZON_URL": HORIZON_URL, - "TOKEN_EXPIRE_TIME": TOKEN_EXPIRE_TIME, - "ROLES_MAPPING": ROLES_MAPPING, - "PROJECT_QUOTA_SIZES": PROJECT_QUOTA_SIZES, - "SHOW_ACTION_ENDPOINTS": SHOW_ACTION_ENDPOINTS, - "QUOTA_SIZES_ASC": QUOTA_SIZES_ASC, - "TOKEN_CACHE_TIME": TOKEN_CACHE_TIME, - "QUOTA_SERVICES": QUOTA_SERVICES, -} diff --git a/adjutant/utils.py b/adjutant/utils.py deleted file mode 100644 index 7592550..0000000 --- a/adjutant/utils.py +++ /dev/null @@ -1,48 +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 copy import deepcopy - - -def dict_merge(a, b): - """ - Recursively merges two dicts. - If both a and b have a key who's value is a dict then dict_merge is called - on both values and the result stored in the returned dictionary. - B is the override. - """ - if not isinstance(b, dict): - return b - result = deepcopy(a) - for k, v in b.items(): - if k in result and isinstance(result[k], dict): - result[k] = dict_merge(result[k], v) - else: - result[k] = deepcopy(v) - return result - - -def setup_task_settings(task_defaults, action_defaults, task_settings): - """ - Cascading merge of the default settings, and the - settings for each task_type. - """ - new_task_settings = {} - for task, settings in task_settings.items(): - task_setting = deepcopy(task_defaults) - task_setting['action_settings'] = deepcopy(action_defaults) - new_task_settings[task] = dict_merge(task_setting, settings) - - return new_task_settings diff --git a/adjutant/wsgi.py b/adjutant/wsgi.py index e554373..f84d207 100644 --- a/adjutant/wsgi.py +++ b/adjutant/wsgi.py @@ -22,10 +22,13 @@ https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ """ import os + from django.core.wsgi import get_wsgi_application -from django.conf import settings + from keystonemiddleware.auth_token import AuthProtocol +from adjutant.config import CONF + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "adjutant.settings") @@ -35,14 +38,14 @@ application = get_wsgi_application() # the Keystone Auth Middleware. conf = { "auth_plugin": "password", - 'username': settings.KEYSTONE['username'], - 'password': settings.KEYSTONE['password'], - 'project_name': settings.KEYSTONE['project_name'], - "project_domain_id": settings.KEYSTONE.get('domain_id', "default"), - "user_domain_id": settings.KEYSTONE.get('domain_id', "default"), - "auth_url": settings.KEYSTONE['auth_url'], + 'username': CONF.identity.auth.username, + 'password': CONF.identity.auth.password, + 'project_name': CONF.identity.auth.project_name, + "project_domain_id": CONF.identity.auth.project_domain_id, + "user_domain_id": CONF.identity.auth.user_domain_id, + "auth_url": CONF.identity.auth.auth_url, 'delay_auth_decision': True, 'include_service_catalog': False, - 'token_cache_time': settings.TOKEN_CACHE_TIME, + 'token_cache_time': CONF.identity.token_cache_time, } application = AuthProtocol(application, conf) diff --git a/api-ref/source/delegate-apis.inc b/api-ref/source/delegate-apis.inc index b3e8336..a4221ca 100644 --- a/api-ref/source/delegate-apis.inc +++ b/api-ref/source/delegate-apis.inc @@ -34,7 +34,7 @@ Response Example "name": "demo", "roles": [ "project_admin", - "__member__" + "_member_" ], "status": "Active" } @@ -64,7 +64,7 @@ Request Example curl -H "X-Auth-Token: $NOS_TOKEN" http://0.0.0.0:5050/v1/openstack/users \ -H 'Content-Type: application/json' \ - -d '{"roles": ["_member_"], "email": "new@example.com"}' + -d '{"roles": ["member"], "email": "new@example.com"}' Response Example ----------------- @@ -216,7 +216,7 @@ Response Example "links": { "self": "http://identity/v3/roles/9fe2ff9ee4384b1894a90878d3e92bab" }, - "name": "_member_" + "name": "member" }, ] } diff --git a/conf/conf.yaml b/conf/conf.yaml deleted file mode 100644 index 9d43c2c..0000000 --- a/conf/conf.yaml +++ /dev/null @@ -1,414 +0,0 @@ -# General settings -SECRET_KEY: '+er!!4olta#17a=n%uotcazg2ncpl==yjog%1*o-(cr%zys-)!' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG: True -ALLOWED_HOSTS: - - "*" - -ADDITIONAL_APPS: - - adjutant.api.v1 - - adjutant.tasks.v1 - - adjutant.actions.v1 - -DATABASES: - default: - ENGINE: django.db.backends.sqlite3 - NAME: db.sqlite3 - -LOGGING: - version: 1 - disable_existing_loggers: False - handlers: - file: - level: INFO - class: logging.FileHandler - filename: reg_log.log - loggers: - adjutant: - handlers: - - file - level: INFO - propagate: False - django: - handlers: - - file - level: INFO - propagate: False - keystonemiddleware: - handlers: - - file - level: INFO - propagate: False - -EMAIL_SETTINGS: - EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend - -# setting to control if user name and email are allowed -# to have different values. -USERNAME_IS_EMAIL: True - -# Keystone config -KEYSTONE: - username: admin - password: openstack - project_name: admin - # MUST BE V3 API: - auth_url: http://localhost/identity/v3 - domain_id: default - can_edit_users: True - -HORIZON_URL: http://localhost:8080/ - -# time for the token to expire in hours -TOKEN_EXPIRE_TIME: 24 - -ACTIVE_DELEGATE_APIS: - - UserRoles - - UserDetail - - UserResetPassword - - UserList - - RoleList - - SignUp - - UserUpdateEmail - - UpdateProjectQuotas - -DEFAULT_TASK_SETTINGS: - emails: - initial: - subject: Initial Confirmation - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - template: initial.txt - # html_template: initial.txt - # If the related actions 'can' send a token, - # this field should here. - token: - subject: Your Token - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - template: token.txt - # html_template: token.txt - completed: - subject: Task completed - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - template: completed.txt - # html_template: completed.txt - notifications: - EmailNotification: - standard: - emails: - - example@example.com - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - template: notification.txt - # html_template: completed.txt - error: - emails: - - example@example.com - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - template: notification.txt - # html_template: completed.txt - -# Default Action settings: -# These can be overridden at a per task level below in the -# task settings so that multiple tasks can use the same actions -# slightly differently. -# -# TASK_SETTINGS: -# : -# .... -# .... -# action_settings: -# : -# .... -DEFAULT_ACTION_SETTINGS: - NewProjectAction: - default_roles: - - project_admin - - project_mod - - heat_stack_owner - - _member_ - NewProjectWithUserAction: - default_roles: - - project_admin - - project_mod - - heat_stack_owner - - _member_ - NewUserAction: - allowed_roles: - - project_admin - - project_mod - - heat_stack_owner - - _member_ - ResetUserPasswordAction: - blacklisted_roles: - - admin - NewDefaultNetworkAction: - RegionOne: - network_name: default_network - subnet_name: default_subnet - router_name: default_router - public_network: 3cb50d61-5bce-4c03-96e6-8e262e12bb35 - DNS_NAMESERVERS: - - 193.168.1.2 - - 193.168.1.3 - SUBNET_CIDR: 192.168.1.0/24 - NewProjectDefaultNetworkAction: - RegionOne: - network_name: default_network - subnet_name: default_subnet - router_name: default_router - public_network: 3cb50d61-5bce-4c03-96e6-8e262e12bb35 - DNS_NAMESERVERS: - - 193.168.1.2 - - 193.168.1.3 - SUBNET_CIDR: 192.168.1.0/24 - AddDefaultUsersToProjectAction: - default_users: - - admin - default_roles: - - admin - SetProjectQuotaAction: - regions: - RegionOne: - quota_size: small - UpdateProjectQuotasAction: - days_between_autoapprove: 30 - SendAdditionalEmailAction: - initial: - email_current_user: False - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - subject: "Openstack Email Notification" - template: null - token: - email_current_user: False - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - subject: "Openstack Email Notification" - template: null - completed: - email_current_user: False - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - subject: "Openstack Email Notification" - template: null - # A null template will cause the email not to send - # Also emails to the given roles on the project - # email_roles: - # - project_admin - # Or sends to an email set in the task cache - # email_task_cache: True - # Or sends to an arbitrary admin email - # email_additional_addresses: - # - admin@example.org - -# These are cascading overrides for the default settings: -TASK_SETTINGS: - create_project_and_user: - # Additional actions for task - # These will run after the default actions, in the given order. - additional_actions: - - NewProjectDefaultNetworkAction - - SetProjectQuotaAction - emails: - initial: - subject: Your OpenStack signup has been received - template: create_project_and_user_initial.txt - token: - subject: Your OpenStack signup has been approved - template: create_project_and_user_token.txt - completed: - subject: Your OpenStack signup has been completed - template: create_project_and_user_completed.txt - notifications: - EmailNotification: - standard: - emails: - - signups@example.com - error: - emails: - - signups@example.com - default_region: RegionOne - # If 'None' (null in yaml) will default to domain as parent. - # If domain isn't set explicity will service user domain (see KEYSTONE). - default_parent_id: null - invite_user_to_project: - duplicate_policy: cancel - emails: - # To not send this email set the value to null - initial: null - token: - subject: Invitation to an OpenStack project - template: invite_user_to_project_token.txt - completed: - subject: Invitation Completed - template: invite_user_to_project_completed.txt - errors: - SMTPException: - notification: acknowledge - engines: False - reset_user_password: - duplicate_policy: cancel - emails: - initial: null - token: - subject: Password Reset for OpenStack - template: reset_user_password_token.txt - completed: - subject: Password Reset Completed - template: reset_user_password_completed.txt - edit_user_roles: - duplicate_policy: cancel - emails: - initial: null - token: null - role_blacklist: - - admin - edit_roles: - duplicate_policy: cancel - emails: - initial: null - token: null - update_user_email: - duplicate_policy: cancel - additional_actions: - - SendAdditionalEmailAction - emails: - initial: null - token: - subject: Confirm OpenStack Email Update - template: update_user_email_token.txt - completed: - subject: OpenStack Email Updated - template: update_user_email_completed.txt - action_settings: - SendAdditionalEmailAction: - initial: - subject: OpenStack Email Update Requested - template: update_user_email_started.txt - email_current_user: True - update_quota: - duplicate_policy: cancel - size_difference_threshold: 0.1 - emails: - initial: null - token: null - completed: - subject: Openstack Quota updated - template: update_quota_completed.txt - -# mapping between roles and managable roles -ROLES_MAPPING: - admin: - - project_admin - - project_mod - - heat_stack_owner - - _member_ - project_admin: - - project_admin - - project_mod - - heat_stack_owner - - _member_ - project_mod: - - project_mod - - heat_stack_owner - - _member_ - -PROJECT_QUOTA_SIZES: - small: - nova: - instances: 10 - cores: 20 - ram: 65536 - floating_ips: 10 - fixed_ips: 0 - metadata_items: 128 - injected_files: 5 - injected_file_content_bytes: 10240 - key_pairs: 50 - security_groups: 20 - security_group_rules: 100 - cinder: - gigabytes: 5000 - snapshots: 50 - volumes: 20 - neutron: - floatingip: 10 - network: 3 - port: 50 - router: 3 - security_group: 20 - security_group_rule: 100 - subnet: 3 - medium: - cinder: - gigabytes: 10000 - volumes: 100 - snapshots: 300 - nova: - metadata_items: 128 - injected_file_content_bytes: 10240 - ram: 327680 - floating_ips: 25 - key_pairs: 50 - instances: 50 - security_group_rules: 400 - injected_files: 5 - cores: 100 - fixed_ips: 0 - security_groups: 50 - neutron: - security_group_rule: 400 - subnet: 5 - network: 5 - floatingip: 25 - security_group: 50 - router: 5 - port: 250 - large: - cinder: - gigabytes: 50000 - volumes: 200 - snapshots: 600 - nova: - metadata_items: 128 - injected_file_content_bytes: 10240 - ram: 655360 - floating_ips: 50 - key_pairs: 50 - instances: 100 - security_group_rules: 800 - injected_files: 5 - cores: 200 - fixed_ips: 0 - security_groups: 100 - neutron: - security_group_rule: 800 - subnet: 10 - network: 10 - floatingip: 50 - security_group: 100 - router: 10 - port: 500 - -# Time in seconds to cache token from Keystone -TOKEN_CACHE_TIME: 600 - -# Ordered list of quota sizes from smallest to biggest -QUOTA_SIZES_ASC: - - small - - medium - - large - -# Services to check through the quotas for -QUOTA_SERVICES: - "*": - - nova - - neutron - - cinder - # Additonal Quota Service - # - octavia diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 4a73146..5a15f01 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -1,264 +1,84 @@ Configuring Adjutant ==================== -.. highlight:: yaml - Adjutant is designed to be highly configurable for various needs. The goal of Adjutant is to provide a variety of common tasks and actions that can -be easily extended or changed based upon the needs of your OpenStack. +be easily extended or changed based upon the needs of your OpenStack cluster. -The default Adjutant configuration is found in conf/conf.yaml, and but will -be overridden if a file is placed at ``/etc/adjutant/conf.yaml``. +For configuration Adjutant uses a library called CONFspirator to define and +register our config values. This makes the app better at processing defaults +and checking the validity of the config. -The first part of the configuration file contains standard Django settings. +An example Adjutant config file is found in conf/adjutant.yaml, and a new one +can be generated by running:: -.. code-block:: yaml + tox -e venv -- adjutant-api exampleconfig --output-file /etc/adjutant/adjutant.yaml - SECRET_KEY: +With ``--output-file`` controlling where the file goes. - ALLOWED_HOSTS: - - "*" +This example file should be your starting point for configuring the service, +and your core source of documentation for what each config does. - ADDITIONAL_APPS: - - adjutant.api.v1 - - adjutant.tasks.v1 - - adjutant.actions.v1 +Adjutant will read the file from ``/etc/adjutant/adjutant.yaml`` or if the +environment variable ``ADJUTANT_CONFIG_FILE`` is set, will look for the file +in the specified location. - DATABASES: - default: - ENGINE: django.db.backends.sqlite3 - NAME: db.sqlite3 +Configuration options ++++++++++++++++++++++ - LOGGING: - ... - - EMAIL_SETTINGS: - EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend - - -If you have any plugins, ensure that they are also added to -**ADDITIONAL_APPS**. - -API Settings +Django group ------------ -The next part of the confirguration file contains a number of settings -for all APIs. +The first part of the configuration file contains standard Django settings, +and for the most part the generated example config will explain all the +options. -.. code-block:: yaml +Identity group +-------------- - USERNAME_IS_EMAIL: True +Are the configs for how Adjutant interacts with Keystone, with the important +ones being as follows: - KEYSTONE: - username: - password: - project_name: - auth_url: http://localhost:5000/v3 - domain_id: default +**adjutant.identity.username_is_email** impacts account creation, and email +modification actions. In the case that it is true, any task passing a username +and email pair, the username will be ignored. This also impacts where emails +are sent to. - HORIZON_URL: http://192.168.122.160:8080/token/ +**adjutant.identity.auth** Are the credentials that Adjutant uses to talk to +Keystone, and the various other OpenStack services. - # default time for the token to expire in hours - TOKEN_EXPIRE_TIME: 24 +**adjutant.identity.role_mapping** defines which roles can modify other roles. +In the default configuration a user who has the role project_mod will not be +able to modify any of the roles for a user with the project_admin role. - ROLES_MAPPING: - admin: - - project_admin - - project_mod - - _member_ - project_admin: - - project_admin - - project_mod - - _member_ - project_mod: - - project_mod - - heat_stack_owner - - _member_ +API group +--------- - ACTIVE_DELEGATE_APIS: - - UserRoles - - UserDetail - - UserResetPassword - - UserList - - RoleList - - SignUp - - UserUpdateEmail +Controls which DelegateAPIs are enabled, and what some of their configuration +may be. -**USERNAME_IS_EMAIL** impacts account creation, and email modification actions. -In the case that it is true, any task passing a username and email pair, the -username will be ignored. This also impacts where emails are sent to. +Notifications group +------------------- -The keystone settings must be for a user with administrative privileges, -and must use the Keystone V3 API endpoint. +Default settings around what notifications should do during the task workflows. -If you have Horizon configured with adjutant-api **TOKEN_SUBMISSION_URL** -should point to that. +Workflow group +-------------- -**ROLES_MAPPING** defines which roles can modify other roles. In the default -configuration a user who has the role project_mod will not be able to -modify any of the roles for a user with the project_admin role. - -**ACTIVE_DELEGATE_APIS** defines all in use DelegateAPIs, including those that -are from plugins must be included in this list. If a task is removed from this -list its endpoint will not be accessable however users who have started tasks -will still be able submit them. - -Standard Task Settings ----------------------- - -The DelegateAPIs are built around the task layer, and the tasks themselves -have their own configuration. - -.. code-block:: yaml - - DEFAULT_TASK_SETTINGS: - duplicate_policy: null - emails: - initial: - subject: Initial Confirmation - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - template: initial.txt - # html_template: initial.txt - token: - - completed: - notifications: - EmailNotification: - standard: - emails: - - example@example.com - reply: no-reply@example.com - from: bounce+%(task_uuid)s@example.com - template: notification.txt - # html_template: completed.txt - error: - -**DEFAULT_TASK_SETTINGS** Represents the default settings for all task -unless otherwise overridden for individual tasks in the TASK_SETTINGS -configuration, these are cascading overrides. Two additional options -are available, overriding the default actions or adding in additional -actions. These will run in the order specified. - -.. code-block:: yaml - - TASK_SETTINGS: - create_project_and_user: - default_actions: - - NewProjectAction - invite_user_to_project: - additional_actions: - - SendAdditionalEmailAction - - -By default duplicate tasks will be marked as invalid, however the duplicate -policy can be set to 'cancel' to cancel duplicates and start a new class. - -You can also here at the task settings layer ensure that the task is never auto -approved by it's underlying actions. - -.. code-block:: yaml - - TASK_SETTINGS: - update_quota: - allow_auto_approve: False - - -Email Settings -~~~~~~~~~~~~~~ -The ``initial`` email will be sent after the user makes the request, the -``token`` email will be sent after approval steps are run, and the -``completed`` email will be sent after the token is submitted. - -The emails will be sent to the current user, however this can be changed at -the action level with the ``get_email()`` function. - -Notification Settings -~~~~~~~~~~~~~~~~~~~~~ -The type of notifications can be defined here for both standard notifications -and error notifications:: - - notifications: - EmailNotification: - standard: - emails: - - example@example.com - reply: no-reply@example.com - template: notification.txt - error: - emails: - - errors@example.com - reply: no-reply@example.com - template: notification.txt - : - -Currently EmailNotification is the only available notification engine however -new engines can be added through plugins and may have different settings. - - -Action Settings ---------------- +**adjutant.workflow.task_defaults** Represents the default settings for all +tasks unless otherwise overridden for individual tasks in +``adjutant.workflow.tasks``. Default action settings. -Actions will each have their own specific settings, dependent on what they -are for. The standard settings for a number of default actions are below: +**adjutant.workflow.action_defaults** Are the default settings for each action +and can be overriden on a per task basis via +``adjutant.workflow.tasks..actions``. -An action can have it's settings overridden in the settings for it's task. -This will only effect when the action is called through that specific task -Overriding action settings for a specific task. -Email Templates ---------------- +Email and notification templates +++++++++++++++++++++++++++++++++ Additional templates can be placed in ``/etc/adjutant/templates/`` and will be loaded in automatically. A plain text template and an HTML template can be specified separately. The context for this will include the task object and a dictionary containing the action objects. - -Additional Emails ------------------- - -The SendAdditionalEmailAction is designed to be added in at configuration -for relevant tasks. It's templates are also passed a context dictionary with -the task and actions available. By default the template is null and the email -will not send. - -The settings for this action should be defined within the action_settings -for its related task. - -.. code-block:: yaml - - additional_actions: - - SendAdditionalEmailAction - action_settings: - SendAdditionalEmailAction: - initial: - subject: OpenStack Email Update Requested - template: update_user_email_started.txt - email_current_user: True - -The additional email action can also send to a subset of people. - -The user who made the request can be emailed with:: - - email_current_user: True - -Or the email can be sent to everyone who has a certain role on the project. -(Multiple roles can also be specified) - -.. code-block:: yaml - - email_roles: - - project_admin - -Or an email can be sent to a specified address in the task cache -(key: ``additional_emails``) :: - - email_in_task_cache: True - -Or sent to an arbitrary administrative email address(es):: - - email_additional_addresses: - - admin@example.org - -This can be useful in the case of large project affecting actions. diff --git a/doc/source/plugins.rst b/doc/source/plugins.rst index c844c14..3eeeb18 100644 --- a/doc/source/plugins.rst +++ b/doc/source/plugins.rst @@ -17,11 +17,11 @@ Building DelegateAPIs New DelegateAPIs should inherit from adjutant.api.v1.base.BaseDelegateAPI can be registered as such:: - from adjutant.api.v1.models import register_delegate_api_class, + from adjutant.plugins import register_plugin_delegate_api, from myplugin import apis - register_delegate_api_class(r'^my-plugin/some-action/?$', apis.MyAPIView) + 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. @@ -55,9 +55,9 @@ Building Tasks Tasks must be derived from adjutant.tasks.v1.base.BaseTask and can be registered as such:: - from adjutant.tasks.v1.models import register_task_class + from adjutant.plugins import register_plugin_task - register_task_class(MyPluginTask) + register_plugin_task(MyPluginTask) Examples of tasks can be found in `adjutant.tasks.v1` @@ -77,9 +77,9 @@ Building Actions Actions must be derived from adjutant.actions.v1.base.BaseAction and are registered alongside their serializer:: - from adjutant.actions.v1.models import register_action_class + from adjutant.plugins import register_plugin_action - register_action_class(MyCustomAction, MyCustomActionSerializer) + register_action_class(MyCustomAction, MyCustomActionSerializer) Serializers can inherit from either rest_framework.serializers.Serializer, or the current serializers in adjutant.actions.v1.serializers. @@ -144,34 +144,54 @@ Example:: value_1 = serializers.CharField() ****************************** -Building Notification Engines +Building Notification Handlers ****************************** -Notification Engines can also be added through a plugin:: +Notification Handlers can also be added through a plugin:: - from adjutant.notifcations.models import NotificationEngine - from django.conf import settings + from adjutant.notifications.models import BaseNotificationHandler + from adjutant.plugins import register_notification_handler - class NewNotificationEngine(NotificationEngine): + class NewNotificationHandler(BaseNotificationHandler): + + settings_group = groups.DynamicNameConfigGroup( + children=[ + fields.BoolConfig( + "do_this_thing", + help_text="Should we do the thing?", + default=False, + ), + ] + ) def _notify(self, task, notification): - if self.conf.get('do_this_thing'): + conf = self.settings(task, notification) + if conf.do_this_thing: # do something with the task and notification - settings.NOTIFICATION_ENGINES.update( - {'NewNotificationEngine': NewNotificationEngine}) + register_notification_handler(NewNotificationHandler) -They should then be referred to in conf.yaml:: +You then need to setup the handler to be used either by default for a task, +or for a specific task:: - TASK_SETTINGS: - signup: + workflow: + task_defaults: notifications: - NewNotificationEngine: - standard: - do_this_thing: True - error: - do_this_thing: False + standard_handlers: + - NewNotificationHandler + standard_handler_settings: + NewNotificationHandler: + do_this_thing: true + tasks: + some_task: + notifications: + standard_handlers: null + error_handlers: + - NewNotificationHandler + error_handler_settings: + NewNotificationHandler: + do_this_thing: true ************************************************* diff --git a/etc/adjutant.yaml b/etc/adjutant.yaml new file mode 100644 index 0000000..9c576f1 --- /dev/null +++ b/etc/adjutant.yaml @@ -0,0 +1,793 @@ +django: + # String + # The Django secret key. + secret_key: Do not ever use this awful secret in prod!!!! + # Boolean + # Django debug mode is turned on. + debug: False + # List + # The Django allowed hosts + allowed_hosts: + - '*' + # List + # A list of additional django apps. + # additional_apps: + # Dict + # Django databases config. + databases: + default: + ATOMIC_REQUESTS: false + AUTOCOMMIT: true + CONN_MAX_AGE: 0 + ENGINE: django.db.backends.sqlite3 + HOST: '' + NAME: db.sqlite3 + OPTIONS: {} + PASSWORD: '' + PORT: '' + TEST: + CHARSET: null + COLLATION: null + MIRROR: null + NAME: null + TIME_ZONE: null + USER: '' + # Dict + # A full override of the Django logging config for more customised logging. + # logging: + # String + # The name and location of the Adjutant log file, superceded by 'adjutant.django.logging'. + log_file: adjutant.log + email: + # String + # Django email backend to use. + email_backend: django.core.mail.backends.console.EmailBackend + # Integer + # Email backend timeout. + # timeout: + # Hostname + # Email backend server location. + # host: + # Port + # Email backend server port. + # port: + # String + # Email backend user. + # host_user: + # String + # Email backend user password. + # host_password: + # Boolean + # Whether to use TLS for email. Mutually exclusive with 'use_ssl'. + use_tls: False + # Boolean + # Whether to use SSL for email. Mutually exclusive with 'use_tls'. + use_ssl: False + +identity: + # Integer + # Cache time for Keystone Tokens in the Keystone Middleware. + token_cache_time: -1 + # Boolean + # Is Adjutant allowed (or able) to edit users in Keystone. + can_edit_users: True + # Boolean + # Should Adjutant assume and treat all usernames as emails. + username_is_email: True + # Dict + # A mapping from held role to roles it is allowed to manage. + role_mapping: + admin: + - project_admin + - project_mod + - heat_stack_owner + - member + project_admin: + - project_admin + - project_mod + - heat_stack_owner + - member + project_mod: + - project_mod + - heat_stack_owner + - member + auth: + # String + # Username for Adjutant Keystone admin user. + # username: + # String + # Password for Adjutant Keystone admin user. + # password: + # String + # Project name for Adjutant Keystone admin user. + # project_name: + # String + # Project domain id for Adjutant Keystone admin user. + project_domain_id: default + # String + # User domain id for Adjutant Keystone admin user. + user_domain_id: default + # URI + # Keystone auth url that Adjutant will use. + # auth_url: + +api: + # List + # List of Active Delegate APIs. + active_delegate_apis: + - UserRoles + - UserDetail + - UserResetPassword + - UserList + - RoleList + delegate_apis: + CreateProjectAndUser: + # String + # Default region in which any potential resources may be created. + default_region: RegionOne + # String + # Domain in which project and users will be created. + default_domain_id: default + # String + # Parent id under which this project will be created. Default is None, and will create under default domain. + # default_parent_id: + UserList: + # List + # Users with any of these roles will be hidden from the user list. + blacklisted_roles: + - admin + UserDetail: + # List + # User with these roles will return not found. + blacklisted_roles: + - admin + UserRoles: + # List + # User with these roles will return not found. + blacklisted_roles: + - admin + SignUp: + # String + # Default region in which any potential resources may be created. + default_region: RegionOne + # String + # Domain in which project and users will be created. + default_domain_id: default + # String + # Parent id under which this project will be created. Default is None, and will create under default domain. + # default_parent_id: + +notifications: + handler_defaults: + EmailNotification: + # List + # List of email addresses to send this notification to. + # emails: + # String + # From email for this notification. + from: bounce+%(task_uuid)s@example.com + # String + # Reply-to email for this notification. + reply: no-reply@example.com + # String + # Email template for this notification. No template will cause the email not to send. + template: notification.txt + # String + # Email html template for this notification. + # html_template: + +workflow: + # URI + # The base Horizon url for Adjutant to use when producing links to Horizon. + horizon_url: http://localhost/ + # Integer + # The default token expiry time for Task tokens. + default_token_expiry: 86400 + task_defaults: + emails: + initial: + # String + # Default email subject for this stage + subject: Task Confirmation + # String + # Default from email for this stage + from: bounce+%(task_uuid)s@example.com + # String + # Default reply-to email for this stage + reply: no-reply@example.com + # String + # Default email template for this stage + template: initial.txt + # String + # Default email html template for this stage + # html_template: + token: + # String + # Default email subject for this stage + subject: Task Token + # String + # Default from email for this stage + from: bounce+%(task_uuid)s@example.com + # String + # Default reply-to email for this stage + reply: no-reply@example.com + # String + # Default email template for this stage + template: token.txt + # String + # Default email html template for this stage + # html_template: + completed: + # String + # Default email subject for this stage + subject: Task Completed + # String + # Default from email for this stage + from: bounce+%(task_uuid)s@example.com + # String + # Default reply-to email for this stage + reply: no-reply@example.com + # String + # Default email template for this stage + template: completed.txt + # String + # Default email html template for this stage + # html_template: + notifications: + # List + # Handlers to use for standard notifications. + standard_handlers: + - EmailNotification + # List + # Handlers to use for error notifications. + error_handlers: + - EmailNotification + # Dict + # Settings for standard notification handlers. + # standard_handler_config: + # Dict + # Settings for error notification handlers. + # error_handler_config: + # List + # Error types which are safe to acknowledge automatically. + safe_errors: + - SMTPException + action_defaults: + NewProjectWithUserAction: + # List + # Roles to be given on project for the user. + default_roles: + - member + - project_admin + NewProjectAction: + # List + # Roles to be given on project to the creating user. + default_roles: + - member + - project_admin + AddDefaultUsersToProjectAction: + # List + # Users which this action should add to the project. + # default_users: + # 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 + # Name to be given to the default network. + network_name: default_network + # String + # Name to be given to the default subnet. + subnet_name: default_subnet + # String + # Name to be given to the default router. + router_name: default_router + # String + # ID of the public network. + # public_network: + # String + # CIDR for the default subnet. + # subnet_cidr: + # List + # DNS nameservers for the subnet. + # dns_nameservers: + # Dict + # Specific per region config for default network. See 'region_defaults'. + # regions: + NewProjectDefaultNetworkAction: + region_defaults: + # String + # Name to be given to the default network. + network_name: default_network + # String + # Name to be given to the default subnet. + subnet_name: default_subnet + # String + # Name to be given to the default router. + router_name: default_router + # String + # ID of the public network. + # public_network: + # String + # CIDR for the default subnet. + # subnet_cidr: + # List + # DNS nameservers for the subnet. + # dns_nameservers: + # Dict + # Specific per region config for default network. See 'region_defaults'. + # regions: + SetProjectQuotaAction: + # Float + # Precentage different allowed when matching quota sizes. + size_difference_threshold: 0.1 + # Integer + # The allowed number of days between auto approved quota changes. + days_between_autoapprove: 30 + # Dict + # Which quota size to use for which region. + region_sizes: + RegionOne: small + UpdateProjectQuotasAction: + # Float + # Precentage different allowed when matching quota sizes. + size_difference_threshold: 0.1 + # Integer + # The allowed number of days between auto approved quota changes. + days_between_autoapprove: 30 + SendAdditionalEmailAction: + prepare: + # String + # Email subject for this stage. + subject: Openstack Email Notification + # String + # From email for this stage. + from: bounce+%(task_uuid)s@example.com + # String + # Reply-to email for this stage. + reply: no-reply@example.com + # String + # Email template for this stage. No template will cause the email not to send. + # template: + # String + # Email html template for this stage. No template will cause the email not to send. + # html_template: + # Boolean + # Email the user who started the task. + email_current_user: False + # Boolean + # Send to an email set in the task cache. + email_task_cache: False + # List + # Send emails to the given roles on the project. + # email_roles: + # List + # Send emails to an arbitrary admin emails + # email_additional_addresses: + approve: + # String + # Email subject for this stage. + subject: Openstack Email Notification + # String + # From email for this stage. + from: bounce+%(task_uuid)s@example.com + # String + # Reply-to email for this stage. + reply: no-reply@example.com + # String + # Email template for this stage. No template will cause the email not to send. + # template: + # String + # Email html template for this stage. No template will cause the email not to send. + # html_template: + # Boolean + # Email the user who started the task. + email_current_user: False + # Boolean + # Send to an email set in the task cache. + email_task_cache: False + # List + # Send emails to the given roles on the project. + # email_roles: + # List + # Send emails to an arbitrary admin emails + # email_additional_addresses: + submit: + # String + # Email subject for this stage. + subject: Openstack Email Notification + # String + # From email for this stage. + from: bounce+%(task_uuid)s@example.com + # String + # Reply-to email for this stage. + reply: no-reply@example.com + # String + # Email template for this stage. No template will cause the email not to send. + # template: + # String + # Email html template for this stage. No template will cause the email not to send. + # html_template: + # Boolean + # Email the user who started the task. + email_current_user: False + # Boolean + # Send to an email set in the task cache. + email_task_cache: False + # List + # Send emails to the given roles on the project. + # email_roles: + # List + # Send emails to an arbitrary admin emails + # email_additional_addresses: + tasks: + create_project_and_user: + # Boolean + # Override if this task allows auto_approval. Otherwise uses task default. + allow_auto_approve: True + # List + # Additional actions to be run as part of the task after default actions. + # additional_actions: + # Integer + # Override for the task token expiry. Otherwise uses task default. + # token_expiry: + # Dict + # Action config overrides over the action defaults. See 'adjutant.workflow.action_defaults'. + actions: + SomeCustomAction: + some_action_setting: + # Dict + # Email config overrides for this task over task defaults.See 'adjutant.workflow.emails'. + emails: + completed: + subject: signup completed + template: create_project_and_user_completed.txt + initial: + subject: signup received + template: create_project_and_user_initial.txt + token: + subject: signup approved + template: create_project_and_user_token.txt + # Dict + # Notification config overrides for this task over task defaults.See 'adjutant.workflow.notifications'. + notifications: + error_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + error_handlers: + - EmailNotification + standard_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + standard_handlers: + - EmailNotification + edit_user_roles: + # Boolean + # Override if this task allows auto_approval. Otherwise uses task default. + allow_auto_approve: True + # List + # Additional actions to be run as part of the task after default actions. + # additional_actions: + # Integer + # Override for the task token expiry. Otherwise uses task default. + # token_expiry: + # Dict + # Action config overrides over the action defaults. See 'adjutant.workflow.action_defaults'. + actions: + SomeCustomAction: + some_action_setting: + # Dict + # Email config overrides for this task over task defaults.See 'adjutant.workflow.emails'. + emails: + completed: null + initial: null + token: null + # Dict + # Notification config overrides for this task over task defaults.See 'adjutant.workflow.notifications'. + notifications: + error_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + error_handlers: + - EmailNotification + standard_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + standard_handlers: + - EmailNotification + invite_user_to_project: + # Boolean + # Override if this task allows auto_approval. Otherwise uses task default. + allow_auto_approve: True + # List + # Additional actions to be run as part of the task after default actions. + # additional_actions: + # Integer + # Override for the task token expiry. Otherwise uses task default. + # token_expiry: + # Dict + # Action config overrides over the action defaults. See 'adjutant.workflow.action_defaults'. + actions: + SomeCustomAction: + some_action_setting: + # Dict + # Email config overrides for this task over task defaults.See 'adjutant.workflow.emails'. + emails: + completed: + subject: invite_user_to_project + template: invite_user_to_project_completed.txt + initial: null + token: + subject: invite_user_to_project + template: invite_user_to_project_token.txt + # Dict + # Notification config overrides for this task over task defaults.See 'adjutant.workflow.notifications'. + notifications: + error_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + error_handlers: + - EmailNotification + standard_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + standard_handlers: + - EmailNotification + reset_user_password: + # Boolean + # Override if this task allows auto_approval. Otherwise uses task default. + allow_auto_approve: True + # List + # Additional actions to be run as part of the task after default actions. + # additional_actions: + # Integer + # Override for the task token expiry. Otherwise uses task default. + # token_expiry: + # Dict + # Action config overrides over the action defaults. See 'adjutant.workflow.action_defaults'. + actions: + SomeCustomAction: + some_action_setting: + # Dict + # Email config overrides for this task over task defaults.See 'adjutant.workflow.emails'. + emails: + completed: + subject: Password Reset for OpenStack + template: reset_user_password_completed.txt + initial: null + token: + subject: Password Reset for OpenStack + template: reset_user_password_token.txt + # Dict + # Notification config overrides for this task over task defaults.See 'adjutant.workflow.notifications'. + notifications: + error_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + error_handlers: + - EmailNotification + standard_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + standard_handlers: + - EmailNotification + update_user_email: + # Boolean + # Override if this task allows auto_approval. Otherwise uses task default. + allow_auto_approve: True + # List + # Additional actions to be run as part of the task after default actions. + additional_actions: + - SendAdditionalEmailAction + # Integer + # Override for the task token expiry. Otherwise uses task default. + # token_expiry: + # Dict + # Action config overrides over the action defaults. See 'adjutant.workflow.action_defaults'. + actions: + SendAdditionalEmailAction: + initial: + email_current_user: true + subject: OpenStack Email Update Requested + template: update_user_email_started.txt + # Dict + # Email config overrides for this task over task defaults.See 'adjutant.workflow.emails'. + emails: + completed: + subject: Email Update Complete + template: update_user_email_completed.txt + initial: null + token: + subject: update_user_email_token + template: update_user_email_token.txt + # Dict + # Notification config overrides for this task over task defaults.See 'adjutant.workflow.notifications'. + notifications: + error_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + error_handlers: + - EmailNotification + standard_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + standard_handlers: + - EmailNotification + update_quota: + # Boolean + # Override if this task allows auto_approval. Otherwise uses task default. + allow_auto_approve: True + # List + # Additional actions to be run as part of the task after default actions. + # additional_actions: + # Integer + # Override for the task token expiry. Otherwise uses task default. + # token_expiry: + # Dict + # Action config overrides over the action defaults. See 'adjutant.workflow.action_defaults'. + actions: + SomeCustomAction: + some_action_setting: + # Dict + # Email config overrides for this task over task defaults.See 'adjutant.workflow.emails'. + emails: + completed: + subject: signup completed + template: create_project_and_user_completed.txt + initial: null + token: null + # Dict + # Notification config overrides for this task over task defaults.See 'adjutant.workflow.notifications'. + notifications: + error_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + error_handlers: + - EmailNotification + standard_handler_config: + EmailNotification: + emails: + - example@example.com + reply: no-reply@example.com + standard_handlers: + - EmailNotification + +quota: + # Dict + # A definition of the quota size groups that Adjutant should use. + sizes: + large: + cinder: + gigabytes: 50000 + snapshots: 600 + volumes: 200 + neutron: + floatingip: 50 + network: 10 + port: 500 + router: 10 + security_group: 100 + security_group_rule: 800 + subnet: 10 + nova: + cores: 200 + fixed_ips: 0 + floating_ips: 50 + injected_file_content_bytes: 10240 + injected_files: 5 + instances: 100 + key_pairs: 50 + metadata_items: 128 + ram: 655360 + security_group_rules: 800 + security_groups: 100 + octavia: + health_monitor: 100 + listener: 10 + load_balancer: 10 + member: 10 + pool: 10 + medium: + cinder: + gigabytes: 10000 + snapshots: 300 + volumes: 100 + neutron: + floatingip: 25 + network: 5 + port: 250 + router: 5 + security_group: 50 + security_group_rule: 400 + subnet: 5 + nova: + cores: 100 + fixed_ips: 0 + floating_ips: 25 + injected_file_content_bytes: 10240 + injected_files: 5 + instances: 50 + key_pairs: 50 + metadata_items: 128 + ram: 327680 + security_group_rules: 400 + security_groups: 50 + octavia: + health_monitor: 50 + listener: 5 + load_balancer: 5 + member: 5 + pool: 5 + small: + cinder: + gigabytes: 5000 + snapshots: 50 + volumes: 20 + neutron: + floatingip: 10 + network: 3 + port: 50 + router: 3 + security_group: 20 + security_group_rule: 100 + subnet: 3 + nova: + cores: 20 + fixed_ips: 0 + floating_ips: 10 + injected_file_content_bytes: 10240 + injected_files: 5 + instances: 10 + key_pairs: 50 + metadata_items: 128 + ram: 65536 + security_group_rules: 100 + security_groups: 20 + octavia: + health_monitor: 5 + listener: 1 + load_balancer: 1 + member: 2 + pool: 1 + # List + # An ascending list of all the quota size names, so that Adjutant knows their relative sizes/order. + sizes_ascending: + - small + - medium + - large + # Dict + # A per region definition of what services Adjutant should manage quotas for. '*' means all or default region. + services: + '*': + - cinder + - neutron + - nova + diff --git a/releasenotes/notes/story-2004488-5468c184cc3a4691.yaml b/releasenotes/notes/story-2004488-5468c184cc3a4691.yaml new file mode 100644 index 0000000..6e81dbf --- /dev/null +++ b/releasenotes/notes/story-2004488-5468c184cc3a4691.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + Adjutant's config system is now built on top of CONFspirator, which is + a config definition library like oslo.config but tailored specifically + for some use-cases that Adjutant has. +upgrade: + - | + An almost entirely different config format will need to be used, but + there will be a better feedback from the service during startup + regarding the validity of the config. An example is present in + `etc/adjutant.yaml` but a new one can be generated by using + `tox -e venv adjutant-api exampleconfig`. diff --git a/requirements.txt b/requirements.txt index 89f7fd2..04ea756 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ python-novaclient>=14.0.0 python-octaviaclient>=1.8.0 PyYAML>=5.1 six>=1.12.0 +confspirator>=0.1.6 # Address soon: Django>=1.11,<1.12 diff --git a/test-requirements.txt b/test-requirements.txt index e5f3efe..258f549 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -8,3 +8,4 @@ coverage>=4.5.3 # Apache-2.0 doc8>=0.8.0 # Apache-2.0 mock>=3.0.0 # BSD Pygments>=2.2.0 # BSD license +flake8-bugbear>=19.3.0;python_version>='3.4' # MIT diff --git a/tox.ini b/tox.ini index 8fbdad8..b1344b1 100644 --- a/tox.ini +++ b/tox.ini @@ -50,7 +50,9 @@ deps = {[testenv:docs]deps} commands = sphinx-build -a -E -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html [flake8] -ignore = D100,D101,D102,D103,D104,D105,D200,D203,D202,D204,D205,D208,D400,D401,W503 +max-line-length = 88 +select = C,E,F,W,B,B950 +ignore = D100,D101,D102,D103,D104,D105,D200,D203,D202,D204,D205,D208,D400,D401,W503,E501 show-source = true builtins = _ exclude=.venv,venv,.env,env,.git,.tox,dist,doc,*lib/python*,*egg,releasenotes,adjutant/api/migrations/*,adjutant/actions/migrations,adjutant/tasks/migrations