├── adjutant ├── api │ ├── v1 │ │ ├── __init__.py │ │ ├── tests │ │ │ └── __init__.py │ │ ├── base.py │ │ ├── urls.py │ │ └── utils.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_auto_20160815_2249.py │ │ ├── 0004_auto_20160929_0317.py │ │ ├── 0003_task_approved_by.py │ │ ├── 0005_auto_20190610_0209.py │ │ ├── 0006_auto_20190610_0209.py │ │ ├── 0007_auto_20190610_0209.py │ │ ├── 0008_auto_20190610_0209.py │ │ └── 0001_initial.py │ ├── __init__.py │ ├── urls.py │ ├── views.py │ ├── exception_handler.py │ ├── models.py │ └── utils.py ├── common │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── utils.py │ ├── constants.py │ ├── utils.py │ └── openstack_clients.py ├── startup │ ├── __init__.py │ ├── models.py │ ├── loading.py │ ├── config.py │ └── checks.py ├── actions │ ├── v1 │ │ ├── __init__.py │ │ └── tests │ │ │ └── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_action_auto_approve.py │ │ ├── 0005_alter_action_auto_approve.py │ │ ├── 0003_auto_20190610_0205.py │ │ ├── 0004_auto_20190610_0209.py │ │ └── 0001_initial.py │ ├── __init__.py │ ├── models.py │ └── utils.py ├── commands │ ├── __init__.py │ └── management │ │ ├── __init__.py │ │ └── commands │ │ ├── __init__.py │ │ └── exampleconfig.py ├── tasks │ ├── v1 │ │ ├── __init__.py │ │ ├── resources.py │ │ ├── projects.py │ │ ├── users.py │ │ └── manager.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0001_initial.py │ │ └── 0002_auto_20190619_0613.py │ ├── templates │ │ ├── update_user_email_completed.txt │ │ ├── create_project_and_user_initial.txt │ │ ├── completed.txt │ │ ├── update_user_email_started.txt │ │ ├── reset_user_password_completed.txt │ │ ├── token.txt │ │ ├── initial.txt │ │ ├── update_quota_completed.txt │ │ ├── invite_user_to_project_completed.txt │ │ ├── reset_user_password_token.txt │ │ ├── update_user_email_token.txt │ │ ├── create_project_and_user_completed.txt │ │ ├── create_project_and_user_token.txt │ │ └── invite_user_to_project_token.txt │ ├── __init__.py │ └── models.py ├── notifications │ ├── v1 │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ └── test_notifications.py │ │ ├── base.py │ │ └── email.py │ ├── migrations │ │ └── __init__.py │ ├── __init__.py │ ├── templates │ │ └── notification.txt │ └── utils.py ├── urls.py ├── config │ ├── feature_sets.py │ ├── notification.py │ ├── api.py │ ├── __init__.py │ ├── django.py │ ├── identity.py │ └── quota.py ├── version.py ├── __init__.py ├── wsgi.py ├── core.py ├── middleware.py ├── settings.py └── exceptions.py ├── .gitreview ├── api-ref ├── _static │ └── fonts │ │ ├── glyphicons-halflings-regular.ttf │ │ └── glyphicons-halflings-regular.woff └── source │ ├── index.rst │ ├── http-status.yaml │ ├── v1-api-reference.rst │ └── parameters.yaml ├── doc ├── requirements.txt ├── Makefile └── source │ ├── quota.rst │ ├── contributing.rst │ ├── index.rst │ ├── configuration.rst │ ├── features.rst │ ├── release-notes.rst │ ├── conf.py │ ├── history.rst │ ├── guide-lines.rst │ └── development.rst ├── releasenotes ├── source │ ├── unreleased.rst │ ├── zed.rst │ ├── xena.rst │ ├── yoga.rst │ ├── 2024.1.rst │ ├── 2024.2.rst │ ├── 2025.1.rst │ ├── 2025.2.rst │ ├── ussuri.rst │ ├── wallaby.rst │ ├── victoria.rst │ └── index.rst └── notes │ ├── add-trove-quota-helper-9c5c96a941ac740c.yaml │ ├── toml-d8fba261f61313bf.yaml │ ├── remove_mysqlclient-74299a42f0d0483e.yaml │ ├── disable-quota-management-feddbaab2c304758.yaml │ ├── django-2-2-465a8bb124f1f7fe.yaml │ ├── story-2004488-5468c184cc3a4691.yaml │ ├── multiple-default-networks-5a89766d377b06d2.yaml │ ├── authed_token-6d29688676e7ee32.yaml │ ├── feature-sets-f363d132c8c377cf.yaml │ ├── story-2004489-857f37e4f6a0fe5c.yaml │ └── multiple-task-emails-0c55ee7103262f14.yaml ├── .git-blame-ignore-revs ├── .gitignore ├── .coveragerc ├── requirements.txt ├── test-requirements.txt ├── bindep.txt ├── .zuul.yaml ├── setup.py ├── package_readme.rst ├── setup.cfg ├── README.rst └── tox.ini /adjutant/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/startup/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/startup/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/actions/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/api/v1/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/common/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/tasks/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/actions/v1/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/notifications/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/tasks/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/actions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/commands/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/notifications/v1/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/notifications/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adjutant/commands/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitreview: -------------------------------------------------------------------------------- 1 | [gerrit] 2 | host=review.opendev.org 3 | port=29418 4 | project=openstack/adjutant.git 5 | -------------------------------------------------------------------------------- /api-ref/_static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openstack/adjutant/HEAD/api-ref/_static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | os-api-ref>=1.6.1 # Apache-2.0 2 | openstackdocstheme>=2.2.1 # Apache-2.0 3 | sphinx>=2.0.0,!=2.1.0 # BSD 4 | reno>=3.1.0 # Apache-2.0 5 | -------------------------------------------------------------------------------- /releasenotes/source/unreleased.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | Current Series Release Notes 3 | ============================ 4 | 5 | .. release-notes:: 6 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Reformat to Black: 2 | 2c62daf54207a4a772c2c5942e4bd88e75d7f463 3 | # Reformat after Black update: 4 | b35fdcc6aba6db0d4a22f7e1ee6a2900bdea49af -------------------------------------------------------------------------------- /api-ref/_static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openstack/adjutant/HEAD/api-ref/_static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /releasenotes/source/zed.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | Zed Series Release Notes 3 | ======================== 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/zed 7 | -------------------------------------------------------------------------------- /releasenotes/source/xena.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | Xena Series Release Notes 3 | ========================= 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/xena 7 | -------------------------------------------------------------------------------- /releasenotes/source/yoga.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | Yoga Series Release Notes 3 | ========================= 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/yoga 7 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/update_user_email_completed.txt: -------------------------------------------------------------------------------- 1 | This email is to confirm that your Openstack account email has now been changed. 2 | 3 | Kind regards, 4 | The Openstack team 5 | -------------------------------------------------------------------------------- /releasenotes/source/2024.1.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | 2024.1 Series Release Notes 3 | =========================== 4 | 5 | .. release-notes:: 6 | :branch: stable/2024.1 7 | -------------------------------------------------------------------------------- /releasenotes/source/2024.2.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | 2024.2 Series Release Notes 3 | =========================== 4 | 5 | .. release-notes:: 6 | :branch: stable/2024.2 7 | -------------------------------------------------------------------------------- /releasenotes/source/2025.1.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | 2025.1 Series Release Notes 3 | =========================== 4 | 5 | .. release-notes:: 6 | :branch: stable/2025.1 7 | -------------------------------------------------------------------------------- /releasenotes/source/2025.2.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | 2025.2 Series Release Notes 3 | =========================== 4 | 5 | .. release-notes:: 6 | :branch: stable/2025.2 7 | -------------------------------------------------------------------------------- /releasenotes/source/ussuri.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Ussuri Series Release Notes 3 | =========================== 4 | 5 | .. release-notes:: 6 | :branch: stable/ussuri 7 | -------------------------------------------------------------------------------- /releasenotes/source/wallaby.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | Wallaby Series Release Notes 3 | ============================ 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/wallaby 7 | -------------------------------------------------------------------------------- /releasenotes/source/victoria.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Victoria Series Release Notes 3 | ============================= 4 | 5 | .. release-notes:: 6 | :branch: unmaintained/victoria 7 | -------------------------------------------------------------------------------- /releasenotes/notes/add-trove-quota-helper-9c5c96a941ac740c.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Add a service quota helper for trove to facilitate the management of trove 5 | quotas via adjutant. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.sqlite3 3 | *~ 4 | *.log 5 | .coverage 6 | python_adjutant.egg-info/* 7 | dist/* 8 | build/ 9 | .tox/* 10 | env/* 11 | cover/* 12 | .vscode/ 13 | venv/ 14 | 15 | AUTHORS 16 | ChangeLog 17 | releasenotes/build -------------------------------------------------------------------------------- /adjutant/tasks/templates/create_project_and_user_initial.txt: -------------------------------------------------------------------------------- 1 | Hello, 2 | 3 | Your signup is in our system and now waiting approval. 4 | 5 | Once someone has approved it you will be emailed an update. 6 | 7 | Kind regards, 8 | The Openstack team 9 | -------------------------------------------------------------------------------- /releasenotes/notes/toml-d8fba261f61313bf.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Adjutant now optionally supports toml as a config file format, 5 | although yaml is still considered the default. Both can now be 6 | exported as example configs. 7 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/completed.txt: -------------------------------------------------------------------------------- 1 | Hello, 2 | 3 | Your task has been completed. 4 | 5 | The actions you had requested are: 6 | {% for action in actions %} 7 | - {{ action }} 8 | {% endfor %} 9 | 10 | Thank you for using our service. 11 | 12 | -------------------------------------------------------------------------------- /releasenotes/notes/remove_mysqlclient-74299a42f0d0483e.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | upgrade: 3 | - | 4 | Adjutant no longer includes mysqlclient in its requirements file. 5 | Going forward you will have to install that yourself as part of your 6 | build/deployment processes. 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | def __repr__ 5 | if self.debug: 6 | if settings.DEBUG: 7 | raise AssertionError 8 | raise NotImplementedError 9 | self.fail 10 | omit = 11 | adjutant/wsgi.py 12 | setup.py 13 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/update_user_email_started.txt: -------------------------------------------------------------------------------- 1 | Hello, 2 | 3 | We have had an email address change request from you. A confirmation email will be sent to '{{ actions.UpdateUserEmailAction.new_email }}'. 4 | 5 | If this was not you please get in touch with an administrator immediately. 6 | 7 | Kind Regards, 8 | The Openstack Team 9 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/reset_user_password_completed.txt: -------------------------------------------------------------------------------- 1 | This email is to confirm that your Openstack account password has now been changed. 2 | 3 | If you did not request this password change, please get in touch with your systems administrator to report suspicious activity and secure your account. 4 | 5 | Kind regards, 6 | The Openstack team 7 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/token.txt: -------------------------------------------------------------------------------- 1 | Hello, 2 | 3 | Your task with Adjutant is almost complete. 4 | 5 | The actions in your task are: 6 | {% for action in actions %} 7 | - {{ action }} 8 | {% endfor %} 9 | 10 | Your token to complete these actions is: 11 | {{ tokenurl }}{{ token }}/ 12 | 13 | 14 | Thank you for using our service. 15 | 16 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/initial.txt: -------------------------------------------------------------------------------- 1 | Hello, 2 | 3 | Your task is in our system and now waiting approval. 4 | 5 | The actions you have requested are: 6 | {% for action in actions %} 7 | - {{ action }} 8 | {% endfor %} 9 | 10 | Once someone has approved your task you will be emailed an update. 11 | 12 | Thank you for using our service. 13 | 14 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/update_quota_completed.txt: -------------------------------------------------------------------------------- 1 | This email is to confirm that the quota for your openstack project {{ task.keystone_user.project_name }} has been changed to {{ task.cache.size }}. 2 | 3 | If you did not do this yourself, please get in touch with your systems administrator to report suspicious activity and secure your account. 4 | 5 | Kind regards, 6 | The Openstack team 7 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/invite_user_to_project_completed.txt: -------------------------------------------------------------------------------- 1 | You have successfully joined the project '{{ task.keystone_user.project_name }}' on Openstack. 2 | 3 | You can switch projects on the dashboard via the project drop down list on the top menu. 4 | When using the command line tools or APIs, you can define the project name and ID you want to connect to. 5 | 6 | Kind regards, 7 | The Openstack team 8 | -------------------------------------------------------------------------------- /api-ref/source/index.rst: -------------------------------------------------------------------------------- 1 | ======================================================= 2 | Welcome to the Admin-Logic API Reference documentation! 3 | ======================================================= 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | v1-api-reference 9 | 10 | Adjutant is a workflow framework built with Django and Django-Rest-Framework to 11 | automate basic admin tasks within an OpenStack Cloud. 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pbr>=5.2.0 2 | 3 | Django>=4.2 4 | Babel>=2.6.0 5 | decorator>=4.4.0 6 | djangorestframework>=3.14.0 7 | jsonfield>=2.0.2 8 | keystoneauth1>=3.14.0 9 | keystonemiddleware>=6.0.0 10 | python-cinderclient>=4.1.0 11 | python-keystoneclient>=3.19.0 12 | python-neutronclient>=6.12.0 13 | python-novaclient>=14.0.0 14 | python-octaviaclient>=1.8.0 15 | python-troveclient>=6.0.1 16 | confspirator>=0.2.2 17 | importlib-metadata>=6.2.1 18 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | # The order of packages is significant, because pip processes them in the order 2 | # of appearance. Changing the order has an impact on the overall integration 3 | # process, which may cause wedges in the gate later. 4 | 5 | flake8>=3.7.7 # MIT 6 | 7 | coverage>=4.5.3 # Apache-2.0 8 | doc8>=0.8.0 # Apache-2.0 9 | Pygments>=2.2.0 # BSD license 10 | flake8-bugbear>=19.3.0;python_version>='3.4' # MIT 11 | black>=19.3b0;python_version>='3.4' # MIT 12 | -------------------------------------------------------------------------------- /adjutant/api/migrations/0002_auto_20160815_2249.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="task", 14 | name="hash_key", 15 | field=models.CharField(max_length=64, db_index=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /adjutant/actions/migrations/0002_action_auto_approve.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("actions", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="action", 14 | name="auto_approve", 15 | field=models.BooleanField(null=True, default=None), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /adjutant/api/migrations/0004_auto_20160929_0317.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0003_task_approved_by"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="task", 14 | name="project_id", 15 | field=models.CharField(max_length=64, null=True, db_index=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /adjutant/api/migrations/0003_task_approved_by.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import migrations, models 4 | import jsonfield.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0002_auto_20160815_2249"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="task", 15 | name="approved_by", 16 | field=jsonfield.fields.JSONField(default={}), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /adjutant/actions/migrations/0005_alter_action_auto_approve.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.16 on 2022-12-07 00:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("actions", "0004_auto_20190610_0209"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="action", 14 | name="auto_approve", 15 | field=models.BooleanField(default=None, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /releasenotes/notes/disable-quota-management-feddbaab2c304758.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Quota management can now be disabled for specific regions by setting 5 | ``quota.services.`` to ``[]`` in the configuration. 6 | This allows additional flexibility in what services are deployed to 7 | which region. 8 | - | 9 | Quota management can now be disabled entirely in Adjutant by setting 10 | ``quota.services`` to ``{}`` in the configuration, if quota management 11 | is not required in deployments. 12 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/reset_user_password_token.txt: -------------------------------------------------------------------------------- 1 | Hello, 2 | 3 | We have received a request to reset your Openstack password. 4 | 5 | Please click the link below to reset your password: 6 | {{ tokenurl }}{{ token }} 7 | 8 | This link will expire automatically after 24 hours. If expired, you will need to request your password to be reset again. 9 | 10 | If you did not request this password change, please get in touch with your systems administrator to report suspicious activity and secure your account. 11 | 12 | Kind regards, 13 | The Openstack team 14 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/update_user_email_token.txt: -------------------------------------------------------------------------------- 1 | Hello, 2 | 3 | We have received a request to update your Openstack email to this account. 4 | 5 | Please click the link below to update your email: 6 | {{ tokenurl }}{{ token }} 7 | 8 | This link will expire automatically after 24 hours. If expired, you will need to request another email update. 9 | 10 | If you did not request this email update, please get in touch with your systems administrator to report suspicious activity and secure your account. 11 | 12 | Kind regards, 13 | The Openstack team 14 | -------------------------------------------------------------------------------- /bindep.txt: -------------------------------------------------------------------------------- 1 | # See openstack-infra/project-config:jenkins/data/bindep-fallback.txt 2 | # This is used by bindep: sudo [apt-get | yum] install $(bindep -b) 3 | 4 | libffi-dev [platform:dpkg] 5 | libffi-devel [platform:rpm] 6 | virtual/libffi [platform:gentoo] 7 | 8 | libssl-dev [platform:dpkg] 9 | openssl-devel [platform:rpm] 10 | 11 | default-libmysqlclient-dev [platform:dpkg] 12 | mariadb-devel [platform:redhat] 13 | libmariadb-devel [platform:suse] 14 | dev-db/mariadb [platform:gentoo] 15 | 16 | python3-all-dev [platform:dpkg] 17 | python3-devel [platform:fedora] 18 | -------------------------------------------------------------------------------- /adjutant/commands/management/commands/exampleconfig.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | import confspirator 4 | 5 | from adjutant import config 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Produce an example config file for Adjutant." 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument("--output-file", default="adjutant.yaml") 13 | 14 | def handle(self, *args, **options): 15 | print("Generating example file to: '%s'" % options["output_file"]) 16 | 17 | confspirator.create_example_config(config._root_config, options["output_file"]) 18 | -------------------------------------------------------------------------------- /releasenotes/notes/django-2-2-465a8bb124f1f7fe.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | upgrade: 3 | - | 4 | Adjutant will now officially only support Python3.6 onwards, and will 5 | start introducing features only applicable to that version onwards. 6 | deprecations: 7 | - | 8 | Python2 support has been officially deprecated. The code may still be 9 | compatible for a bit longer, but we are also switching to Django 2.2, 10 | and python2 can no longer be tested. Support for Python less than 3.6 11 | has also been deprecated, but may still work for older python3 versions 12 | for a little longer, although not officially. -------------------------------------------------------------------------------- /.zuul.yaml: -------------------------------------------------------------------------------- 1 | - job: 2 | name: adjutant-black-style-check 3 | parent: tox 4 | description: | 5 | Runs black linting tests. 6 | 7 | Uses tox with the ``black`` environment. 8 | vars: 9 | tox_envlist: black_check 10 | test_setup_skip: true 11 | 12 | 13 | - project: 14 | queue: adjutant 15 | templates: 16 | - publish-openstack-docs-pti 17 | - build-release-notes-jobs-python3 18 | - openstack-cover-jobs 19 | - openstack-python3-jobs 20 | check: 21 | jobs: 22 | - adjutant-black-style-check 23 | gate: 24 | jobs: 25 | - adjutant-black-style-check 26 | -------------------------------------------------------------------------------- /adjutant/actions/migrations/0003_auto_20190610_0205.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.21 on 2019-06-10 02:05 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("actions", "0002_action_auto_approve"), 10 | ] 11 | 12 | run_before = [ 13 | ("api", "0005_auto_20190610_0209"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="action", 19 | name="state", 20 | field=models.CharField(default="default", max_length=200), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /releasenotes/notes/story-2004488-5468c184cc3a4691.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Adjutant's config system is now built on top of CONFspirator, which is 5 | a config definition library like oslo.config but tailored specifically 6 | for some use-cases that Adjutant has. 7 | upgrade: 8 | - | 9 | An almost entirely different config format will need to be used, but 10 | there will be a better feedback from the service during startup 11 | regarding the validity of the config. An example is present in 12 | `etc/adjutant.yaml` but a new one can be generated by using 13 | `tox -e venv adjutant-api exampleconfig`. 14 | -------------------------------------------------------------------------------- /adjutant/api/migrations/0005_auto_20190610_0209.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.21 on 2019-06-10 02:09 3 | 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | atomic = False 9 | 10 | dependencies = [ 11 | ("api", "0004_auto_20160929_0317"), 12 | ] 13 | 14 | operations = [ 15 | migrations.SeparateDatabaseAndState( 16 | database_operations=[ 17 | migrations.AlterModelTable( 18 | name="task", 19 | table="tasks_task", 20 | ), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /adjutant/api/migrations/0006_auto_20190610_0209.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.21 on 2019-06-10 02:09 3 | 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tasks", "0001_initial"), 11 | ("actions", "0003_auto_20190610_0205"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="token", 17 | name="task", 18 | field=models.ForeignKey( 19 | on_delete=django.db.models.deletion.CASCADE, to="tasks.Task" 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /adjutant/actions/migrations/0004_auto_20190610_0209.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.21 on 2019-06-10 02:09 3 | 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tasks", "0001_initial"), 11 | ("actions", "0003_auto_20190610_0205"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="action", 17 | name="task", 18 | field=models.ForeignKey( 19 | on_delete=django.db.models.deletion.CASCADE, to="tasks.Task" 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /adjutant/api/migrations/0007_auto_20190610_0209.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.21 on 2019-06-10 02:09 3 | 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tasks", "0001_initial"), 11 | ("actions", "0003_auto_20190610_0205"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="notification", 17 | name="task", 18 | field=models.ForeignKey( 19 | on_delete=django.db.models.deletion.CASCADE, to="tasks.Task" 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = Adjutant 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /adjutant/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | TASK_CLASSES = {} 16 | -------------------------------------------------------------------------------- /releasenotes/notes/multiple-default-networks-5a89766d377b06d2.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | The ``create_in_all_regions`` option has been added to 5 | ``NewDefaultNetworkAction`` and ``NewProjectDefaultNetworkAction``. 6 | When set to ``true``, default networks and routers will be created in 7 | all enabled regions, instead of just the default region. 8 | - | 9 | The ``create_in_regions`` option has been added to 10 | ``NewDefaultNetworkAction`` and ``NewProjectDefaultNetworkAction``. 11 | This defines a list of regions to create default networks and routers in 12 | for new sign ups, instead of the default region 13 | (or all regions, if ``create_in_all_regions`` is ``true``). 14 | -------------------------------------------------------------------------------- /adjutant/notifications/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | NOTIFICATION_HANDLERS = {} 16 | -------------------------------------------------------------------------------- /releasenotes/notes/authed_token-6d29688676e7ee32.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Tasks can now be configured to required a user to be authenticated when an 5 | Adjutant token is submitted for the final phase of a task. Actions will now 6 | be passed the ``keystone_user`` who submitted the token to do any processing 7 | on that as needed for the final step. 8 | deprecations: 9 | - | 10 | All actions now need to have ``keystone_user`` as a second optional 11 | paramater in the ``submit``function. It should have a default of ``None``, 12 | set as ``keystone_user=None``. Any existing actions without this will continue 13 | to work with a fallback, but that fallback will be removed in the W release 14 | cycle. -------------------------------------------------------------------------------- /adjutant/api/migrations/0008_auto_20190610_0209.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.21 on 2019-06-10 02:09 3 | 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0005_auto_20190610_0209"), 10 | ("tasks", "0001_initial"), 11 | ("actions", "0004_auto_20190610_0209"), 12 | ("api", "0006_auto_20190610_0209"), 13 | ("api", "0007_auto_20190610_0209"), 14 | ] 15 | 16 | operations = [ 17 | migrations.SeparateDatabaseAndState( 18 | state_operations=[ 19 | migrations.DeleteModel( 20 | name="Task", 21 | ), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from setuptools import setup 16 | 17 | setup( 18 | setup_requires=["pbr"], 19 | pbr=True, 20 | ) 21 | -------------------------------------------------------------------------------- /adjutant/urls.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from django.urls import include, re_path 16 | 17 | urlpatterns = [ 18 | re_path(r"^", include("adjutant.api.urls")), 19 | ] 20 | -------------------------------------------------------------------------------- /adjutant/config/feature_sets.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from confspirator import groups 16 | 17 | 18 | config_group = groups.ConfigGroup("feature_sets", lazy_load=True) 19 | -------------------------------------------------------------------------------- /adjutant/api/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | # Dict of DelegateAPIs and their url_paths. 16 | # - This is populated by registering DelegateAPIs. 17 | DELEGATE_API_CLASSES = {} 18 | -------------------------------------------------------------------------------- /adjutant/version.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import pbr.version 16 | 17 | version_info = pbr.version.VersionInfo("python-adjutant") 18 | version_string = version_info.version_string() 19 | -------------------------------------------------------------------------------- /releasenotes/notes/feature-sets-f363d132c8c377cf.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Feature sets have been introduced, allowing Adjutant's plugins to be 5 | registered via entrypoints, so all that is required to include them 6 | is to install them in the same environment. Then which DelegateAPIs 7 | are enabled from the feature sets is still controlled by 8 | ``adjutant.api.active_delegate_apis``. 9 | upgrade: 10 | - | 11 | Plugins that want to work with Adjutant will need to be upgraded to use 12 | the new feature set pattern for registrations of Actions, Tasks, DelegateAPIs, 13 | and NotificationHandlers. 14 | deprecations: 15 | - | 16 | Adjutant's plugin mechanism has entirely changed, making many plugins 17 | imcompatible until updated to match the new plugin mechanism. 18 | -------------------------------------------------------------------------------- /adjutant/actions/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | # Dict of actions and their serializers. 16 | # - This is populated from the various model modules at startup: 17 | ACTION_CLASSES = {} 18 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/create_project_and_user_completed.txt: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% if task.cache.user_state == "default" %} 3 | This email is to confirm that your Openstack signup has been completed and your new user and password have now been set up. 4 | {% elif task.cache.user_state == "existing" %} 5 | This email is to confirm that your Openstack signup has been completed and your existing user has access to your new project. 6 | {% elif task.cache.user_state == "disabled" %} 7 | This email is to confirm that your Openstack signup has been completed and your existing user has been re-enabled and given access to your new project. 8 | {% endif %} 9 | {% endspaceless %} 10 | 11 | If you did not do this yourself, please get in touch with your systems administrator to report suspicious activity and secure your account. 12 | 13 | Kind regards, 14 | The Openstack team 15 | -------------------------------------------------------------------------------- /adjutant/startup/loading.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import importlib_metadata as metadata 16 | 17 | 18 | def load_feature_sets(): 19 | for entry_point in metadata.entry_points(group="adjutant.feature_sets"): 20 | feature_set = entry_point.load() 21 | feature_set().load() 22 | -------------------------------------------------------------------------------- /adjutant/config/notification.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from confspirator import groups 16 | 17 | 18 | config_group = groups.ConfigGroup("notifications") 19 | 20 | handler_defaults_group = groups.ConfigGroup("handler_defaults", lazy_load=True) 21 | config_group.register_child_config(handler_defaults_group) 22 | -------------------------------------------------------------------------------- /releasenotes/source/index.rst: -------------------------------------------------------------------------------- 1 | .. 2 | Licensed under the Apache License, Version 2.0 (the "License"); you may 3 | not use this file except in compliance with the License. You may obtain 4 | a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 10 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 11 | License for the specific language governing permissions and limitations 12 | under the License. 13 | 14 | ====================== 15 | Adjutant Release Notes 16 | ====================== 17 | 18 | .. toctree:: 19 | :maxdepth: 1 20 | 21 | unreleased 22 | 2025.2 23 | 2025.1 24 | 2024.2 25 | 2024.1 26 | zed 27 | yoga 28 | xena 29 | wallaby 30 | victoria 31 | ussuri 32 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/create_project_and_user_token.txt: -------------------------------------------------------------------------------- 1 | Your OpenStack sign-up has been approved! 2 | 3 | Please follow this link to finalise access to your new OpenStack project: 4 | {{ tokenurl }}{{ token }} 5 | 6 | {% spaceless %} 7 | {% if task.cache.user_state == "disabled" %} 8 | It appears you already have a user account that was disabled. We've reactivated it, but because it may have been a while we've reset your password. After you setup your new password you will be given access to your new project and will be able to login. 9 | {% else %} 10 | You will be asked to define a password, after that you will be given access to the project and will be able to login. 11 | {% endif %} 12 | {% endspaceless %} 13 | 14 | This link expires automatically after 24 hours. If expired, you can simply go to the dashboard and request a password reset. 15 | 16 | You can find examples and documentation on using Openstack at http://docs.openstack.org/ 17 | 18 | Kind regards, 19 | The Openstack team 20 | -------------------------------------------------------------------------------- /adjutant/common/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2018 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | # Date formats to use when storing time data we expect to parse. 16 | DATE_FORMAT = "%Y-%m-%dT%H:%M:%S" 17 | DATE_FORMAT_MS = "%Y-%m-%dT%H:%M:%S.%f" 18 | EMAIL_REGEX = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" 19 | EMAIL_WITH_TEMPLATE_REGEX = r"(^[%()a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" 20 | -------------------------------------------------------------------------------- /adjutant/common/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2018 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from datetime import datetime 16 | 17 | from adjutant.common import constants 18 | 19 | 20 | def str_datetime(datetime_obj, include_ms=False): 21 | if include_ms: 22 | return datetime.strftime(datetime_obj, constants.DATE_FORMAT_MS) 23 | else: 24 | return datetime.strftime(datetime_obj, constants.DATE_FORMAT) 25 | -------------------------------------------------------------------------------- /api-ref/source/http-status.yaml: -------------------------------------------------------------------------------- 1 | 200: 2 | default: | 3 | Request was successful. 4 | task-view: | 5 | Request successful, task submitted. 6 | 202: 7 | default: | 8 | Request is accepted, but processing may take some time. 9 | 400: 10 | default: | 11 | Bad request 12 | task-view: | 13 | Invalid task data, or missing parameters. The response body will include 14 | the details of the missing or invalid parameters. 15 | 401: 16 | default: | 17 | User is unauthenticated, or X-Auth-Token has expired. 18 | 403: 19 | default: | 20 | User has the wrong roles for this operation. 21 | 404: 22 | default: | 23 | The requested resource could not be found. 24 | 405: 25 | default: | 26 | Method is not valid for this endpoint and resource. 27 | 409: 28 | default: | 29 | Conflict 30 | task-view: | 31 | Duplicate task. 32 | 500: 33 | default: | 34 | Something went wrong with the service which prevents it from 35 | fulfilling the request. 36 | 503: 37 | default: | 38 | Adjutant cannot connect to the Keystone Authentication Server. 39 | -------------------------------------------------------------------------------- /adjutant/notifications/templates/notification.txt: -------------------------------------------------------------------------------- 1 | {% if notification.error %} 1 2 | An error has occur in the adjutant service that needs attention. 3 | {% else %} 4 | There is a task that needs some attention. 5 | {% endif %} 6 | 7 | Related Task: 8 | uuid: {{ task.uuid }} 9 | keystone_user: {{ task.keystone_user|safe }} 10 | project_id: {{ task.project_id }} 11 | task_type: {{ task.task_type }} 12 | cancelled: {{ task.cancelled }} 13 | approved: {{ task.approved }} 14 | completed: {{ task.completed }} 15 | created_on: {{ task.created_on }} 16 | approved_on: {{ task.approved_on }} 17 | completed_on: {{ task.completed_on }} 18 | action_notes: 19 | {% for action, notes in task.action_notes.items %}- {{ action|safe }} 20 | {% for note in notes %} - {{ note|safe }} 21 | {% endfor %}{% endfor %} 22 | 23 | Notification details: 24 | uuid: {{ notification.uuid }} 25 | notes: 26 | {{ notification.notes|safe }} 27 | {% if task_url %} 28 | Task link: 29 | {{ task_url }} 30 | {% endif %} 31 | {% if notification.error and notification_url %} 32 | Notification link: 33 | {{ notification_url }} 34 | {% endif %} 35 | -------------------------------------------------------------------------------- /adjutant/tasks/v1/resources.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from adjutant.tasks.v1.base import BaseTask 16 | 17 | 18 | class UpdateProjectQuotas(BaseTask): 19 | task_type = "update_quota" 20 | default_actions = [ 21 | "UpdateProjectQuotasAction", 22 | ] 23 | 24 | email_config = { 25 | "initial": None, 26 | "token": None, 27 | "completed": { 28 | "template": "update_quota_completed.txt", 29 | "subject": "Quota Updated", 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /adjutant/api/urls.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from django.urls import include, re_path 16 | 17 | from adjutant.api import views 18 | from adjutant.api.views import build_version_details 19 | from adjutant.api.v1 import views as views_v1 20 | 21 | urlpatterns = [ 22 | re_path(r"^$", views.VersionView.as_view()), 23 | ] 24 | 25 | # NOTE(adriant): make this conditional once we have a v2. 26 | build_version_details("1.0", "CURRENT", relative_endpoint="v1/") 27 | urlpatterns.append(re_path(r"^v1/?$", views_v1.V1VersionEndpoint.as_view())) 28 | urlpatterns.append(re_path(r"^v1/", include("adjutant.api.v1.urls"))) 29 | -------------------------------------------------------------------------------- /package_readme.rst: -------------------------------------------------------------------------------- 1 | Adjutant is a service that sits along Keystone and allows the 2 | automation and approval of tasks normally requiring a user with an 3 | admin role. Adjutant allows defining of such tasks as part of a 4 | workflow which can either be entirely automatic, or require admin 5 | approval. The goal is to automate business logic, and augment the 6 | functionality of Keystone and other OpenStack services without getting 7 | in the way of future OpenStack features or duplicating development 8 | effort. 9 | 10 | Quick Dev Deployment 11 | ==================== 12 | 13 | To quickly deploy the service for testing you can install via pip, 14 | setup a default config file, and then run the test Django server. 15 | 16 | :: 17 | 18 | pip install adjutant 19 | 20 | Then running the service will look for a config in either 21 | **/etc/adjutant/conf.yaml** or it will default to **conf/conf.yaml** 22 | from the directory you run the command in. 23 | 24 | :: 25 | 26 | adjutant migrate 27 | adjutant runserver 28 | 29 | For now you will have to source the default conf from the github repo 30 | or the library install location itself, but we hope to add an 31 | additional commandline function which will copy and setup a basic 32 | default config in **/etc/adjutant/conf.yaml**. 33 | -------------------------------------------------------------------------------- /adjutant/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import json 16 | 17 | from confspirator.exceptions import InvalidConf 18 | 19 | 20 | def management_command(): 21 | """Entry-point for the 'adjutant' command-line admin utility.""" 22 | import os 23 | import sys 24 | 25 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "adjutant.settings") 26 | 27 | from django.core.management import execute_from_command_line 28 | 29 | try: 30 | execute_from_command_line(sys.argv) 31 | except InvalidConf as e: 32 | print("This command requires a valid config, see following errors:") 33 | print(json.dumps(e.errors["adjutant"], indent=2)) 34 | sys.exit(1) 35 | -------------------------------------------------------------------------------- /adjutant/api/v1/base.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from adjutant.api.v1.views import APIViewWithLogger 16 | 17 | from adjutant.config import CONF 18 | 19 | 20 | class BaseDelegateAPI(APIViewWithLogger): 21 | """Base Class for Adjutant's deployer configurable APIs.""" 22 | 23 | url = None 24 | 25 | config_group = None 26 | 27 | def __init__(self, *args, **kwargs): 28 | super(BaseDelegateAPI, self).__init__(*args, **kwargs) 29 | # NOTE(adriant): This is only used at registration, 30 | # so lets not expose it: 31 | self.config_group = None 32 | 33 | @property 34 | def config(self): 35 | return CONF.api.delegate_apis.get(self.__class__.__name__) 36 | -------------------------------------------------------------------------------- /adjutant/common/tests/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from django.test import TestCase 16 | from rest_framework.test import APITestCase 17 | 18 | from adjutant.common.tests import fake_clients 19 | 20 | 21 | class AdjutantTestCase(TestCase): 22 | def tearDown(self): 23 | fake_clients.identity_cache.clear() 24 | fake_clients.neutron_cache.clear() 25 | fake_clients.nova_cache.clear() 26 | fake_clients.cinder_cache.clear() 27 | 28 | 29 | class AdjutantAPITestCase(APITestCase): 30 | def tearDown(self): 31 | fake_clients.identity_cache.clear() 32 | fake_clients.neutron_cache.clear() 33 | fake_clients.nova_cache.clear() 34 | fake_clients.cinder_cache.clear() 35 | -------------------------------------------------------------------------------- /adjutant/tasks/templates/invite_user_to_project_token.txt: -------------------------------------------------------------------------------- 1 | You have been invited by {{ task.keystone_user.username }} to join the project '{{ task.keystone_user.project_name }}' on Openstack. 2 | 3 | Please click on this link to accept the invitation: 4 | {{ tokenurl }}{{ token }} 5 | 6 | {% spaceless %} 7 | {% if task.cache.user_state == "default" %} 8 | You will be asked to define a password when accepting the invitation. After that you will be given access to the project and will be able to login. 9 | {% elif task.cache.user_state == "existing" %} 10 | As an existing user you will be added to the project and do not need to provide additional information. All you have to do is click confirm. 11 | {% elif task.cache.user_state == "disabled" %} 12 | It appears you already have a user account that was disabled. We've reactivated it, but because it may have been a while we've reset your password. After you setup your new password you will be given access to the project and will be able to login. 13 | {% else %} 14 | If you are a new user you will have to define a password when accepting the invitation, while as an existing user you simply need to click confirm. 15 | {% endif %} 16 | {% endspaceless %} 17 | 18 | This link will expire automatically after 24 hours. If expired, you will need to request another one from the person who invited you. 19 | 20 | Kind regards, 21 | The Openstack team 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = python-adjutant 3 | author = OpenStack 4 | author_email = openstack-discuss@lists.openstack.org 5 | summary = An admin task workflow service for openstack. 6 | description_file = package_readme.rst 7 | description_content_type = text/x-rst; charset=UTF-8 8 | home_page = https://opendev.org/openstack/adjutant 9 | project_urls = 10 | Bug Tracker = https://storyboard.openstack.org/#!/project/openstack/adjutant 11 | Documentation = https://docs.openstack.org/adjutant/latest/ 12 | Source Code = https://opendev.org/openstack/adjutant 13 | license = Apache-2 14 | classifier = 15 | Development Status :: 5 - Production/Stable 16 | Intended Audience :: Developers 17 | Intended Audience :: System Administrators 18 | License :: OSI Approved :: Apache Software License 19 | Framework :: Django :: 3.2 20 | Programming Language :: Python :: 3 21 | Programming Language :: Python :: 3.9 22 | Programming Language :: Python :: 3.10 23 | Programming Language :: Python :: 3.11 24 | Environment :: OpenStack 25 | 26 | keywords = 27 | openstack 28 | keystone 29 | users 30 | tasks 31 | registration 32 | workflow 33 | 34 | [files] 35 | packages = 36 | adjutant 37 | 38 | [entry_points] 39 | console_scripts = 40 | adjutant-api = adjutant:management_command 41 | 42 | adjutant.feature_sets = 43 | core = adjutant.core:AdjutantCore 44 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Adjutant 3 | ======== 4 | 5 | .. image:: https://governance.openstack.org/tc/badges/adjutant.svg 6 | 7 | .. Change things from this point on 8 | 9 | A basic workflow framework built using Django and 10 | Django-Rest-Framework to help automate basic Admin tasks within an 11 | OpenStack cluster. 12 | 13 | Primarily built as user registration service that fits into the 14 | OpenStack ecosystem alongside Keystone, its purpose to fill 15 | functionality missing from Keystone. Ultimately it is just a framework 16 | with actions that are tied to an endpoint and can require certain data 17 | fields and perform actions via the OpenStack clients as well as talk 18 | to external systems as needed. 19 | 20 | Useful for automating generic admin tasks that users might request but 21 | otherwise can't do without the admin role. Also allows automating the 22 | signup and creation of new users, and allows such requests to require 23 | approval first if wanted. Due to issuing of uri+tokens for final steps 24 | of some actions, allows for a password submit/reset system as well. 25 | 26 | Documentation 27 | ============= 28 | 29 | Documentation can be found at: https://docs.openstack.org/adjutant/latest 30 | 31 | Documentation is stored in doc/, a sphinx build of the documentation 32 | can be generated with the command `tox -e docs`. 33 | 34 | An API Reference is stored in api-ref. This is also a sphinx build and 35 | can be generated with `tox -e api-ref`. 36 | -------------------------------------------------------------------------------- /adjutant/notifications/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from adjutant import notifications 16 | from adjutant.api.models import Notification 17 | 18 | 19 | def create_notification(task, notes, error=False, handlers=True): 20 | notification = Notification.objects.create(task=task, notes=notes, error=error) 21 | notification.save() 22 | 23 | if not handlers: 24 | return notification 25 | 26 | notif_conf = task.config.notifications 27 | 28 | if error: 29 | notif_handlers = notif_conf.error_handlers 30 | else: 31 | notif_handlers = notif_conf.standard_handlers 32 | 33 | if notif_handlers: 34 | for notif_handler in notif_handlers: 35 | handler = notifications.NOTIFICATION_HANDLERS[notif_handler]() 36 | handler.notify(task, notification) 37 | 38 | return notification 39 | -------------------------------------------------------------------------------- /adjutant/tasks/v1/projects.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from adjutant.tasks.v1.base import BaseTask 16 | 17 | 18 | class CreateProjectAndUser(BaseTask): 19 | duplicate_policy = "block" 20 | task_type = "create_project_and_user" 21 | deprecated_task_types = ["create_project", "signup"] 22 | default_actions = [ 23 | "NewProjectWithUserAction", 24 | ] 25 | 26 | email_config = { 27 | "initial": { 28 | "template": "create_project_and_user_initial.txt", 29 | "subject": "signup received", 30 | }, 31 | "token": { 32 | "template": "create_project_and_user_token.txt", 33 | "subject": "signup approved", 34 | }, 35 | "completed": { 36 | "template": "create_project_and_user_completed.txt", 37 | "subject": "signup completed", 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /adjutant/startup/config.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from django.apps import AppConfig 16 | 17 | from adjutant.startup import checks 18 | from adjutant.startup import loading 19 | 20 | 21 | class StartUpConfig(AppConfig): 22 | name = "adjutant.startup" 23 | 24 | def ready(self): 25 | """A pre-startup function for the api 26 | 27 | Code run here will occur before the API is up and active but after 28 | all models have been loaded. 29 | 30 | Loads feature_sets. 31 | 32 | Useful for any start up checks. 33 | """ 34 | # load all the feature sets 35 | loading.load_feature_sets() 36 | 37 | # First check that all expect DelegateAPIs are present 38 | checks.check_expected_delegate_apis() 39 | # Now check if all the actions those views expecte are present. 40 | checks.check_configured_actions() 41 | -------------------------------------------------------------------------------- /adjutant/api/v1/urls.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from django.urls import re_path 16 | from adjutant.api.v1 import views 17 | 18 | from adjutant import api 19 | from adjutant.config import CONF 20 | 21 | urlpatterns = [ 22 | re_path(r"^status/?$", views.StatusView.as_view()), 23 | re_path(r"^tasks/(?P\w+)/?$", views.TaskDetail.as_view()), 24 | re_path(r"^tasks/?$", views.TaskList.as_view()), 25 | re_path(r"^tokens/(?P\w+)", views.TokenDetail.as_view()), 26 | re_path(r"^tokens/?$", views.TokenList.as_view()), 27 | re_path(r"^notifications/(?P\w+)/?$", views.NotificationDetail.as_view()), 28 | re_path(r"^notifications/?$", views.NotificationList.as_view()), 29 | ] 30 | 31 | for active_view in CONF.api.active_delegate_apis: 32 | delegate_api = api.DELEGATE_API_CLASSES[active_view] 33 | 34 | urlpatterns.append(re_path(delegate_api.url, delegate_api.as_view())) 35 | -------------------------------------------------------------------------------- /doc/source/quota.rst: -------------------------------------------------------------------------------- 1 | #################################### 2 | Quota Management 3 | #################################### 4 | 5 | The quota API will allow users to change their quota values in any region to 6 | a number of preset quota definitions. If a user has updated their quota in 7 | the past 30 days or are attempting to jump across quota values, administrator 8 | approval is required. The exact number of days can be modified in the 9 | configuration file. 10 | 11 | Adjutant will assume that you have quotas setup for nova, cinder and neutron. 12 | Adjutant offers deployers a chance to define what services they offer in which 13 | region that require quota updates. At present Adjutant does not check the 14 | catalog for what services are available, but will in future, with the below 15 | setting acting as an override. 16 | 17 | The setting ``QUOTA_SERVICES`` can be modified to include or remove a service 18 | from quota listing and updating, and it is a mapping of region name to services 19 | with ``*`` acting as a wildcard for all regions: 20 | 21 | .. code-block:: yaml 22 | 23 | QUOTA_SERVICES: 24 | "*": 25 | - cinder 26 | - neutron 27 | - nova 28 | - octavia 29 | RegionThree: 30 | - nova 31 | - cinder 32 | - neutron 33 | 34 | A new service can be added by creating a new helper object, like 35 | ``adjutant.common.quota.QuotaManager.ServiceQuotaCinderHelper`` and adding 36 | it into the ``_quota_updaters`` class value dictionary. The key being the 37 | name that is specified in ``QUOTA_SERVICES`` and on the quota definition. 38 | -------------------------------------------------------------------------------- /adjutant/actions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | import django.utils.timezone 5 | import jsonfield.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("api", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Action", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | verbose_name="ID", 21 | serialize=False, 22 | auto_created=True, 23 | primary_key=True, 24 | ), 25 | ), 26 | ("action_name", models.CharField(max_length=200)), 27 | ("action_data", jsonfield.fields.JSONField(default={})), 28 | ("cache", jsonfield.fields.JSONField(default={})), 29 | ("state", models.CharField(default=b"default", max_length=200)), 30 | ("valid", models.BooleanField(default=False)), 31 | ("need_token", models.BooleanField(default=False)), 32 | ("order", models.IntegerField()), 33 | ("created", models.DateTimeField(default=django.utils.timezone.now)), 34 | ( 35 | "task", 36 | models.ForeignKey( 37 | on_delete=django.db.models.deletion.CASCADE, to="api.Task" 38 | ), 39 | ), 40 | ], 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /releasenotes/notes/story-2004489-857f37e4f6a0fe5c.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Adjutant now introduces two new concepts for handling the configurable 5 | APIs and workflow layer. DelegateAPIs are now the APIs which can be 6 | customised and enabled in Adjutant, and Tasks are now their own layer 7 | which can be called from the DelegateAPIs. 8 | upgrade: 9 | - | 10 | * Major changes internal classes. Many plugins likely to need reworking 11 | before using this release to match new internal changes. 12 | * The Task database model has been renamed and moved, this will require 13 | downtime for the migration to run, but should be fairly quick. 14 | deprecations: 15 | - | 16 | * TaskViews are gone, and replaced with DelegateAPIs, with much of their old 17 | logic now in the TaskManager and BaseTask. 18 | * tasks config cannot override default_actions anymore 19 | * standardized task API response codes on 202 unless task is completed from 200 20 | * Action stages renamed to 'prepare', 'approve', 'submit'. 21 | * TaskView logic and task defition moved to new task.v1 layer 22 | * UserSetPassword API has been removed because it was a duplicate of 23 | UserResetPassword. 24 | * Removed redundant ip_address value on Task model 25 | * multiple task_types have been renamed 26 | * signup to create_project_and_user 27 | * invite_user to invite_user_to_project 28 | * reset_password to reset_user_password 29 | * edit_user to edit_user_roles 30 | * update_email to update_user_email 31 | fixes: 32 | - | 33 | Reissuing task token now deletes old task tokens properly. 34 | -------------------------------------------------------------------------------- /adjutant/config/api.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from confspirator import groups 16 | from confspirator import fields 17 | 18 | 19 | config_group = groups.ConfigGroup("api") 20 | 21 | config_group.register_child_config( 22 | fields.ListConfig( 23 | "active_delegate_apis", 24 | help_text="List of Active Delegate APIs.", 25 | required=True, 26 | default=[ 27 | "UserRoles", 28 | "UserDetail", 29 | "UserResetPassword", 30 | "UserList", 31 | "RoleList", 32 | ], 33 | # NOTE(adriant): for testing purposes we include ALL default APIs 34 | test_default=[ 35 | "UserRoles", 36 | "UserDetail", 37 | "UserResetPassword", 38 | "UserList", 39 | "RoleList", 40 | "SignUp", 41 | "UpdateProjectQuotas", 42 | "CreateProjectAndUser", 43 | "InviteUser", 44 | "ResetPassword", 45 | "EditUser", 46 | "UpdateEmail", 47 | ], 48 | ) 49 | ) 50 | 51 | delegate_apis_group = groups.ConfigGroup("delegate_apis", lazy_load=True) 52 | config_group.register_child_config(delegate_apis_group) 53 | -------------------------------------------------------------------------------- /adjutant/startup/checks.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from adjutant.config import CONF 16 | from adjutant import actions, api, tasks 17 | from adjutant.exceptions import ActionNotRegistered, DelegateAPINotRegistered 18 | 19 | 20 | def check_expected_delegate_apis(): 21 | missing_delegate_apis = list( 22 | set(CONF.api.active_delegate_apis) - set(api.DELEGATE_API_CLASSES.keys()) 23 | ) 24 | 25 | if missing_delegate_apis: 26 | raise DelegateAPINotRegistered( 27 | message=( 28 | "Expected DelegateAPIs are unregistered: %s" % missing_delegate_apis 29 | ) 30 | ) 31 | 32 | 33 | def check_configured_actions(): 34 | """Check that all the expected actions have been registered.""" 35 | configured_actions = [] 36 | 37 | for task in tasks.TASK_CLASSES: 38 | task_class = tasks.TASK_CLASSES.get(task) 39 | 40 | configured_actions += task_class.default_actions 41 | configured_actions += CONF.workflow.tasks.get( 42 | task_class.task_type 43 | ).additional_actions 44 | 45 | missing_actions = list(set(configured_actions) - set(actions.ACTION_CLASSES.keys())) 46 | 47 | if missing_actions: 48 | raise ActionNotRegistered( 49 | "Configured actions are unregistered: %s" % missing_actions 50 | ) 51 | -------------------------------------------------------------------------------- /adjutant/wsgi.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | """ 16 | WSGI config for Adjutant. 17 | 18 | It exposes the WSGI callable as a module-level variable named ``application``. 19 | 20 | For more information on this file, see 21 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 22 | """ 23 | 24 | import os 25 | 26 | from django.core.wsgi import get_wsgi_application 27 | 28 | from keystonemiddleware.auth_token import AuthProtocol 29 | 30 | from adjutant.config import CONF 31 | 32 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "adjutant.settings") 33 | 34 | 35 | application = get_wsgi_application() 36 | 37 | # Here we replace the default application with one wrapped by 38 | # the Keystone Auth Middleware. 39 | conf = { 40 | "auth_plugin": "password", 41 | "username": CONF.identity.auth.username, 42 | "password": CONF.identity.auth.password, 43 | "project_name": CONF.identity.auth.project_name, 44 | "project_domain_id": CONF.identity.auth.project_domain_id, 45 | "user_domain_id": CONF.identity.auth.user_domain_id, 46 | "auth_url": CONF.identity.auth.auth_url, 47 | "interface": CONF.identity.auth.interface, 48 | "delay_auth_decision": True, 49 | "include_service_catalog": False, 50 | "token_cache_time": CONF.identity.token_cache_time, 51 | } 52 | application = AuthProtocol(application, conf) 53 | -------------------------------------------------------------------------------- /adjutant/api/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.views import APIView 2 | from rest_framework.response import Response 3 | 4 | 5 | _VERSIONS = {} 6 | 7 | 8 | def build_version_details(id, status, links=None, relative_endpoint=None): 9 | """ 10 | Build a standard version dictionary 11 | """ 12 | int_id = int(float(id)) 13 | if not relative_endpoint: 14 | relative_endpoint = "v%s/" % int_id 15 | mime_type = "application/vnd.openstack.adjutant-v%s+json" % int_id 16 | version_details = { 17 | "status": status, 18 | "id": id, 19 | "media-types": [{"base": "application/json", "type": mime_type}], 20 | "links": [], 21 | } 22 | 23 | if links: 24 | version_details["links"] = links 25 | 26 | version_details["relative_endpoint"] = relative_endpoint 27 | _VERSIONS[id] = version_details 28 | return version_details 29 | 30 | 31 | class VersionView(APIView): 32 | def get(self, request): 33 | versions = [] 34 | for version in _VERSIONS.values(): 35 | version = version.copy() 36 | rel_endpoint = version.pop("relative_endpoint") 37 | url = request.build_absolute_uri() + rel_endpoint 38 | version["links"] = version["links"] + [{"href": url, "rel": "self"}] 39 | versions.append(version) 40 | 41 | return Response({"versions": versions}, status=200) 42 | 43 | 44 | class SingleVersionView(APIView): 45 | """ 46 | A view to be added to the root of each API version detailing it's 47 | own version details. Should be subclassed and have a version set. 48 | """ 49 | 50 | def get(self, request): 51 | version = _VERSIONS.get(self.version, {}).copy() 52 | if not version: 53 | return Response({"error": "Not Found"}, status=404) 54 | 55 | version.pop("relative_endpoint") 56 | 57 | version["links"] = version["links"] + [ 58 | {"href": request.build_absolute_uri(), "rel": "self"} 59 | ] 60 | return Response({"version": version}, status=200) 61 | -------------------------------------------------------------------------------- /api-ref/source/v1-api-reference.rst: -------------------------------------------------------------------------------- 1 | ################################### 2 | Admin Logic Version 1 API reference 3 | ################################### 4 | 5 | This is the reference for Adjutant when it is using the default configuration. 6 | Different deployments may exclude certain DelegateAPIs or include their own 7 | additional ones. 8 | 9 | The core functionality of Adjutant is built around the concept of tasks and 10 | actions. 11 | 12 | Actions are both concepts in the database and code that can execute whatever 13 | logic is necessary at each stage. 14 | 15 | Tasks can bundle a number of actions and have 3 main steps. 16 | 17 | 1. A user submits a request to the specified endpoint. 18 | 2. An admin approves the request, or it is automatically approved. At this 19 | point the admin can also update invalid data inside the task. 20 | 3. If necessary a user will be emailed a token and will submit additional data 21 | (ie passwords or a confirmation) to finish the task. 22 | 23 | Depending on the task and the data provided some steps may be skipped. 24 | 25 | 26 | ************** 27 | Authentication 28 | ************** 29 | 30 | The 'X-Auth-Token' header value should be provided for authentication 31 | with a valid Keystone token. 32 | 33 | ****************** 34 | HTTP Status Codes 35 | ****************** 36 | 37 | .. rest_status_code:: success http-status.yaml 38 | 39 | - 200 40 | - 200: task-view 41 | - 202 42 | 43 | 44 | .. rest_status_code:: error http-status.yaml 45 | 46 | - 400 47 | - 401 48 | - 403 49 | - 404 50 | - 405 51 | - 409 52 | - 500 53 | - 503 54 | 55 | 56 | ****************** 57 | Service Discovery 58 | ****************** 59 | 60 | Version Discovery Endpoint 61 | ========================== 62 | 63 | .. rest_method:: GET / 64 | 65 | Unauthenticated. 66 | 67 | JSON containing details of the currently available versions (just v1 for now) 68 | 69 | Normal response code: 200 70 | 71 | Version One Details Endpoint 72 | ============================= 73 | .. rest_method:: GET /v1 74 | 75 | Unauthenticated. 76 | 77 | Details V1 version details. 78 | 79 | Normal response code: 200 80 | 81 | .. include:: admin-api.inc 82 | 83 | .. include:: delegate-apis.inc 84 | 85 | -------------------------------------------------------------------------------- /adjutant/notifications/v1/base.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from logging import getLogger 16 | 17 | from adjutant.config import CONF 18 | 19 | 20 | class BaseNotificationHandler(object): 21 | """""" 22 | 23 | config_group = None 24 | 25 | def __init__(self): 26 | self.logger = getLogger("adjutant") 27 | 28 | def config(self, task, notification): 29 | """build config based on conf and defaults 30 | 31 | Will use the Handler defaults, and the overlay them with more 32 | specific overrides from the task defaults, and the per task 33 | type config. 34 | """ 35 | try: 36 | notif_config = CONF.notifications.handler_defaults.get( 37 | self.__class__.__name__ 38 | ) 39 | except KeyError: 40 | # Handler has no config 41 | return {} 42 | 43 | task_defaults = task.config.notifications 44 | 45 | try: 46 | if notification.error: 47 | task_defaults = task_defaults.error_handler_config[ 48 | self.__class__.__name__ 49 | ] 50 | else: 51 | task_defaults = task_defaults.standard_handler_config[ 52 | self.__class__.__name__ 53 | ] 54 | except KeyError: 55 | task_defaults = {} 56 | 57 | return notif_config.overlay(task_defaults) 58 | 59 | def notify(self, task, notification): 60 | return self._notify(task, notification) 61 | 62 | def _notify(self, task, notification): 63 | raise NotImplementedError 64 | -------------------------------------------------------------------------------- /adjutant/actions/models.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from jsonfield import JSONField 16 | 17 | from django.db import models 18 | from django.utils import timezone 19 | 20 | from adjutant import actions 21 | 22 | 23 | class Action(models.Model): 24 | """ 25 | Database model representation of an action. 26 | """ 27 | 28 | action_name = models.CharField(max_length=200) 29 | action_data = JSONField(default={}) 30 | cache = JSONField(default={}) 31 | state = models.CharField(max_length=200, default="default") 32 | valid = models.BooleanField(default=False) 33 | need_token = models.BooleanField(default=False) 34 | task = models.ForeignKey("tasks.Task", on_delete=models.CASCADE) 35 | # NOTE(amelia): Auto approve is technically a ternary operator 36 | # If all in a task are None it will not auto approve 37 | # However if at least one action has it set to True it 38 | # will auto approve. If any are set to False this will 39 | # override all of them. 40 | # Can be thought of in terms of priority, None has the 41 | # lowest priority, then True with False having the 42 | # highest priority 43 | auto_approve = models.BooleanField(default=None, null=True) 44 | order = models.IntegerField() 45 | created = models.DateTimeField(default=timezone.now) 46 | 47 | def get_action(self): 48 | """Returns self as the appropriate action wrapper type.""" 49 | data = self.action_data 50 | return actions.ACTION_CLASSES[self.action_name](data=data, action_model=self) 51 | -------------------------------------------------------------------------------- /adjutant/api/exception_handler.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from logging import getLogger 16 | 17 | from django.http import Http404 18 | from django.utils import timezone 19 | 20 | from rest_framework.response import Response 21 | 22 | from adjutant import exceptions 23 | from adjutant.notifications.utils import create_notification 24 | 25 | 26 | LOG = getLogger("adjutant") 27 | 28 | 29 | def exception_handler(exc, context): 30 | """Returns the response that should be used for any given exception.""" 31 | now = timezone.now() 32 | if isinstance(exc, Http404): 33 | exc = exceptions.NotFound() 34 | elif isinstance(exc, exceptions.BaseServiceException): 35 | LOG.exception("(%s) - Internal service error." % now) 36 | exc = exceptions.ServiceUnavailable() 37 | 38 | if isinstance(exc, exceptions.BaseAPIException): 39 | if isinstance(exc.message, (list, dict)): 40 | data = {"errors": exc.message} 41 | else: 42 | data = {"errors": [exc.message]} 43 | note_data = data 44 | 45 | if isinstance(exc, exceptions.TaskActionsFailed): 46 | if exc.internal_message: 47 | if isinstance(exc.internal_message, (list, dict)): 48 | note_data = {"errors": exc.internal_message} 49 | else: 50 | note_data = {"errors": [exc.internal_message]} 51 | create_notification(exc.task, note_data, error=True) 52 | 53 | LOG.info("(%s) - %s" % (now, exc)) 54 | return Response(data, status=exc.status_code) 55 | 56 | LOG.exception("(%s) - Internal service error." % now) 57 | return None 58 | -------------------------------------------------------------------------------- /adjutant/api/v1/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import json 16 | 17 | from decorator import decorator 18 | 19 | from django.core.exceptions import FieldError 20 | 21 | from rest_framework.response import Response 22 | 23 | 24 | # "{'filters': {'fieldname': { 'operation': 'value'}} 25 | @decorator 26 | def parse_filters(func, *args, **kwargs): 27 | """ 28 | Parses incoming filters paramters and converts them to 29 | Django usable operations if valid. 30 | 31 | BE AWARE! WILL NOT WORK UNLESS POSITIONAL ARGUMENT 3 IS FILTERS! 32 | """ 33 | request = args[1] 34 | filters = request.query_params.get("filters", None) 35 | 36 | if not filters: 37 | return func(*args, **kwargs) 38 | cleaned_filters = {} 39 | try: 40 | filters = json.loads(filters) 41 | for field, operations in filters.items(): 42 | for operation, value in operations.items(): 43 | cleaned_filters["%s__%s" % (field, operation)] = value 44 | except (ValueError, AttributeError): 45 | return Response( 46 | { 47 | "errors": [ 48 | "Filters incorrectly formatted. Required format: " 49 | "{'filters': {'fieldname': { 'operation': 'value'}}" 50 | ] 51 | }, 52 | status=400, 53 | ) 54 | 55 | try: 56 | # NOTE(adriant): This feels dirty and unclear, but it works. 57 | # Positional argument 3 is filters, so we just replace it. 58 | args = list(args) 59 | args[2] = cleaned_filters 60 | return func(*args, **kwargs) 61 | except FieldError as e: 62 | return Response({"errors": [str(e)]}, status=400) 63 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3,pep8,black_check,cover_report 3 | minversion = 3.18.0 4 | 5 | [testenv] 6 | usedevelop = True 7 | deps = 8 | -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} 9 | -r{toxinidir}/requirements.txt 10 | -r{toxinidir}/test-requirements.txt 11 | commands = 12 | find adjutant -type f -name "*.pyc" -delete 13 | adjutant-api test {posargs} 14 | setenv = VIRTUAL_ENV={envdir} 15 | allowlist_externals = 16 | find 17 | 18 | [testenv:pep8] 19 | commands = 20 | flake8 21 | doc8 22 | 23 | [testenv:cover] 24 | commands = 25 | coverage run --source='adjutant' .tox/cover/bin/adjutant-api test {posargs} 26 | coverage html -d cover 27 | coverage xml -o cover/coverage.xml 28 | 29 | [testenv:cover_report] 30 | commands = 31 | coverage run --source='.' .tox/cover_report/bin/adjutant-api test {posargs} 32 | coverage report --include adjutant/* -m 33 | 34 | [testenv:venv] 35 | commands = {posargs} 36 | 37 | [testenv:docs] 38 | deps = 39 | -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} 40 | -r{toxinidir}/doc/requirements.txt 41 | commands = 42 | sphinx-build -W -b html -d doc/build/doctrees doc/source doc/build/html 43 | 44 | [testenv:api-ref] 45 | deps = {[testenv:docs]deps} 46 | commands = 47 | sphinx-build -W -b html -d api-ref/build/doctrees api-ref/source api-ref/build/html 48 | 49 | [testenv:releasenotes] 50 | deps = {[testenv:docs]deps} 51 | commands = sphinx-build -a -E -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html 52 | 53 | [flake8] 54 | max-line-length = 88 55 | select = C,E,F,W,B,B950 56 | ignore = D100,D101,D102,D103,D104,D105,D200,D203,D202,D204,D205,D208,D400,D401,W503,E203,E231,E501 57 | show-source = true 58 | builtins = _ 59 | exclude=.venv,venv,.env,env,.git,.tox,dist,doc,*lib/python*,*egg,releasenotes,adjutant/api/migrations/*,adjutant/actions/migrations,adjutant/tasks/migrations 60 | 61 | [doc8] 62 | ignore-path=.tox,*.egg-info,doc/build,releasenotes/build,api-ref/build,.eggs/*/EGG-INFO/*.txt,./*.txt,adjutant 63 | extension=.txt,.rst,.inc 64 | 65 | [testenv:black] 66 | commands = 67 | black -t py38 --exclude /(\.tox|\.venv|.*venv.*|build|dist)/ . 68 | 69 | [testenv:black_check] 70 | commands = 71 | black -t py38 --exclude /(\.tox|\.venv|.*venv.*|build|dist)/ --check . 72 | -------------------------------------------------------------------------------- /adjutant/api/models.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from django.db import models 16 | from uuid import uuid4 17 | from django.utils import timezone 18 | from jsonfield import JSONField 19 | 20 | from adjutant.tasks.models import Task 21 | 22 | 23 | def hex_uuid(): 24 | return uuid4().hex 25 | 26 | 27 | class Token(models.Model): 28 | """ 29 | UUID token object bound to a task. 30 | """ 31 | 32 | task = models.ForeignKey(Task, on_delete=models.CASCADE) 33 | token = models.CharField(max_length=32, primary_key=True) 34 | created_on = models.DateTimeField(default=timezone.now) 35 | expires = models.DateTimeField(db_index=True) 36 | 37 | def to_dict(self): 38 | return { 39 | "task": self.task.uuid, 40 | "task_type": self.task.task_type, 41 | "token": self.token, 42 | "created_on": self.created_on, 43 | "expires": self.expires, 44 | } 45 | 46 | @property 47 | def expired(self): 48 | return self.expires < timezone.now() 49 | 50 | 51 | class Notification(models.Model): 52 | """ 53 | Notification linked to a task with some notes. 54 | """ 55 | 56 | uuid = models.CharField(max_length=32, default=hex_uuid, primary_key=True) 57 | notes = JSONField(default={}) 58 | task = models.ForeignKey(Task, on_delete=models.CASCADE) 59 | error = models.BooleanField(default=False, db_index=True) 60 | created_on = models.DateTimeField(default=timezone.now) 61 | acknowledged = models.BooleanField(default=False, db_index=True) 62 | 63 | def to_dict(self): 64 | return { 65 | "uuid": self.uuid, 66 | "notes": self.notes, 67 | "task": self.task.uuid, 68 | "error": self.error, 69 | "acknowledged": self.acknowledged, 70 | "created_on": self.created_on, 71 | } 72 | -------------------------------------------------------------------------------- /releasenotes/notes/multiple-task-emails-0c55ee7103262f14.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | features: 3 | - | 4 | Added the ``to`` field to task stage email configurations, for setting 5 | an arbitrary address to send task stage emails to. 6 | - | 7 | Added the ``email_current_user`` field to task stage email configurations, 8 | for sending task stage emails to the user who initiated the task. 9 | Set ``email_current_user`` to ``true`` to enable this behaviour. 10 | - | 11 | Added the ``from_address`` variable to task stage email template 12 | contexts, allowing the address the email is being sent from internally 13 | to be templated in task stage email bodies. 14 | Note that this is not necessarily the same address that is set in the 15 | ``From`` header of the email. For that address, use 16 | ``reply_address`` instead. 17 | - | 18 | Added the ``reply_address`` variable to task stage email template 19 | contexts, allowing the reply-to address sent to the recipient to be 20 | templated in task stage email bodies. 21 | - | 22 | Added the ``email_address`` variable to task stage email template contexts, 23 | allowing the recipient email address to be templated in task stage email 24 | bodies. 25 | - | 26 | Added the ``email_current_user_address`` variable to task stage email 27 | template contexts, which exposes the email address of the user that 28 | initiated the task for use in task stage email templates. 29 | Note that depending on the task being run this value may not be 30 | available for use, in which case it will be set to ``None``. 31 | - | 32 | Added the ``email_action_addresses`` variable to task stage email 33 | template contexts, which exposes a dictionary mapping task actions 34 | to their recipient email addresses for use in task stage email templates. 35 | Note that depending on the task being run there may not be an email 36 | address available for certain actions, in which case the dictionary will 37 | not store a value for those tasks. If no tasks have any recipient email 38 | addresses, the dictionary will be empty. 39 | - | 40 | Multiple emails can now be sent per task stage using the new ``emails`` 41 | configuration field. To send multiple emails per task stage, define a list 42 | of emails to be sent as ``emails``, with per-email configuration set in 43 | the list elements. If a value is not set per-email, the value set in the 44 | stage configuration will be used, and if that is unset, the default value 45 | will be used. 46 | -------------------------------------------------------------------------------- /doc/source/contributing.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | So You Want to Contribute... 3 | ============================ 4 | 5 | For general information on contributing to OpenStack, please check out the 6 | `contributor guide `_ to get started. 7 | It covers all the basics that are common to all OpenStack projects: the 8 | accounts you need, the basics of interacting with our Gerrit review system, how 9 | we communicate as a community, etc. 10 | 11 | Below will cover the more project specific information you need to get started 12 | with Adjutant. 13 | 14 | Communication 15 | ~~~~~~~~~~~~~~ 16 | 17 | To communicate with the Adjutant Team, you can use the `mailing lists`_ with 18 | ``[adjutant]`` in the subject or get in touch with us directly via `IRC`_ in 19 | the ``#openstack-adjutant`` channel. 20 | 21 | .. _mailing lists: http://lists.openstack.org/cgi-bin/mailman/listinfo/openstack-discuss 22 | .. _IRC: https://docs.openstack.org/contributors/common/irc.html 23 | 24 | Contacting the Core Team 25 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | The `Adjutant core`_ group will be happy to help, so feel free to reach out. 28 | 29 | .. _Adjutant core: https://review.opendev.org/#/admin/groups/1790,members 30 | 31 | New Feature Planning 32 | ~~~~~~~~~~~~~~~~~~~~ 33 | 34 | For new features that you are planning check out our `storyboard tasks`_ and 35 | add a new describing your feature. 36 | 37 | .. _storyboard tasks: https://storyboard.openstack.org/#!/project/openstack/adjutant 38 | 39 | Task Tracking 40 | ~~~~~~~~~~~~~~ 41 | 42 | Our tasks are all tracked on storyboard, with related projects visiable at: 43 | `Adjutant Group `__ 44 | 45 | 46 | Reporting a Bug 47 | ~~~~~~~~~~~~~~~ 48 | 49 | If you've found a bug and want to make us aware of it, open a new story 50 | among our `storyboard tasks`_, and tag it as a bug. 51 | 52 | Getting Your Patch Merged 53 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 54 | 55 | After submitting a Patch, anyone can cooperate by `reviewing`_ the patch on 56 | `gerrit`_. Finally, the patch will be `merged`_ by the `Adjutant core`_. 57 | 58 | .. _gerrit: https://review.opendev.org/ 59 | .. _reviewing: https://docs.opendev.org/opendev/infra-manual/latest/developers.html#peer-review 60 | .. _merged: https://docs.opendev.org/opendev/infra-manual/latest/developers.html#merging 61 | 62 | Project Team Lead Duties 63 | ------------------------ 64 | 65 | All common PTL duties are enumerated here in the `PTL guide `_. 66 | -------------------------------------------------------------------------------- /adjutant/tasks/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.21 on 2019-06-10 02:09 3 | 4 | import adjutant.tasks.models 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import jsonfield.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("api", "0005_auto_20190610_0209"), 13 | ] 14 | 15 | operations = [ 16 | migrations.SeparateDatabaseAndState( 17 | state_operations=[ 18 | migrations.CreateModel( 19 | name="Task", 20 | fields=[ 21 | ( 22 | "uuid", 23 | models.CharField( 24 | default=adjutant.tasks.models.hex_uuid, 25 | max_length=32, 26 | primary_key=True, 27 | serialize=False, 28 | ), 29 | ), 30 | ("hash_key", models.CharField(db_index=True, max_length=64)), 31 | ("ip_address", models.GenericIPAddressField()), 32 | ("keystone_user", jsonfield.fields.JSONField(default={})), 33 | ( 34 | "project_id", 35 | models.CharField(db_index=True, max_length=64, null=True), 36 | ), 37 | ("approved_by", jsonfield.fields.JSONField(default={})), 38 | ("task_type", models.CharField(db_index=True, max_length=100)), 39 | ("action_notes", jsonfield.fields.JSONField(default={})), 40 | ( 41 | "cancelled", 42 | models.BooleanField(db_index=True, default=False), 43 | ), 44 | ("approved", models.BooleanField(db_index=True, default=False)), 45 | ( 46 | "completed", 47 | models.BooleanField(db_index=True, default=False), 48 | ), 49 | ( 50 | "created_on", 51 | models.DateTimeField(default=django.utils.timezone.now), 52 | ), 53 | ("approved_on", models.DateTimeField(null=True)), 54 | ("completed_on", models.DateTimeField(null=True)), 55 | ], 56 | options={ 57 | "indexes": [], 58 | }, 59 | ), 60 | ], 61 | ), 62 | ] 63 | -------------------------------------------------------------------------------- /adjutant/core.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from adjutant.feature_set import BaseFeatureSet 16 | 17 | from adjutant.actions.v1 import misc as misc_actions 18 | from adjutant.actions.v1 import projects as project_actions 19 | from adjutant.actions.v1 import resources as resource_actions 20 | from adjutant.actions.v1 import users as user_actions 21 | 22 | from adjutant.api.v1 import openstack as openstack_apis 23 | from adjutant.api.v1 import tasks as task_apis 24 | 25 | from adjutant.tasks.v1 import projects as project_tasks 26 | from adjutant.tasks.v1 import resources as resource_tasks 27 | from adjutant.tasks.v1 import users as user_tasks 28 | 29 | from adjutant.notifications.v1 import email as email_handlers 30 | 31 | 32 | class AdjutantCore(BaseFeatureSet): 33 | """Adjutant's Core feature set.""" 34 | 35 | actions = [ 36 | project_actions.NewProjectWithUserAction, 37 | project_actions.NewProjectAction, 38 | project_actions.AddDefaultUsersToProjectAction, 39 | resource_actions.NewDefaultNetworkAction, 40 | resource_actions.NewProjectDefaultNetworkAction, 41 | resource_actions.SetProjectQuotaAction, 42 | resource_actions.UpdateProjectQuotasAction, 43 | user_actions.NewUserAction, 44 | user_actions.ResetUserPasswordAction, 45 | user_actions.EditUserRolesAction, 46 | user_actions.UpdateUserEmailAction, 47 | misc_actions.SendAdditionalEmailAction, 48 | ] 49 | 50 | tasks = [ 51 | project_tasks.CreateProjectAndUser, 52 | user_tasks.EditUserRoles, 53 | user_tasks.InviteUser, 54 | user_tasks.ResetUserPassword, 55 | user_tasks.UpdateUserEmail, 56 | resource_tasks.UpdateProjectQuotas, 57 | ] 58 | 59 | delegate_apis = [ 60 | task_apis.CreateProjectAndUser, 61 | task_apis.InviteUser, 62 | task_apis.ResetPassword, 63 | task_apis.EditUser, 64 | task_apis.UpdateEmail, 65 | openstack_apis.UserList, 66 | openstack_apis.UserDetail, 67 | openstack_apis.UserRoles, 68 | openstack_apis.RoleList, 69 | openstack_apis.UserResetPassword, 70 | openstack_apis.UserUpdateEmail, 71 | openstack_apis.SignUp, 72 | openstack_apis.UpdateProjectQuotas, 73 | ] 74 | 75 | notification_handlers = [ 76 | email_handlers.EmailNotification, 77 | ] 78 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | #################################### 2 | Welcome to Adjutant's documentation! 3 | #################################### 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | contributing 9 | development 10 | release-notes 11 | devstack-guide 12 | configuration 13 | feature-sets 14 | quota 15 | guide-lines 16 | features 17 | history 18 | 19 | A basic workflow framework built using Django and Django-Rest-Framework to 20 | help automate Admin tasks within an OpenStack cluster. 21 | 22 | The goal of Adjutant is to provide a place and standard actions to fill in 23 | functionality missing from Keystone, and allow for the easy addition of 24 | business logic into more complex tasks, and connections with outside systems. 25 | 26 | Tasks are built around three states of initial submission, admin approval and 27 | token submission. All of the states are not always used in every task, but this 28 | format allows the easy implementation of systems requiring approval and checks 29 | final user data entry. 30 | 31 | While this is a Django application, it does not follow the standard Django 32 | folder structure because of certain packaging requirements. As such the project 33 | does not have a manage.py file and must be installed via setup.py or pip. 34 | 35 | Once installed, all the normal manage.py functions can be called directly on 36 | the 'adjutant-api' commandline function. 37 | 38 | The command ``tox -e venv {your commands}`` can be used and will setup a 39 | virtual environment with all the required dependencies for you. 40 | 41 | For example, running the server on port 5050 can be done with:: 42 | 43 | tox -e venv adjutant-api runserver 0.0.0.0:5050 44 | 45 | 46 | *********************** 47 | Client and UI Libraries 48 | *********************** 49 | 50 | Both a commandline/python and a horizon plugin exist for adjutant: 51 | 52 | * `python-adjutantclient `_ 53 | * `adjutant-ui `_ 54 | 55 | 56 | *********************** 57 | Tests and Documentation 58 | *********************** 59 | 60 | Tests and documentation are managed by tox, they can be run simply with the 61 | command ``tox``. 62 | 63 | To run just action unit tests:: 64 | 65 | tox adjutant.actions 66 | 67 | To run a single api test:: 68 | 69 | tox adjutant.api.v1.tests.test_delegate_api.DelegateAPITests.test_duplicate_tasks_new_user 70 | 71 | Tox will run the tests in Python 2.7, Python 3.5 and produce a coverage report. 72 | 73 | Api reference can be generated with the command ``tox -e api-ref`` . This will 74 | be placed in the ``api-ref/build`` directory, these docs can be generated with 75 | the command ``tox -e docs``, these will be placed inside the ``doc/build`` 76 | directory. 77 | 78 | 79 | ************ 80 | Contributing 81 | ************ 82 | 83 | Bugs and blueprints for Adjutant, its ui and client are managed `here on 84 | launchpad. `_ 85 | 86 | Changes should be submitted through the OpenStack gerrit, the guide for 87 | contributing to OpenStack projects is 88 | `here `_ . 89 | -------------------------------------------------------------------------------- /adjutant/tasks/migrations/0002_auto_20190619_0613.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.21 on 2019-06-19 06:13 3 | 4 | from django.db import migrations, models 5 | import jsonfield.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("tasks", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name="task", 16 | name="ip_address", 17 | ), 18 | migrations.AddField( 19 | model_name="task", 20 | name="task_notes", 21 | field=jsonfield.fields.JSONField(default=[]), 22 | ), 23 | migrations.AlterField( 24 | model_name="task", 25 | name="approved", 26 | field=models.BooleanField(default=False), 27 | ), 28 | migrations.AlterField( 29 | model_name="task", 30 | name="cancelled", 31 | field=models.BooleanField(default=False), 32 | ), 33 | migrations.AlterField( 34 | model_name="task", 35 | name="completed", 36 | field=models.BooleanField(default=False), 37 | ), 38 | migrations.AlterField( 39 | model_name="task", 40 | name="hash_key", 41 | field=models.CharField(max_length=64), 42 | ), 43 | migrations.AlterField( 44 | model_name="task", 45 | name="project_id", 46 | field=models.CharField(max_length=64, null=True), 47 | ), 48 | migrations.AlterField( 49 | model_name="task", 50 | name="task_type", 51 | field=models.CharField(max_length=100), 52 | ), 53 | migrations.AddIndex( 54 | model_name="task", 55 | index=models.Index(fields=["completed"], name="completed_idx"), 56 | ), 57 | migrations.AddIndex( 58 | model_name="task", 59 | index=models.Index( 60 | fields=["project_id", "uuid"], name="tasks_task_project_a1cfa7_idx" 61 | ), 62 | ), 63 | migrations.AddIndex( 64 | model_name="task", 65 | index=models.Index( 66 | fields=["project_id", "task_type"], name="tasks_task_project_e86456_idx" 67 | ), 68 | ), 69 | migrations.AddIndex( 70 | model_name="task", 71 | index=models.Index( 72 | fields=["project_id", "task_type", "cancelled"], 73 | name="tasks_task_project_f0ec0e_idx", 74 | ), 75 | ), 76 | migrations.AddIndex( 77 | model_name="task", 78 | index=models.Index( 79 | fields=["project_id", "task_type", "completed", "cancelled"], 80 | name="tasks_task_project_1cb2a8_idx", 81 | ), 82 | ), 83 | migrations.AddIndex( 84 | model_name="task", 85 | index=models.Index( 86 | fields=["hash_key", "completed", "cancelled"], 87 | name="tasks_task_hash_ke_781b6a_idx", 88 | ), 89 | ), 90 | ] 91 | -------------------------------------------------------------------------------- /adjutant/api/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from datetime import datetime 16 | import time 17 | import sys 18 | 19 | from decorator import decorator 20 | 21 | from rest_framework.response import Response 22 | 23 | 24 | def require_roles(roles, func, *args, **kwargs): 25 | """ 26 | endpoints setup with this decorator require the defined roles. 27 | """ 28 | request = args[1] 29 | req_roles = set(roles) 30 | if not request.keystone_user.get("authenticated", False): 31 | return Response({"errors": ["Credentials incorrect or none given."]}, 401) 32 | 33 | roles = set(request.keystone_user.get("roles", [])) 34 | 35 | if roles & req_roles: 36 | return func(*args, **kwargs) 37 | 38 | return Response( 39 | {"errors": ["Must have one of the following roles: %s" % list(req_roles)]}, 403 40 | ) 41 | 42 | 43 | @decorator 44 | def mod_or_admin(func, *args, **kwargs): 45 | """ 46 | Require project_mod or project_admin. 47 | Admin is allowed everything, so is also included. 48 | """ 49 | return require_roles( 50 | {"project_admin", "project_mod", "admin"}, func, *args, **kwargs 51 | ) 52 | 53 | 54 | @decorator 55 | def project_admin(func, *args, **kwargs): 56 | """ 57 | endpoints setup with this decorator require the admin/project admin role. 58 | """ 59 | return require_roles({"project_admin", "admin"}, func, *args, **kwargs) 60 | 61 | 62 | @decorator 63 | def admin(func, *args, **kwargs): 64 | """ 65 | endpoints setup with this decorator require the admin role. 66 | """ 67 | return require_roles({"admin"}, func, *args, **kwargs) 68 | 69 | 70 | @decorator 71 | def authenticated(func, *args, **kwargs): 72 | """ 73 | endpoints setup with this decorator require the user to be signed in 74 | """ 75 | request = args[1] 76 | if not request.keystone_user.get("authenticated", False): 77 | return Response({"errors": ["Credentials incorrect or none given."]}, 401) 78 | 79 | return func(*args, **kwargs) 80 | 81 | 82 | @decorator 83 | def minimal_duration(func, min_time=1, *args, **kwargs): 84 | """ 85 | Make a function (or API call) take at least some time. 86 | """ 87 | # doesn't apply during tests 88 | if "test" in sys.argv: 89 | return func(*args, **kwargs) 90 | 91 | start = datetime.utcnow() 92 | return_val = func(*args, **kwargs) 93 | end = datetime.utcnow() 94 | duration = end - start 95 | if duration.total_seconds() < min_time: 96 | time.sleep(min_time - duration.total_seconds()) 97 | return return_val 98 | -------------------------------------------------------------------------------- /adjutant/tasks/v1/users.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from adjutant.tasks.v1.base import BaseTask 16 | 17 | 18 | class InviteUser(BaseTask): 19 | duplicate_policy = "block" 20 | task_type = "invite_user_to_project" 21 | deprecated_task_types = ["invite_user"] 22 | default_actions = [ 23 | "NewUserAction", 24 | ] 25 | 26 | email_config = { 27 | "initial": None, 28 | "token": { 29 | "template": "invite_user_to_project_token.txt", 30 | "subject": "invite_user_to_project", 31 | }, 32 | "completed": { 33 | "template": "invite_user_to_project_completed.txt", 34 | "subject": "invite_user_to_project", 35 | }, 36 | } 37 | 38 | 39 | class ResetUserPassword(BaseTask): 40 | task_type = "reset_user_password" 41 | deprecated_task_types = ["reset_password"] 42 | default_actions = [ 43 | "ResetUserPasswordAction", 44 | ] 45 | 46 | email_config = { 47 | "initial": None, 48 | "token": { 49 | "template": "reset_user_password_token.txt", 50 | "subject": "Password Reset for OpenStack", 51 | }, 52 | "completed": { 53 | "template": "reset_user_password_completed.txt", 54 | "subject": "Password Reset for OpenStack", 55 | }, 56 | } 57 | 58 | 59 | class EditUserRoles(BaseTask): 60 | task_type = "edit_user_roles" 61 | deprecated_task_types = ["edit_user"] 62 | default_actions = [ 63 | "EditUserRolesAction", 64 | ] 65 | 66 | email_config = {"initial": None, "token": None, "completed": None} 67 | 68 | 69 | class UpdateUserEmail(BaseTask): 70 | task_type = "update_user_email" 71 | deprecated_task_types = ["update_email"] 72 | default_actions = [ 73 | "UpdateUserEmailAction", 74 | ] 75 | additional_actions = [ 76 | "SendAdditionalEmailAction", 77 | ] 78 | action_config = { 79 | "SendAdditionalEmailAction": { 80 | "prepare": { 81 | "subject": "OpenStack Email Update Requested", 82 | "template": "update_user_email_started.txt", 83 | "email_current_user": True, 84 | }, 85 | }, 86 | } 87 | email_config = { 88 | "initial": None, 89 | "token": { 90 | "subject": "update_user_email_token", 91 | "template": "update_user_email_token.txt", 92 | }, 93 | "completed": { 94 | "subject": "Email Update Complete", 95 | "template": "update_user_email_completed.txt", 96 | }, 97 | } 98 | -------------------------------------------------------------------------------- /adjutant/common/openstack_clients.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | 16 | from keystoneauth1.identity import v3 17 | from keystoneauth1 import session 18 | from keystoneclient import client as ks_client 19 | 20 | from cinderclient import client as cinderclient 21 | from neutronclient.v2_0 import client as neutronclient 22 | from novaclient import client as novaclient 23 | from octaviaclient.api.v2 import octavia 24 | from troveclient.v1 import client as troveclient 25 | 26 | from adjutant.config import CONF 27 | 28 | # Defined for use locally 29 | DEFAULT_COMPUTE_VERSION = "2" 30 | DEFAULT_IDENTITY_VERSION = "3" 31 | DEFAULT_IMAGE_VERSION = "2" 32 | DEFAULT_METERING_VERSION = "2" 33 | DEFAULT_OBJECT_STORAGE_VERSION = "1" 34 | DEFAULT_ORCHESTRATION_VERSION = "1" 35 | DEFAULT_VOLUME_VERSION = "3" 36 | 37 | # Auth session shared by default with all clients 38 | client_auth_session = None 39 | 40 | 41 | def get_auth_session(): 42 | """Returns a global auth session to be shared by all clients""" 43 | global client_auth_session 44 | if not client_auth_session: 45 | auth = v3.Password( 46 | username=CONF.identity.auth.username, 47 | password=CONF.identity.auth.password, 48 | project_name=CONF.identity.auth.project_name, 49 | auth_url=CONF.identity.auth.auth_url, 50 | user_domain_id=CONF.identity.auth.user_domain_id, 51 | project_domain_id=CONF.identity.auth.project_domain_id, 52 | ) 53 | client_auth_session = session.Session(auth=auth) 54 | 55 | return client_auth_session 56 | 57 | 58 | def get_keystoneclient(version=DEFAULT_IDENTITY_VERSION): 59 | return ks_client.Client(version, session=get_auth_session()) 60 | 61 | 62 | def get_neutronclient(region): 63 | # always returns neutron client v2 64 | return neutronclient.Client(session=get_auth_session(), region_name=region) 65 | 66 | 67 | def get_novaclient(region, version=DEFAULT_COMPUTE_VERSION): 68 | return novaclient.Client(version, session=get_auth_session(), region_name=region) 69 | 70 | 71 | def get_cinderclient(region, version=DEFAULT_VOLUME_VERSION): 72 | return cinderclient.Client(version, session=get_auth_session(), region_name=region) 73 | 74 | 75 | def get_octaviaclient(region): 76 | ks = get_keystoneclient() 77 | 78 | service = ks.services.list(name="octavia")[0] 79 | endpoint = ks.endpoints.list(service=service, region=region, interface="public")[0] 80 | return octavia.OctaviaAPI(session=get_auth_session(), endpoint=endpoint.url) 81 | 82 | 83 | def get_troveclient(region): 84 | return troveclient.Client(session=get_auth_session(), region_name=region) 85 | -------------------------------------------------------------------------------- /adjutant/tasks/v1/manager.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from logging import getLogger 16 | 17 | from adjutant import exceptions 18 | from adjutant import tasks 19 | from adjutant.tasks.models import Task 20 | from adjutant.tasks.v1.base import BaseTask 21 | 22 | 23 | class TaskManager(object): 24 | def __init__(self, message=None): 25 | self.logger = getLogger("adjutant") 26 | 27 | def _get_task_class(self, task_type): 28 | """Get the task class from the given task_type 29 | 30 | If the task_type is a string, it will get the correct class, 31 | otherwise if it is a valid task class, will return it. 32 | """ 33 | try: 34 | return tasks.TASK_CLASSES[task_type] 35 | except KeyError: 36 | if task_type in tasks.TASK_CLASSES.values(): 37 | return task_type 38 | raise exceptions.TaskNotRegistered("Unknown task type: '%s'" % task_type) 39 | 40 | def create_from_request(self, task_type, request): 41 | task_class = self._get_task_class(task_type) 42 | task_data = { 43 | "keystone_user": request.keystone_user, 44 | "project_id": request.keystone_user.get("project_id"), 45 | } 46 | task = task_class(task_data=task_data, action_data=request.data) 47 | task.prepare() 48 | return task 49 | 50 | def create_from_data(self, task_type, task_data, action_data): 51 | task_class = self._get_task_class(task_type) 52 | task = task_class(task_data=task_data, action_data=action_data) 53 | task.prepare() 54 | return task 55 | 56 | def get(self, task): 57 | if isinstance(task, BaseTask): 58 | return task 59 | if isinstance(task, str): 60 | try: 61 | task = Task.objects.get(uuid=task) 62 | except Task.DoesNotExist: 63 | raise exceptions.TaskNotFound( 64 | "Task not found with uuid of: '%s'" % task 65 | ) 66 | if isinstance(task, Task): 67 | return task.get_task() 68 | raise exceptions.TaskNotFound("Task not found for value of: '%s'" % task) 69 | 70 | def update(self, task, action_data): 71 | task = self.get(task) 72 | task.update(action_data) 73 | return task 74 | 75 | def approve(self, task, approved_by): 76 | task = self.get(task) 77 | task.approve(approved_by) 78 | return task 79 | 80 | def submit(self, task, token_data, keystone_user=None): 81 | task = self.get(task) 82 | task.submit(token_data, keystone_user) 83 | return task 84 | 85 | def cancel(self, task): 86 | task = self.get(task) 87 | task.cancel() 88 | return task 89 | 90 | def reissue_token(self, task): 91 | task = self.get(task) 92 | task.reissue_token() 93 | return task 94 | -------------------------------------------------------------------------------- /adjutant/api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | import jsonfield.fields 5 | import django.utils.timezone 6 | import adjutant.api.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Notification", 15 | fields=[ 16 | ( 17 | "uuid", 18 | models.CharField( 19 | default=adjutant.api.models.hex_uuid, 20 | max_length=32, 21 | serialize=False, 22 | primary_key=True, 23 | ), 24 | ), 25 | ("notes", jsonfield.fields.JSONField(default={})), 26 | ("error", models.BooleanField(default=False, db_index=True)), 27 | ("created_on", models.DateTimeField(default=django.utils.timezone.now)), 28 | ("acknowledged", models.BooleanField(default=False, db_index=True)), 29 | ], 30 | ), 31 | migrations.CreateModel( 32 | name="Task", 33 | fields=[ 34 | ( 35 | "uuid", 36 | models.CharField( 37 | default=adjutant.api.models.hex_uuid, 38 | max_length=32, 39 | serialize=False, 40 | primary_key=True, 41 | ), 42 | ), 43 | ("hash_key", models.CharField(max_length=32, db_index=True)), 44 | ("ip_address", models.GenericIPAddressField()), 45 | ("keystone_user", jsonfield.fields.JSONField(default={})), 46 | ( 47 | "project_id", 48 | models.CharField(max_length=32, null=True, db_index=True), 49 | ), 50 | ("task_type", models.CharField(max_length=100, db_index=True)), 51 | ("action_notes", jsonfield.fields.JSONField(default={})), 52 | ("cancelled", models.BooleanField(default=False, db_index=True)), 53 | ("approved", models.BooleanField(default=False, db_index=True)), 54 | ("completed", models.BooleanField(default=False, db_index=True)), 55 | ("created_on", models.DateTimeField(default=django.utils.timezone.now)), 56 | ("approved_on", models.DateTimeField(null=True)), 57 | ("completed_on", models.DateTimeField(null=True)), 58 | ], 59 | ), 60 | migrations.CreateModel( 61 | name="Token", 62 | fields=[ 63 | ( 64 | "token", 65 | models.CharField(max_length=32, serialize=False, primary_key=True), 66 | ), 67 | ("created_on", models.DateTimeField(default=django.utils.timezone.now)), 68 | ("expires", models.DateTimeField(db_index=True)), 69 | ( 70 | "task", 71 | models.ForeignKey( 72 | on_delete=django.db.models.deletion.CASCADE, to="api.Task" 73 | ), 74 | ), 75 | ], 76 | ), 77 | migrations.AddField( 78 | model_name="notification", 79 | name="task", 80 | field=models.ForeignKey( 81 | on_delete=django.db.models.deletion.CASCADE, to="api.Task" 82 | ), 83 | ), 84 | ] 85 | -------------------------------------------------------------------------------- /adjutant/actions/utils.py: -------------------------------------------------------------------------------- 1 | from django.core.mail import EmailMultiAlternatives 2 | from django.template import loader 3 | 4 | from adjutant.notifications.utils import create_notification 5 | 6 | 7 | def validate_steps(validation_steps): 8 | """Helper function for validation in actions 9 | 10 | Takes a list of validation functions or validation function results. 11 | If function, will call it first, otherwise checks if valid. Will break 12 | and return False on first validation failure, or return True if all valid. 13 | 14 | It is best to pass in the functions and let this call them so that it 15 | doesn't keep validating after the first invalid result. 16 | """ 17 | for step in validation_steps: 18 | if callable(step): 19 | if not step(): 20 | return False 21 | if not step: 22 | return False 23 | return True 24 | 25 | 26 | def send_email(to_addresses, context, conf, task): 27 | """ 28 | Function for sending emails from actions 29 | """ 30 | 31 | if not conf.get("template"): 32 | return 33 | 34 | if not to_addresses: 35 | return 36 | if isinstance(to_addresses, str): 37 | to_addresses = [to_addresses] 38 | elif isinstance(to_addresses, set): 39 | to_addresses = list(to_addresses) 40 | 41 | text_template = loader.get_template(conf["template"], using="include_etc_templates") 42 | 43 | html_template = conf.get("html_template") 44 | if html_template: 45 | html_template = loader.get_template( 46 | html_template, using="include_etc_templates" 47 | ) 48 | 49 | try: 50 | message = text_template.render(context) 51 | # from_email is the return-path and is distinct from the 52 | # message headers 53 | from_email = conf.get("from") 54 | if not from_email: 55 | from_email = conf.get("reply") 56 | if not from_email: 57 | return 58 | elif "%(task_uuid)s" in from_email: 59 | from_email = from_email % {"task_uuid": task.uuid} 60 | 61 | reply_email = conf["reply"] 62 | # these are the message headers which will be visible to 63 | # the email client. 64 | headers = { 65 | "X-Adjutant-Task-UUID": task.uuid, 66 | # From needs to be set to be distinct from return-path 67 | "From": reply_email, 68 | "Reply-To": reply_email, 69 | } 70 | 71 | email = EmailMultiAlternatives( 72 | conf["subject"], 73 | message, 74 | from_email, 75 | to_addresses, 76 | headers=headers, 77 | ) 78 | 79 | if html_template: 80 | email.attach_alternative(html_template.render(context), "text/html") 81 | 82 | email.send(fail_silently=False) 83 | return True 84 | 85 | except Exception as e: 86 | notes = { 87 | "errors": ( 88 | "Error: '%s' while sending additional email for task: %s" 89 | % (e, task.uuid) 90 | ) 91 | } 92 | 93 | notif_conf = task.config.notifications 94 | 95 | if e.__class__.__name__ in notif_conf.safe_errors: 96 | notification = create_notification(task, notes, error=True, handlers=False) 97 | notification.acknowledged = True 98 | notification.save() 99 | else: 100 | create_notification(task, notes, error=True) 101 | 102 | return False 103 | -------------------------------------------------------------------------------- /doc/source/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuring Adjutant 2 | ==================== 3 | 4 | Adjutant is designed to be highly configurable for various needs. The goal 5 | of Adjutant is to provide a variety of common tasks and actions that can 6 | be easily extended or changed based upon the needs of your OpenStack cluster. 7 | 8 | For configuration Adjutant uses a library called CONFspirator to define and 9 | register our config values. This makes the app better at processing defaults 10 | and checking the validity of the config. 11 | 12 | An example Adjutant config file is found in etc/adjutant.yaml, and a new one 13 | can be generated by running:: 14 | 15 | tox -e venv -- adjutant-api exampleconfig --output-file /etc/adjutant/adjutant.yaml 16 | 17 | With ``--output-file`` controlling where the file goes. If the file extension 18 | is given as ``toml`` rather than ``yaml``, a toml format config file will be 19 | generated instead. 20 | 21 | This example file should be your starting point for configuring the service, 22 | and your core source of documentation for what each config does. 23 | 24 | Adjutant will read the file from ``/etc/adjutant/adjutant.yaml`` or 25 | ``/etc/adjutant/adjutant.toml``, and if the environment variable 26 | ``ADJUTANT_CONFIG_FILE`` is set, will look for the file in the 27 | specified location. 28 | 29 | .. note:: 30 | 31 | While Adjutant does support toml as a config format, you are likely 32 | better off sticking with yaml as it may prove easier and more reliable, 33 | but for those who much prefer an ini type format, that might feel closer. 34 | 35 | Configuration options 36 | +++++++++++++++++++++ 37 | 38 | Django group 39 | ------------ 40 | 41 | The first part of the configuration file contains standard Django settings, 42 | and for the most part the generated example config will explain all the 43 | options. 44 | 45 | Identity group 46 | -------------- 47 | 48 | Are the configs for how Adjutant interacts with Keystone, with the important 49 | ones being as follows: 50 | 51 | **adjutant.identity.username_is_email** impacts account creation, and email 52 | modification actions. In the case that it is true, any task passing a username 53 | and email pair, the username will be ignored. This also impacts where emails 54 | are sent to. 55 | 56 | **adjutant.identity.auth** Are the credentials that Adjutant uses to talk to 57 | Keystone, and the various other OpenStack services. 58 | 59 | **adjutant.identity.role_mapping** defines which roles can modify other roles. 60 | In the default configuration a user who has the role project_mod will not be 61 | able to modify any of the roles for a user with the project_admin role. 62 | 63 | API group 64 | --------- 65 | 66 | Controls which DelegateAPIs are enabled, and what some of their configuration 67 | may be. 68 | 69 | Notifications group 70 | ------------------- 71 | 72 | Default settings around what notifications should do during the task workflows. 73 | 74 | Workflow group 75 | -------------- 76 | 77 | **adjutant.workflow.task_defaults** Represents the default settings for all 78 | tasks unless otherwise overridden for individual tasks in 79 | ``adjutant.workflow.tasks``. 80 | 81 | Default action settings. 82 | **adjutant.workflow.action_defaults** Are the default settings for each action 83 | and can be overriden on a per task basis via 84 | ``adjutant.workflow.tasks..actions``. 85 | 86 | 87 | Email and notification templates 88 | ++++++++++++++++++++++++++++++++ 89 | 90 | Additional templates can be placed in ``/etc/adjutant/templates/`` and will be 91 | loaded in automatically. A plain text template and an HTML template can be 92 | specified separately. The context for this will include the task object and 93 | a dictionary containing the action objects. 94 | -------------------------------------------------------------------------------- /doc/source/features.rst: -------------------------------------------------------------------------------- 1 | Project Features 2 | ################ 3 | 4 | To be clear, Adjutant doesn't really have features. It's a framework for 5 | deployer defined workflow, and a service to expose those workflows on 6 | configurable APIs, and supplementary micro APIs. This provides useful ways to 7 | extend some functionality in OpenStack and wrap sensible business logic around 8 | it, all while providing a clear audit trail for all tasks that Adjutant 9 | handles. 10 | 11 | Adjutant does have default implementations of workflows and the APIs for 12 | them. These are in part meant to be workflow that is applicable to any cloud, 13 | but also example implementations, as well as actions that could potentially be 14 | reused in deployer specific workflow in their own feature sets. If anything 15 | could be considered a feature, it potentially could be these. The plan is to 16 | add many of these, which any cloud can use out of the box, or augment as 17 | needed. 18 | 19 | To enable these they must be added to `ACTIVE_DELEGATE_APIS` in the conf file. 20 | 21 | For most of these there are matching panels in Horizon. 22 | 23 | Built in Tasks and APIs 24 | ======================= 25 | 26 | UserList 27 | ++++++++ 28 | 29 | List the users on your project if you have the `project_admin` or `project_mod` 30 | role, and allow the invitiation of additional members (via email) to your 31 | project. 32 | 33 | .. note:: Adjutant-UI exposes this with the Project Users panel for Horizon. 34 | 35 | UserRoles 36 | +++++++++ 37 | 38 | Allows the editing of roles for users in the same project provided you have 39 | the `project_admin` or `project_mod` role. 40 | 41 | .. note:: Adjutant-UI exposes this with the Project Users panel for Horizon. 42 | 43 | RoleList 44 | ++++++++ 45 | 46 | Micro API to list roles that can be managed by your current user for users on 47 | your project. 48 | 49 | .. note:: Adjutant-UI exposes this with the Project Users panel for Horizon. 50 | 51 | UserDetail 52 | ++++++++++ 53 | 54 | Just a micro API to show details of users on your project, and cancel invite 55 | user tasks. 56 | 57 | .. note:: Adjutant-UI exposes this with the Project Users panel for Horizon. 58 | 59 | UserResetPassword 60 | +++++++++++++++++ 61 | 62 | An unauthenticated API that allows password reset request submissions. Will 63 | check if user exists, and email user with password reset token to reset 64 | password. This token is usable in Horizon, or via the API directly. 65 | 66 | .. note:: Adjutant-UI exposes this with the Forgot Password panel for Horizon. 67 | 68 | SignUp 69 | ++++++ 70 | 71 | An unauthenticated API that allows prospective users to submit requests to have 72 | a project and account created. This will then notify an admin as configured 73 | and an admin can approve or cancel the request. 74 | 75 | This is mostly built as a basic example of a signup workflow. Most companies 76 | would use this only as a template and expand on the actions to talk to external 77 | systems and facilitate much more complex validation. 78 | 79 | A more complex example of a signup process built on top of the defaults is 80 | Catalyst Cloud's own one: https://github.com/catalyst-cloud/adjutant-odoo 81 | 82 | .. note:: Adjutant-UI exposes this with the Sign Up panel for Horizon. 83 | 84 | UserUpdateEmail 85 | +++++++++++++++ 86 | 87 | A simple task that allows a user to update their own email address (or username 88 | if username==email). An email is sent to the old email informing them of the 89 | change, and a token to the new email so that the user must confirm they have 90 | correctly given their email. 91 | 92 | .. note:: Adjutant-UI exposes this with the Update Email Address panel for 93 | Horizon. 94 | 95 | 96 | UpdateProjectQuotas 97 | +++++++++++++++++++ 98 | 99 | A way for users to request quota changes between given sizes. These requests 100 | are either automatically approved if configured as such, or require an admin 101 | to approve the quota change. 102 | 103 | .. note:: Adjutant-UI exposes this with the Quota Management panel for Horizon. 104 | -------------------------------------------------------------------------------- /adjutant/config/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | import os 16 | import sys 17 | 18 | import confspirator 19 | from confspirator import groups 20 | 21 | from adjutant.config import api 22 | from adjutant.config import django 23 | from adjutant.config import identity 24 | from adjutant.config import notification 25 | from adjutant.config import quota 26 | from adjutant.config import workflow 27 | from adjutant.config import feature_sets 28 | 29 | _root_config = groups.ConfigGroup("adjutant") 30 | _root_config.register_child_config(django.config_group) 31 | _root_config.register_child_config(identity.config_group) 32 | _root_config.register_child_config(api.config_group) 33 | _root_config.register_child_config(notification.config_group) 34 | _root_config.register_child_config(workflow.config_group) 35 | _root_config.register_child_config(quota.config_group) 36 | _root_config.register_child_config(feature_sets.config_group) 37 | 38 | _config_files = [ 39 | "/etc/adjutant/adjutant.yaml", 40 | "/etc/adjutant/adjutant.toml", 41 | ] 42 | _old_config_file = "/etc/adjutant/conf.yaml" 43 | 44 | 45 | _test_mode_commands = [ 46 | # Adjutant commands: 47 | "exampleconfig", 48 | # Django commands: 49 | "check", 50 | "makemigrations", 51 | "squashmigrations", 52 | "test", 53 | "testserver", 54 | ] 55 | 56 | 57 | def _load_config(): 58 | if "adjutant-api" in sys.argv[0] and sys.argv[1] in _test_mode_commands: 59 | test_mode = True 60 | else: 61 | test_mode = False 62 | 63 | config_file_locations = list(_config_files) 64 | config_file_locations.append(_old_config_file) 65 | 66 | conf_file = os.environ.get("ADJUTANT_CONFIG_FILE", None) 67 | 68 | if conf_file: 69 | config_file_locations.insert(0, conf_file) 70 | 71 | loaded_config = None 72 | used_config_loc = None 73 | for conf_file_loc in config_file_locations: 74 | try: 75 | # NOTE(adriant): we print because we don't yet know 76 | # where to log to 77 | if not test_mode: 78 | print("Checking for config at '%s'" % conf_file_loc) 79 | loaded_config = confspirator.load_file( 80 | _root_config, conf_file_loc, test_mode=test_mode 81 | ) 82 | used_config_loc = conf_file_loc 83 | break 84 | except IOError: 85 | if not test_mode: 86 | print( 87 | "Conf file not found at '%s', trying next possible location." 88 | % conf_file_loc 89 | ) 90 | 91 | if ( 92 | used_config_loc != conf_file 93 | and used_config_loc == _old_config_file 94 | and not test_mode 95 | ): 96 | print( 97 | "DEPRECATED: Using the old default config location '%s' is deprecated " 98 | "in favor of one of '%s', or setting a config location via the environment " 99 | "variable 'ADJUTANT_CONFIG_FILE'." % (_old_config_file, _config_files) 100 | ) 101 | 102 | if loaded_config is None: 103 | if not test_mode: 104 | print( 105 | "No valid conf file not found, will rely on defaults and " 106 | "environment variables.\n" 107 | "Config should be placed at one of '%s' or a location defined via the " 108 | "environment variable 'ADJUTANT_CONFIG_FILE'." % _config_files 109 | ) 110 | return confspirator.load_dict(_root_config, {}, test_mode=test_mode) 111 | 112 | return loaded_config 113 | 114 | 115 | CONF = _load_config() 116 | -------------------------------------------------------------------------------- /adjutant/middleware.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from time import time 16 | from logging import getLogger 17 | from django.utils import timezone 18 | 19 | 20 | class KeystoneHeaderUnwrapper: 21 | """ 22 | Middleware to build an easy to use dict of important data from 23 | what the keystone wsgi middleware gives us. 24 | """ 25 | 26 | def __init__(self, get_response): 27 | self.get_response = get_response 28 | 29 | def __call__(self, request): 30 | try: 31 | token_data = { 32 | "project_domain_id": request.headers["x-project-domain-id"], 33 | "project_name": request.headers["x-project-name"], 34 | "project_id": request.headers["x-project-id"], 35 | "roles": request.headers["x-roles"].split(","), 36 | "user_domain_id": request.headers["x-user-domain-id"], 37 | "username": request.headers["x-user-name"], 38 | "user_id": request.headers["x-user-id"], 39 | "authenticated": request.headers["x-identity-status"], 40 | } 41 | except KeyError: 42 | token_data = {} 43 | request.keystone_user = token_data 44 | 45 | response = self.get_response(request) 46 | return response 47 | 48 | 49 | class TestingHeaderUnwrapper: 50 | """ 51 | Replacement for the KeystoneHeaderUnwrapper for testing purposes. 52 | """ 53 | 54 | def __init__(self, get_response): 55 | self.get_response = get_response 56 | 57 | def __call__(self, request): 58 | try: 59 | token_data = { 60 | # TODO(adriant): follow up patch to update all the test 61 | # headers to provide domain values. 62 | # Default here is just a temporary measure. 63 | "project_domain_id": request.headers.get( 64 | "project_domain_id", "default" 65 | ), 66 | "project_name": request.headers["project_name"], 67 | "project_id": request.headers["project_id"], 68 | "roles": request.headers["roles"].split(","), 69 | "user_domain_id": request.headers.get("user_domain_id", "default"), 70 | "username": request.headers["username"], 71 | "user_id": request.headers["user_id"], 72 | "authenticated": request.headers["authenticated"], 73 | } 74 | except KeyError: 75 | token_data = {} 76 | request.keystone_user = token_data 77 | 78 | response = self.get_response(request) 79 | return response 80 | 81 | 82 | class RequestLoggingMiddleware: 83 | """ 84 | Middleware to log the requests and responses. 85 | Will time the duration of a request and log that. 86 | """ 87 | 88 | def __init__(self, get_response): 89 | self.get_response = get_response 90 | self.logger = getLogger("adjutant") 91 | 92 | def __call__(self, request): 93 | self.logger.info( 94 | "(%s) - <%s> %s [%s]", 95 | timezone.now(), 96 | request.method, 97 | request.META["REMOTE_ADDR"], 98 | request.get_full_path(), 99 | ) 100 | request.timer = time() 101 | 102 | response = self.get_response(request) 103 | 104 | if hasattr(request, "timer"): 105 | time_delta = time() - request.timer 106 | else: 107 | time_delta = -1 108 | self.logger.info( 109 | "(%s) - <%s> [%s] - (%.1fs)", 110 | timezone.now(), 111 | response.status_code, 112 | request.get_full_path(), 113 | time_delta, 114 | ) 115 | return response 116 | -------------------------------------------------------------------------------- /doc/source/release-notes.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Working with Release Notes 3 | ========================== 4 | 5 | The Adjutant team uses `reno 6 | `_ to generate release 7 | notes. These are important user-facing documents that must be included when a 8 | user or operator facing change is performed, like a bug-fix or a new feature. A 9 | release note should be included in the same patch the work is being performed. 10 | Release notes should be short, easy to read, and easy to maintain. They also 11 | `must` link back to any appropriate documentation if it exists. The following 12 | conventions help ensure all release notes achieve those goals. 13 | 14 | Most release notes either describe bug fixes or announce support for new 15 | features, both of which are tracked using Launchpad. The conventions below rely 16 | on links in Launchpad to provide readers with more context. 17 | 18 | .. warning:: 19 | 20 | We highly recommend taking careful thought when writing and reviewing 21 | release notes. Once a release note has been published with a formal 22 | release, updating it across releases will cause it to be published in a 23 | subsequent release. Reviews that update, or modify, a release note from a 24 | previous release outside of the branch it was added in should be rejected 25 | unless it's required for a very specific case. 26 | 27 | Please refer to reno's `documentation 28 | `_ for more 29 | information. 30 | 31 | Release Notes for Bugs 32 | ====================== 33 | 34 | When creating a release note that communicates a bug fix, use the story number 35 | in the name of the release note: 36 | 37 | .. code-block:: bash 38 | 39 | $ tox -e venv reno new story-1652012 40 | Created new notes file in releasenotes/notes/story-1652012-7c53b9702b10084d.yaml 41 | 42 | The body of the release note should clearly explain how the impact will affect 43 | users and operators. It should also include why the change was necessary but 44 | not be overspecific about implementation details, as that can be found in the 45 | commit and the bug report. It should contain a properly formatted link in 46 | reStructuredText that points back to the original bug report used to track the 47 | fix. This ensures the release note is kept short and to-the-point while 48 | providing readers with additional resources: 49 | 50 | .. code-block:: yaml 51 | 52 | --- 53 | fixes: 54 | - | 55 | [`bug 1652012 `_] 56 | This bug was fixed because X and we needed to maintain a certain level 57 | of backwards compatibility with the fix despite so it still defaults to 58 | an unsafe value. 59 | deprecations: 60 | - > 61 | X is now deprecated and should no longer be used. Instead use Y. 62 | 63 | 64 | Release Notes for Features 65 | ========================== 66 | 67 | Release notes detailing feature work follow the same basic format, since 68 | features are also tracked as stories. 69 | 70 | .. code-block:: bash 71 | 72 | $ tox -e venv reno new story-1652012 73 | Created new notes file in releasenotes/notes/story-1652012-7c53b9702b10084d.yaml 74 | 75 | Just like release notes communicating bug fixes, release notes detailing 76 | feature work must contain a link back to the story. Readers should be able 77 | to easily discover all patches that implement the feature, as well as find 78 | links to the full specification and documentation. The release notes can be 79 | added to the last patch of the feature. All of this is typically found in the 80 | story on storyboard: 81 | 82 | .. code-block:: yaml 83 | 84 | --- 85 | features: 86 | - > 87 | [`story 1652012 `_] 88 | We now support Q 89 | upgrade: 90 | - > 91 | We highly recommend people using W to switch to using Q 92 | 93 | In the rare case there is a release note that does not pertain to a bug or 94 | feature work, use a sensible slug and include any documentation relating to the 95 | note. We can iterate on the content and application of the release note during 96 | the review process. 97 | 98 | For more information on how and when to create release notes, see the 99 | `project-team-guide 100 | `_. 101 | -------------------------------------------------------------------------------- /adjutant/config/django.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from confspirator import groups 16 | from confspirator import fields 17 | 18 | 19 | config_group = groups.ConfigGroup("django") 20 | 21 | config_group.register_child_config( 22 | fields.StrConfig( 23 | "secret_key", 24 | help_text="The Django secret key.", 25 | required=True, 26 | default="Do not ever use this awful secret in prod!!!!", 27 | secret=True, 28 | unsafe_default=True, 29 | ) 30 | ) 31 | config_group.register_child_config( 32 | fields.BoolConfig( 33 | "debug", 34 | help_text="Django debug mode is turned on.", 35 | default=False, 36 | unsafe_default=True, 37 | ) 38 | ) 39 | config_group.register_child_config( 40 | fields.ListConfig( 41 | "allowed_hosts", 42 | help_text="The Django allowed hosts", 43 | required=True, 44 | default=["*"], 45 | unsafe_default=True, 46 | ) 47 | ) 48 | config_group.register_child_config( 49 | fields.StrConfig( 50 | "secure_proxy_ssl_header", 51 | help_text="The header representing a HTTP header/value combination " 52 | "that signifies a request is secure.", 53 | default="HTTP_X_FORWARDED_PROTO", 54 | ) 55 | ) 56 | config_group.register_child_config( 57 | fields.StrConfig( 58 | "secure_proxy_ssl_header_value", 59 | help_text="The value representing a HTTP header/value combination " 60 | "that signifies a request is secure.", 61 | default="https", 62 | ) 63 | ) 64 | config_group.register_child_config( 65 | fields.DictConfig( 66 | "databases", 67 | help_text="Django databases config.", 68 | default={ 69 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "db.sqlite3"} 70 | }, 71 | is_json=True, 72 | unsafe_default=True, 73 | ) 74 | ) 75 | config_group.register_child_config( 76 | fields.DictConfig( 77 | "logging", 78 | help_text="A full override of the Django logging config for more customised logging.", 79 | is_json=True, 80 | ) 81 | ) 82 | config_group.register_child_config( 83 | fields.StrConfig( 84 | "log_file", 85 | help_text="The name and location of the Adjutant log file, " 86 | "superceded by 'adjutant.django.logging'.", 87 | default="adjutant.log", 88 | ) 89 | ) 90 | 91 | _email_group = groups.ConfigGroup("email") 92 | _email_group.register_child_config( 93 | fields.StrConfig( 94 | "email_backend", 95 | help_text="Django email backend to use.", 96 | default="django.core.mail.backends.console.EmailBackend", 97 | required=True, 98 | ) 99 | ) 100 | _email_group.register_child_config( 101 | fields.IntConfig("timeout", help_text="Email backend timeout.") 102 | ) 103 | _email_group.register_child_config( 104 | fields.HostNameConfig("host", help_text="Email backend server location.") 105 | ) 106 | _email_group.register_child_config( 107 | fields.PortConfig("port", help_text="Email backend server port.") 108 | ) 109 | _email_group.register_child_config( 110 | fields.StrConfig("host_user", help_text="Email backend user.") 111 | ) 112 | _email_group.register_child_config( 113 | fields.StrConfig("host_password", help_text="Email backend user password.") 114 | ) 115 | _email_group.register_child_config( 116 | fields.BoolConfig( 117 | "use_tls", 118 | help_text="Whether to use TLS for email. Mutually exclusive with 'use_ssl'.", 119 | default=False, 120 | ) 121 | ) 122 | _email_group.register_child_config( 123 | fields.BoolConfig( 124 | "use_ssl", 125 | help_text="Whether to use SSL for email. Mutually exclusive with 'use_tls'.", 126 | default=False, 127 | ) 128 | ) 129 | 130 | config_group.register_child_config(_email_group) 131 | -------------------------------------------------------------------------------- /api-ref/source/parameters.yaml: -------------------------------------------------------------------------------- 1 | # variables in header 2 | X-Auth-Token: 3 | description: | 4 | A valid authentication token for a user. 5 | in: header 6 | required: true 7 | type: string 8 | 9 | # Path parameters 10 | notification_id: 11 | description: | 12 | The notification UUID, as given on list endpoints and in email correspondence. 13 | in: path 14 | required: true 15 | type: string 16 | task_id: 17 | description: | 18 | The task UUID as given in the task list and email correspondence. 19 | in: path 20 | required: true 21 | type: string 22 | token_id: 23 | description: | 24 | The token UUID, as given on the lists and in email correspondence. 25 | in: path 26 | required: true 27 | type: string 28 | user_id: 29 | description: | 30 | The user id, as seen on the ../v1/openstack/users page. Note that this 31 | is the openstack user id for confirmed users and the task ID for invited 32 | users. 33 | in: path 34 | required: true 35 | type: string 36 | 37 | 38 | # Query Parameters 39 | filters: 40 | description: | 41 | Django style filters for task, token and notification endpoints. 42 | See section `Filters` for details. 43 | in: query 44 | required: false 45 | type: dictionary 46 | page: 47 | description: | 48 | Page number to access, starts at and defaults to 1. 49 | in: query 50 | required: false 51 | type: int 52 | project_name: 53 | description: | 54 | Name for the new project. 55 | in: query 56 | required: true 57 | type: string 58 | region: 59 | description: | 60 | Region to perform actions in. 61 | in: query 62 | required: true 63 | type: string 64 | setup_network: 65 | description: | 66 | Whether or not to setup a default network for a new project 67 | in: query 68 | required: true 69 | type: boolean 70 | tasks_per_page: 71 | description: | 72 | Limit on the tasks viewed on each page. 73 | in: query 74 | required: false 75 | type: int 76 | 77 | 78 | # Body Parameters 79 | acknowledged: 80 | description: | 81 | Confirmation for acknowledging a notification. 82 | in: body 83 | required: true 84 | type: boolean 85 | approved: 86 | description: | 87 | Confirmation to approve a task. 88 | in: body 89 | required: true 90 | type: boolean 91 | email: 92 | description: | 93 | New user email address. 94 | in: body 95 | required: true 96 | type: string 97 | email_password: 98 | description: | 99 | Email address for the user whose password needs resetting 100 | in: body 101 | required: true 102 | type: string 103 | email_signup: 104 | description: | 105 | Email address for the default user and project admin. 106 | in: body 107 | required: true 108 | type: string 109 | notifications: 110 | description: | 111 | List of notification UUIDs to acknowledge 112 | in: body 113 | required: true 114 | type: array 115 | regions: 116 | description: | 117 | Regions to perform actions in. 118 | in: body 119 | required: false 120 | type: array 121 | roles: 122 | description: | 123 | List of roles for the user. 124 | in: body 125 | required: true 126 | type: array 127 | size: 128 | description: | 129 | Which size out of the selections shown in the quota get request should 130 | the region(s) be updated to. 131 | in: body 132 | required: true 133 | type: string 134 | task_data: 135 | description: | 136 | A dictionary replacing all the data for a task. See the task details 137 | for what values should be included 138 | in: body 139 | required: true 140 | type: dictionary 141 | task_id_body: 142 | description: | 143 | The task UUID as given in the task list and email correspondence. 144 | in: body 145 | required: true 146 | type: int 147 | token_data: 148 | description: | 149 | A dictionary replacing all the data for a task. Use the token get request 150 | to see what should needs to be included. 151 | in: body 152 | required: true 153 | type: dictionary 154 | username: 155 | description: | 156 | New user username, required only if USERNAME_IS_EMAIL is false. 157 | in: body 158 | required: false 159 | type: string 160 | username_password: 161 | description: | 162 | Username, required only if USERNAME_IS_EMAIL is false. 163 | in: body 164 | required: false 165 | type: string 166 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Adjutant documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Jun 21 13:29:33 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # import os 20 | # import sys 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ["openstackdocstheme"] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ["_templates"] 37 | 38 | # openstackdocstheme settings 39 | openstackdocs_repo_name = "openstack/adjutant" 40 | openstackdocs_auto_name = False 41 | html_theme = "openstackdocs" 42 | openstackdocs_use_storyboard = True 43 | 44 | # The suffix(es) of source filenames. 45 | # You can specify multiple suffix as a list of string: 46 | # 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = ".rst" 49 | 50 | # The master toctree document. 51 | master_doc = "index" 52 | 53 | # General information about the project. 54 | project = "Adjutant" 55 | copyright = "2017, Catalyst IT Ltd" 56 | 57 | 58 | # List of patterns, relative to source directory, that match files and 59 | # directories to ignore when looking for source files. 60 | # This patterns also effect to html_static_path and html_extra_path 61 | exclude_patterns = [] 62 | 63 | # The name of the Pygments (syntax highlighting) style to use. 64 | pygments_style = "native" 65 | 66 | # If true, `todo` and `todoList` produce output, else they produce nothing. 67 | todo_include_todos = False 68 | 69 | 70 | # -- Options for HTML output ---------------------------------------------- 71 | 72 | # The theme to use for HTML and HTML Help pages. See the documentation for 73 | # a list of builtin themes. 74 | # 75 | # html_theme = 'sphinx_rtd_theme' 76 | 77 | # Add any paths that contain custom themes here, relative to this directory. 78 | # html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() 79 | 80 | # Theme options are theme-specific and customize the look and feel of a theme 81 | # further. For a list of options available for each theme, see the 82 | # documentation. 83 | # 84 | # html_theme_options = {} 85 | 86 | # Add any paths that contain custom static files (such as style sheets) here, 87 | # relative to this directory. They are copied after the builtin static files, 88 | # so a file named "default.css" will overwrite the builtin "default.css". 89 | # html_static_path = ['_static'] 90 | 91 | 92 | # -- Options for HTMLHelp output ------------------------------------------ 93 | 94 | # Output file base name for HTML help builder. 95 | htmlhelp_basename = "Adjutantdoc" 96 | 97 | 98 | # -- Options for LaTeX output --------------------------------------------- 99 | 100 | # Grouping the document tree into LaTeX files. List of tuples 101 | # (source start file, target name, title, 102 | # author, documentclass [howto, manual, or own class]). 103 | latex_documents = [ 104 | (master_doc, "Adjutant.tex", "Adjutant Documentation", "Catalyst IT Ltd", "manual"), 105 | ] 106 | 107 | 108 | # -- Options for manual page output --------------------------------------- 109 | 110 | # One entry per manual page. List of tuples 111 | # (source start file, name, description, authors, manual section). 112 | man_pages = [(master_doc, "adjutant", "Adjutant Documentation", ["Catalyst IT Ltd"], 1)] 113 | 114 | 115 | # -- Options for Texinfo output ------------------------------------------- 116 | 117 | # Grouping the document tree into Texinfo files. List of tuples 118 | # (source start file, target name, title, author, 119 | # dir menu entry, description, category) 120 | texinfo_documents = [ 121 | ( 122 | master_doc, 123 | "Adjutant", 124 | "Adjutant Documentation", 125 | "Catalyst Cloud", 126 | "Adjutant", 127 | "One line description of project.", 128 | "Miscellaneous", 129 | ), 130 | ] 131 | -------------------------------------------------------------------------------- /adjutant/config/identity.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from confspirator import groups 16 | from confspirator import fields 17 | from confspirator import types 18 | 19 | 20 | config_group = groups.ConfigGroup("identity") 21 | 22 | config_group.register_child_config( 23 | fields.IntConfig( 24 | "token_cache_time", 25 | help_text="Cache time for Keystone Tokens in the Keystone Middleware.", 26 | default=-1, 27 | required=True, 28 | required_for_tests=False, 29 | ) 30 | ) 31 | config_group.register_child_config( 32 | fields.BoolConfig( 33 | "can_edit_users", 34 | help_text="Is Adjutant allowed (or able) to edit users in Keystone.", 35 | default=True, 36 | ) 37 | ) 38 | config_group.register_child_config( 39 | fields.BoolConfig( 40 | "username_is_email", 41 | help_text="Should Adjutant assume and treat all usernames as emails.", 42 | default=True, 43 | ) 44 | ) 45 | config_group.register_child_config( 46 | fields.DictConfig( 47 | "role_mapping", 48 | help_text="A mapping from held role to roles it is allowed to manage.", 49 | value_type=types.List(), 50 | check_value_type=True, 51 | is_json=True, 52 | default={ 53 | "admin": [ 54 | "project_admin", 55 | "project_mod", 56 | "heat_stack_owner", 57 | "member", 58 | ], 59 | "project_admin": [ 60 | "project_admin", 61 | "project_mod", 62 | "heat_stack_owner", 63 | "member", 64 | ], 65 | "project_mod": [ 66 | "project_mod", 67 | "heat_stack_owner", 68 | "member", 69 | ], 70 | }, 71 | test_default={ 72 | "admin": ["project_admin", "project_mod", "member", "heat_stack_owner"], 73 | "project_admin": [ 74 | "project_mod", 75 | "member", 76 | "heat_stack_owner", 77 | "project_admin", 78 | ], 79 | "project_mod": ["member", "heat_stack_owner", "project_mod"], 80 | }, 81 | ) 82 | ) 83 | 84 | _auth_group = groups.ConfigGroup("auth") 85 | _auth_group.register_child_config( 86 | fields.StrConfig( 87 | "username", 88 | help_text="Username for Adjutant Keystone admin user.", 89 | required=True, 90 | required_for_tests=False, 91 | ) 92 | ) 93 | _auth_group.register_child_config( 94 | fields.StrConfig( 95 | "password", 96 | help_text="Password for Adjutant Keystone admin user.", 97 | required=True, 98 | secret=True, 99 | required_for_tests=False, 100 | ) 101 | ) 102 | _auth_group.register_child_config( 103 | fields.StrConfig( 104 | "project_name", 105 | help_text="Project name for Adjutant Keystone admin user.", 106 | required=True, 107 | required_for_tests=False, 108 | ) 109 | ) 110 | _auth_group.register_child_config( 111 | fields.StrConfig( 112 | "project_domain_id", 113 | help_text="Project domain id for Adjutant Keystone admin user.", 114 | default="default", 115 | required=True, 116 | required_for_tests=False, 117 | ) 118 | ) 119 | _auth_group.register_child_config( 120 | fields.StrConfig( 121 | "user_domain_id", 122 | help_text="User domain id for Adjutant Keystone admin user.", 123 | default="default", 124 | required=True, 125 | required_for_tests=False, 126 | ) 127 | ) 128 | _auth_group.register_child_config( 129 | fields.URIConfig( 130 | "auth_url", 131 | help_text="Keystone auth url that Adjutant will use.", 132 | schemes=["https", "http"], 133 | required=True, 134 | required_for_tests=False, 135 | ) 136 | ) 137 | _auth_group.register_child_config( 138 | fields.StrConfig( 139 | "interface", 140 | help_text="Keystone endpoint interface type.", 141 | default="public", 142 | required=True, 143 | ) 144 | ) 145 | config_group.register_child_config(_auth_group) 146 | -------------------------------------------------------------------------------- /adjutant/notifications/v1/tests/test_notifications.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from unittest import mock 16 | 17 | from confspirator.tests import utils as conf_utils 18 | from django.core import mail 19 | from rest_framework import status 20 | 21 | from adjutant.api.models import Notification 22 | from adjutant.tasks.models import Task 23 | from adjutant.common.tests.fake_clients import FakeManager, setup_identity_cache 24 | from adjutant.common.tests.utils import AdjutantAPITestCase 25 | from adjutant.config import CONF 26 | from adjutant import exceptions 27 | 28 | 29 | @mock.patch("adjutant.common.user_store.IdentityManager", FakeManager) 30 | @conf_utils.modify_conf( 31 | CONF, 32 | operations={ 33 | "adjutant.workflow.tasks.create_project_and_user.notifications": [ 34 | { 35 | "operation": "override", 36 | "value": { 37 | "standard_handlers": ["EmailNotification"], 38 | "error_handlers": ["EmailNotification"], 39 | "standard_handler_config": { 40 | "EmailNotification": { 41 | "emails": ["example_notification@example.com"], 42 | "reply": "no-reply@example.com", 43 | } 44 | }, 45 | "error_handler_config": { 46 | "EmailNotification": { 47 | "emails": ["example_error_notification@example.com"], 48 | "reply": "no-reply@example.com", 49 | } 50 | }, 51 | }, 52 | }, 53 | ], 54 | }, 55 | ) 56 | class NotificationTests(AdjutantAPITestCase): 57 | def test_new_project_sends_notification(self): 58 | """ 59 | Confirm that the email notification handler correctly acknowledges 60 | notifications it sends out. 61 | 62 | This tests standard and error notifications. 63 | """ 64 | setup_identity_cache() 65 | 66 | url = "/v1/openstack/sign-up" 67 | data = {"project_name": "test_project", "email": "test@example.com"} 68 | response = self.client.post(url, data, format="json") 69 | self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) 70 | 71 | new_task = Task.objects.all()[0] 72 | self.assertEqual(Notification.objects.count(), 1) 73 | self.assertEqual(len(mail.outbox), 2) 74 | self.assertEqual(mail.outbox[1].subject, "create_project_and_user notification") 75 | self.assertEqual(mail.outbox[1].to, ["example_notification@example.com"]) 76 | 77 | notif = Notification.objects.all()[0] 78 | self.assertEqual(notif.task.uuid, new_task.uuid) 79 | self.assertFalse(notif.error) 80 | self.assertTrue(notif.acknowledged) 81 | 82 | headers = { 83 | "project_name": "test_project", 84 | "project_id": "test_project_id", 85 | "roles": "admin,member", 86 | "username": "test@example.com", 87 | "user_id": "test_user_id", 88 | "authenticated": True, 89 | } 90 | url = "/v1/tasks/" + new_task.uuid 91 | with mock.patch( 92 | "adjutant.common.tests.fake_clients.FakeManager.find_project" 93 | ) as mocked_find: 94 | mocked_find.side_effect = exceptions.ServiceUnavailable( 95 | "Forced key error for testing." 96 | ) 97 | response = self.client.post( 98 | url, {"approved": True}, format="json", headers=headers 99 | ) 100 | 101 | # should send token email, but no new notification 102 | self.assertEqual(Notification.objects.count(), 2) 103 | self.assertEqual(len(mail.outbox), 3) 104 | self.assertEqual( 105 | mail.outbox[2].subject, "Error - create_project_and_user notification" 106 | ) 107 | self.assertEqual(mail.outbox[2].to, ["example_error_notification@example.com"]) 108 | 109 | notif = Notification.objects.all()[1] 110 | self.assertEqual(notif.task.uuid, new_task.uuid) 111 | self.assertTrue(notif.error) 112 | self.assertTrue(notif.acknowledged) 113 | -------------------------------------------------------------------------------- /doc/source/history.rst: -------------------------------------------------------------------------------- 1 | Project History 2 | =============== 3 | 4 | Adjutant was started by Catalyst Cloud to fill our needs around missing 5 | features in OpenStack. Catalyst Cloud is public cloud provider based in New 6 | Zealand with a strong focus on Open Source, with a team of operators and 7 | developers who are all contributors to OpenStack itself. 8 | 9 | Early prototyping for Adjutant began at the end of 2015 to fill some of the 10 | missing pieces we needed as a public cloud provider. It was initially something 11 | we had started designing as far back as early 2014, with the scope and design 12 | changing many times until initial prototyping and implementation was started in 13 | late 2015. 14 | 15 | Originally it was designed to act as a service to manage customers, their 16 | users, projects, quotas, and to be able to process signups and initial resource 17 | creation for new customers. It would act as a layer above OpenStack and most 18 | non-authentication based identity management from a user perspective would 19 | happen through it, with the service itself making the appropriate changes to 20 | the underlying OpenStack services and resources. The reason why it didn't end 21 | up quite so complex and so big is because OpenStack itself, and many of the 22 | services (and the future roadmap) had planned solutions to many of the things 23 | we wanted, and our business requirements changed to storing our customer 24 | information in an external ERP system (Odoo/OpenERP at the time) rather than a 25 | standalone service. 26 | 27 | So instead of something so grand, we tried smaller, a service that handles our 28 | unique public cloud requirements for signup. It should take in signup data from 29 | some source such as a public API that our website posts to, then validate it, 30 | give us those validation notes, and lets us decide if we wanted that customer, 31 | or even have the system itself based on certain criteria approve that customer 32 | itself. It would then create the user in Keystone, create a project, give them 33 | access, create a default network, and then also link and store the customer 34 | data in our ERP and billing systems. This was the initial 35 | 'openstack-registration' project, and the naming of which is present in our git 36 | history, and where the initial service-type name comes from. 37 | 38 | As we prototyped this we realised that it was just a workflow system for 39 | business logic, so we decided to make it flexible, and found other things we 40 | could use it for: 41 | 42 | - Allowing non-admin users to invite other users to their project. 43 | - Let users reset their password by sending an email token to them. 44 | - Manage and create child-projects in a single domain environment. 45 | - Request quota increases for their projects. 46 | 47 | All with the optional step of actually requiring an admin to approve the user 48 | request if needed. And with a good audit trail as to where the actions came 49 | from, and who approved them. 50 | 51 | Eventually it also got a rename, because calling it OpenStack Registration got 52 | dull and wasn't accurate anymore. The name it got at the time was StackTask, 53 | and there are still elements of that naming in our systems, and plenty in the 54 | git history. Eventually we would rename it again because the name still being 55 | feel right, and was too close to StackTach. 56 | 57 | Around that time we also added plugin support to try and keep any company 58 | specific code out of the core codebase, and in the process realised just how 59 | much further flexibility we'd now added. 60 | 61 | The service gave us an easy way to build APIs around workflow we wanted our 62 | customers to be able to trigger around larger normally unsafe admin APIs in 63 | OpenStack itself. With the ability to have those workflows do changes to our 64 | ERP system, and other external systems. It gave us the missing glue we needed 65 | to make our public cloud business requirements and logic actually work. 66 | 67 | But we were always clear, that if something made better sense as a feature in 68 | another service, we should implemented in that other service. This was meant to 69 | be a glue layer, or potentially for mini API features that don't entirely have 70 | a good place for them, or just a wrapper around an existing OpenStack feature 71 | that needs organisation specific logic added to it. 72 | 73 | Throughout all this, the goal was always to keep this project fully opensource, 74 | to invite external contribution, to do our planning, bug tracking, and 75 | development where the OpenStack community could see and be transparent about 76 | our own internal usage of the service and our plans for it. The code had been 77 | on our company github for a while, but it was time to move it somewhere better. 78 | 79 | So, in 2017 we renamed again, and then moved all the core repos to OpenStack 80 | infrastructure, as well as the code review, bug, and spec tracking. 81 | 82 | Adjutant, in its current form is the culmination of that process, and while 83 | the core driving force behind Adjutant was our own needs, it always was the 84 | intention to provide Adjutant for anyone to build and use themselves so that 85 | their effort isn't wasted treading the same ground. 86 | -------------------------------------------------------------------------------- /adjutant/settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | """ 16 | Django settings for Adjutant. 17 | 18 | For more information on this file, see 19 | https://docs.djangoproject.com/en/1.11/topics/settings/ 20 | 21 | For the full list of settings and their values, see 22 | https://docs.djangoproject.com/en/1.11/ref/settings/ 23 | """ 24 | 25 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 26 | import os 27 | import sys 28 | 29 | from adjutant.config import CONF as adj_conf 30 | 31 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = ( 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "rest_framework", 42 | "adjutant.commands", 43 | "adjutant.actions", 44 | "adjutant.api", 45 | "adjutant.notifications", 46 | "adjutant.tasks", 47 | "adjutant.startup.config.StartUpConfig", 48 | ) 49 | 50 | MIDDLEWARE = ( 51 | "django.middleware.common.CommonMiddleware", 52 | "adjutant.middleware.KeystoneHeaderUnwrapper", 53 | "adjutant.middleware.RequestLoggingMiddleware", 54 | ) 55 | 56 | if "test" in sys.argv: 57 | # modify MIDDLEWARE 58 | MIDDLEWARE = list(MIDDLEWARE) 59 | MIDDLEWARE.remove("adjutant.middleware.KeystoneHeaderUnwrapper") 60 | MIDDLEWARE.append("adjutant.middleware.TestingHeaderUnwrapper") 61 | 62 | ROOT_URLCONF = "adjutant.urls" 63 | 64 | WSGI_APPLICATION = "adjutant.wsgi.application" 65 | 66 | LANGUAGE_CODE = "en-us" 67 | 68 | TIME_ZONE = "UTC" 69 | 70 | USE_I18N = True 71 | 72 | USE_TZ = True 73 | 74 | STATIC_URL = "/static/" 75 | 76 | TEMPLATES = [ 77 | { 78 | "BACKEND": "django.template.backends.django.DjangoTemplates", 79 | "APP_DIRS": True, 80 | "NAME": "default", 81 | }, 82 | { 83 | "BACKEND": "django.template.backends.django.DjangoTemplates", 84 | "APP_DIRS": True, 85 | "DIRS": ["/etc/adjutant/templates/"], 86 | "NAME": "include_etc_templates", 87 | }, 88 | ] 89 | 90 | AUTHENTICATION_BACKENDS = [] 91 | 92 | REST_FRAMEWORK = { 93 | "EXCEPTION_HANDLER": "adjutant.api.exception_handler.exception_handler", 94 | "DEFAULT_RENDERER_CLASSES": [ 95 | "rest_framework.renderers.JSONRenderer", 96 | ], 97 | "DEFAULT_PARSER_CLASSES": [ 98 | "rest_framework.parsers.JSONParser", 99 | ], 100 | "DEFAULT_PERMISSION_CLASSES": [], 101 | } 102 | 103 | SECRET_KEY = adj_conf.django.secret_key 104 | 105 | # SECURITY WARNING: don't run with debug turned on in production! 106 | DEBUG = adj_conf.django.debug 107 | if DEBUG: 108 | REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"].append( 109 | "rest_framework.renderers.BrowsableAPIRenderer" 110 | ) 111 | 112 | ALLOWED_HOSTS = adj_conf.django.allowed_hosts 113 | 114 | SECURE_PROXY_SSL_HEADER = ( 115 | adj_conf.django.secure_proxy_ssl_header, 116 | adj_conf.django.secure_proxy_ssl_header_value, 117 | ) 118 | 119 | DATABASES = adj_conf.django.databases 120 | 121 | if adj_conf.django.logging: 122 | LOGGING = adj_conf.django.logging 123 | else: 124 | LOGGING = { 125 | "version": 1, 126 | "disable_existing_loggers": False, 127 | "handlers": { 128 | "file": { 129 | "level": "INFO", 130 | "class": "logging.FileHandler", 131 | "filename": adj_conf.django.log_file, 132 | }, 133 | }, 134 | "loggers": { 135 | "adjutant": { 136 | "handlers": ["file"], 137 | "level": "INFO", 138 | "propagate": False, 139 | }, 140 | "django": { 141 | "handlers": ["file"], 142 | "level": "INFO", 143 | "propagate": False, 144 | }, 145 | "keystonemiddleware": { 146 | "handlers": ["file"], 147 | "level": "INFO", 148 | "propagate": False, 149 | }, 150 | }, 151 | } 152 | 153 | 154 | EMAIL_BACKEND = adj_conf.django.email.email_backend 155 | EMAIL_TIMEOUT = adj_conf.django.email.timeout 156 | 157 | EMAIL_HOST = adj_conf.django.email.host 158 | EMAIL_PORT = adj_conf.django.email.port 159 | EMAIL_HOST_USER = adj_conf.django.email.host_user 160 | EMAIL_HOST_PASSWORD = adj_conf.django.email.host_password 161 | EMAIL_USE_TLS = adj_conf.django.email.use_tls 162 | EMAIL_USE_SSL = adj_conf.django.email.use_ssl 163 | -------------------------------------------------------------------------------- /adjutant/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from rest_framework import status 16 | 17 | 18 | class BaseServiceException(Exception): 19 | """Configuration, or core service logic has had an error 20 | 21 | This is an internal only exception and should only be thrown 22 | when and error occurs that the user shouldn't see. 23 | 24 | If thrown during the course of an API call will be caught and returned 25 | to the user as an ServiceUnavailable error with a 503 response. 26 | """ 27 | 28 | default_message = "A internal service error has occured." 29 | 30 | def __init__(self, message=None): 31 | self.message = message 32 | 33 | def __str__(self): 34 | return self.message or self.default_message 35 | 36 | 37 | class InvalidActionClass(BaseServiceException): 38 | default_message = "Cannot register action not built off the BaseAction class." 39 | 40 | 41 | class InvalidActionSerializer(BaseServiceException): 42 | default_message = "Action serializer must be a valid DRF serializer." 43 | 44 | 45 | class InvalidTaskClass(BaseServiceException): 46 | default_message = "Action serializer must be a valid DRF serializer." 47 | 48 | 49 | class InvalidAPIClass(BaseServiceException): 50 | default_message = "Cannot register task not built off the BaseTask class." 51 | 52 | 53 | class DelegateAPINotRegistered(BaseServiceException): 54 | default_message = "Failed to setup DelegateAPI that has not been registered." 55 | 56 | 57 | class TaskNotRegistered(BaseServiceException): 58 | default_message = "Failed to setup Task that has not been registered." 59 | 60 | 61 | class ActionNotRegistered(BaseServiceException): 62 | default_message = "Failed to setup Action that has not been registered." 63 | 64 | 65 | class SerializerMissingException(BaseServiceException): 66 | default_message = "Serializer configured but it does not exist." 67 | 68 | 69 | class ConfigurationException(BaseServiceException): 70 | default_message = "Missing or incorrect configuration value." 71 | 72 | 73 | class BaseAPIException(Exception): 74 | """An Task error occurred.""" 75 | 76 | status_code = status.HTTP_400_BAD_REQUEST 77 | 78 | def __init__(self, message=None, internal_message=None): 79 | if message: 80 | self.message = message 81 | else: 82 | self.message = self.default_message 83 | self.internal_message = internal_message 84 | 85 | def __str__(self): 86 | message = "" 87 | if self.internal_message: 88 | message = "%s - " % self.internal_message 89 | message += str(self.message) 90 | return message 91 | 92 | 93 | class NotFound(BaseAPIException): 94 | status_code = status.HTTP_404_NOT_FOUND 95 | default_message = "Not found." 96 | 97 | 98 | class TaskNotFound(NotFound): 99 | status_code = status.HTTP_404_NOT_FOUND 100 | default_message = "Task not found." 101 | 102 | 103 | class ServiceUnavailable(BaseAPIException): 104 | status_code = status.HTTP_503_SERVICE_UNAVAILABLE 105 | default_message = "Service temporarily unavailable, try again later." 106 | 107 | 108 | class TaskSerializersInvalid(BaseAPIException): 109 | default_message = "Data passed to the Task was invalid." 110 | 111 | 112 | class TaskDuplicateFound(BaseAPIException): 113 | default_message = "This Task already exists." 114 | status_code = status.HTTP_409_CONFLICT 115 | 116 | 117 | class BaseTaskException(BaseAPIException): 118 | default_message = "An Task error occurred." 119 | status_code = status.HTTP_400_BAD_REQUEST 120 | 121 | def __init__(self, task, message=None, internal_message=None): 122 | super(BaseTaskException, self).__init__(message, internal_message) 123 | self.task = task 124 | 125 | def __str__(self): 126 | message = "%s (%s) - " % (self.task.task_type, self.task.uuid) 127 | message += super(BaseTaskException, self).__str__() 128 | return message 129 | 130 | 131 | class TaskTokenSerializersInvalid(BaseTaskException): 132 | default_message = "Data passed for the Task token was invalid." 133 | 134 | 135 | class TaskActionsInvalid(BaseTaskException): 136 | default_message = "One or more of the Task actions was invalid." 137 | 138 | 139 | class TaskStateInvalid(BaseTaskException): 140 | default_message = "Action does is not possible on task in current state." 141 | 142 | 143 | class TaskActionsFailed(BaseTaskException): 144 | """For use when Task processing fails and we want to wrap that.""" 145 | 146 | status_code = status.HTTP_503_SERVICE_UNAVAILABLE 147 | default_message = "Service temporarily unavailable, try again later." 148 | -------------------------------------------------------------------------------- /adjutant/tasks/models.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from django.db import models 16 | from uuid import uuid4 17 | from django.utils import timezone 18 | from jsonfield import JSONField 19 | 20 | from adjutant.config import CONF 21 | from adjutant import exceptions 22 | from adjutant import tasks 23 | 24 | 25 | def hex_uuid(): 26 | return uuid4().hex 27 | 28 | 29 | class Task(models.Model): 30 | """ 31 | Wrapper object for the request and related actions. 32 | Stores the state of the Task and a log for the 33 | action. 34 | """ 35 | 36 | uuid = models.CharField(max_length=32, default=hex_uuid, primary_key=True) 37 | hash_key = models.CharField(max_length=64) 38 | 39 | # who is this: 40 | keystone_user = JSONField(default={}) 41 | project_id = models.CharField(max_length=64, null=True) 42 | 43 | # keystone_user for the approver: 44 | approved_by = JSONField(default={}) 45 | 46 | # type of the task, for easy grouping 47 | task_type = models.CharField(max_length=100) 48 | 49 | # task level notes 50 | task_notes = JSONField(default=[]) 51 | 52 | # Effectively a log of what the actions are doing. 53 | action_notes = JSONField(default={}) 54 | 55 | cancelled = models.BooleanField(default=False) 56 | approved = models.BooleanField(default=False) 57 | completed = models.BooleanField(default=False) 58 | 59 | created_on = models.DateTimeField(default=timezone.now) 60 | approved_on = models.DateTimeField(null=True) 61 | completed_on = models.DateTimeField(null=True) 62 | 63 | class Meta: 64 | indexes = [ 65 | models.Index(fields=["completed"], name="completed_idx"), 66 | models.Index(fields=["project_id", "uuid"]), 67 | models.Index(fields=["project_id", "task_type"]), 68 | models.Index(fields=["project_id", "task_type", "cancelled"]), 69 | models.Index(fields=["project_id", "task_type", "completed", "cancelled"]), 70 | models.Index(fields=["hash_key", "completed", "cancelled"]), 71 | ] 72 | 73 | def __init__(self, *args, **kwargs): 74 | super(Task, self).__init__(*args, **kwargs) 75 | # in memory dict to be used for passing data between actions: 76 | self.cache = {} 77 | 78 | def get_task(self): 79 | """Returns self as the appropriate task wrapper type.""" 80 | try: 81 | return tasks.TASK_CLASSES[self.task_type](task_model=self) 82 | except KeyError: 83 | # TODO(adriant): Maybe we should handle this better 84 | # for older deprecated tasks: 85 | raise exceptions.TaskNotRegistered( 86 | "Task type '%s' not registered, " 87 | "and used for existing task." % self.task_type 88 | ) 89 | 90 | @property 91 | def config(self): 92 | try: 93 | task_conf = CONF.workflow.tasks[self.task_type] 94 | except KeyError: 95 | task_conf = {} 96 | return CONF.workflow.task_defaults.overlay(task_conf) 97 | 98 | @property 99 | def actions(self): 100 | return self.action_set.order_by("order") 101 | 102 | @property 103 | def tokens(self): 104 | return self.token_set.all() 105 | 106 | @property 107 | def notifications(self): 108 | return self.notification_set.all() 109 | 110 | def to_dict(self): 111 | actions = [] 112 | for action in self.actions: 113 | actions.append( 114 | { 115 | "action_name": action.action_name, 116 | "data": action.action_data, 117 | "valid": action.valid, 118 | } 119 | ) 120 | 121 | return { 122 | "uuid": self.uuid, 123 | "keystone_user": self.keystone_user, 124 | "approved_by": self.approved_by, 125 | "project_id": self.project_id, 126 | "actions": actions, 127 | "task_type": self.task_type, 128 | "task_notes": self.task_notes, 129 | "action_notes": self.action_notes, 130 | "cancelled": self.cancelled, 131 | "approved": self.approved, 132 | "completed": self.completed, 133 | "created_on": self.created_on, 134 | "approved_on": self.approved_on, 135 | "completed_on": self.completed_on, 136 | } 137 | 138 | def add_task_note(self, note): 139 | self.task_notes.append(note) 140 | self.save() 141 | 142 | def add_action_note(self, action, note): 143 | if action in self.action_notes: 144 | self.action_notes[action].append(note) 145 | else: 146 | self.action_notes[action] = [note] 147 | self.save() 148 | -------------------------------------------------------------------------------- /doc/source/guide-lines.rst: -------------------------------------------------------------------------------- 1 | Project Guide Lines 2 | =================== 3 | 4 | Because of the extremely vague scope of the Adjutant project, we need to have 5 | some sensible guides lines to help us define what isn't part of it, and what 6 | should or could be. 7 | 8 | Adjutant is a service to let cloud providers build workflow around certain 9 | actions, or to build smaller APIs around existing things in OpenStack. Or even 10 | APIs to integrate with OpenStack, but do actions in external systems. 11 | 12 | Ultimately Adjutant is a Django project with a few limitations, and the feature 13 | set system probably exposes too much extra functionality which can be added. 14 | Some of this we plan to cut down, and throw in some explicitly defined 15 | limitations, but even with the planned limitations the framework will always 16 | be very flexible. 17 | 18 | 19 | Should a feature become part of core 20 | ++++++++++++++++++++++++++++++++++++ 21 | 22 | Core Adjutant is mostly two parts. The first is the underlying workflow system, 23 | the APIs associated that, and the notifications. The second is the provider 24 | configurable APIs. This separation will increase further as we try and distance 25 | the workflow layer away from having one task linked to a view. 26 | 27 | Anything that is a useful improvement to the task workflow framework and the 28 | associated APIs and notifications system, is always useful for core. As part of 29 | this we do include, and plan to keep adding to, a collection of generally 30 | useful actions and tasks. For those we need to be clear what should be part of 31 | core. 32 | 33 | 1. Is the action one that better makes sense as a feature in one of the 34 | existing services in OpenStack? If so, we should add it there, and then 35 | build an action in Adjutant that calls this new API or feature. 36 | 2. Is the action you want to add one that is useful or potentially useful to 37 | any cloud provider? If it is too specific, it should not be added to core. 38 | 3. Is the action you want to add talking to system outside of Adjutant itself 39 | or outside of OpenStack? If either, then it should not be added to core. 40 | 4. Is the task (a combination of actions), doing something that is already in 41 | some fashion in OpenStack, or better suited to be a feature in another 42 | OpenStack service. If so, it does not belong in core. 43 | 44 | In addition to that, we include a collection of generally useful API views 45 | which expose certain underlying tasks as part of the workflow framework. These 46 | also need clarification as to when they should be in core. These are mostly a 47 | way to build smaller APIs that cloud users can use consume that underneath are 48 | using Adjutant's workflow framework. Or often build APIs that expose useful 49 | wrappers or supplementary logic around existing OpenStack APIs and features. 50 | 51 | 1. Is the API you are building something that makes better sense as a feature 52 | in one of the other existing OpenStack services? If so, it doesn't belong in 53 | Adjutant core. 54 | 2. Does the API query systems outside of Adjutant or OpenStack? Or rely on 55 | actions or tasks that also need to consume systems outside of Adjutant or 56 | OpenStack. 57 | 58 | 59 | .. note:: 60 | 61 | If an action, task, or API doesn't fit in core, it may fit in a external feature 62 | set, potentially even one that is maintained by the core team. If a feature isn't 63 | yet present in OpenStack that we can build in Adjutant quickly, we can do so 64 | as a semi-official feature set with the knowledge that we plan to deprecate that 65 | feature when it becomes present in OpenStack proper. In addition this process 66 | allows us to potentially allow providers to expose a variant of the feature 67 | if they are running older versions of OpenStack that don't entirely support 68 | it, but Adjutant could via the feature set mechanism. This gives us a large amount 69 | of flexibility, while ensuring we aren't reinventing the wheel. 70 | 71 | 72 | Appropriate locations for types of logic in Adjutant 73 | ++++++++++++++++++++++++++++++++++++++++++++++++++++ 74 | 75 | In Adjutant there are different elements of the system that are better suited 76 | to certain types of logic either because of what they expose, or what level of 77 | auditability is appropriate for a given area. 78 | 79 | Actions and Tasks 80 | ***************** 81 | 82 | Actions and Tasks (collections of actions), have no real constraint. An action 83 | can do anything, and needs a high level of flexibility. Given that is the cases 84 | they should ideally have sensible validation built in, and should log what 85 | they'd done so it can be audited. 86 | 87 | Pluggable APIs 88 | ************** 89 | 90 | Within the pluggable APIs, there should never be any logic that changes 91 | resources outside of Adjutant. They should either only change Adjutant internal 92 | resources (such as cancel a task), or query and return data. Building an API 93 | which can return a complex query across multiple OpenStack services is fine, 94 | but if a resource in any of those services needs to be changed, that should 95 | always be done by triggering an underlying task workflow. This keeps the logic 96 | clean, and the changes auditable. 97 | 98 | .. warning:: 99 | 100 | Anyone writing feature sets that break the above convention will not be 101 | supported. We may help and encourage you to move to using the underlying 102 | workflows, but the core team won't help you troubleshoot any logic that isn't 103 | in the right place. 104 | -------------------------------------------------------------------------------- /adjutant/config/quota.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 Catalyst Cloud Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from confspirator import groups 16 | from confspirator import fields 17 | from confspirator import types 18 | 19 | 20 | DEFAULT_QUOTA_SIZES = { 21 | "small": { 22 | "nova": { 23 | "instances": 10, 24 | "cores": 20, 25 | "ram": 65536, 26 | "floating_ips": 10, 27 | "fixed_ips": 0, 28 | "metadata_items": 128, 29 | "injected_files": 5, 30 | "injected_file_content_bytes": 10240, 31 | "key_pairs": 50, 32 | "security_groups": 20, 33 | "security_group_rules": 100, 34 | }, 35 | "cinder": { 36 | "gigabytes": 5000, 37 | "snapshots": 50, 38 | "volumes": 20, 39 | }, 40 | "neutron": { 41 | "floatingip": 10, 42 | "network": 3, 43 | "port": 50, 44 | "router": 3, 45 | "security_group": 20, 46 | "security_group_rule": 100, 47 | "subnet": 3, 48 | }, 49 | "octavia": { 50 | "health_monitor": 5, 51 | "listener": 1, 52 | "load_balancer": 1, 53 | "member": 2, 54 | "pool": 1, 55 | }, 56 | "trove": { 57 | "instances": 3, 58 | "volumes": 3, 59 | "backups": 15, 60 | }, 61 | }, 62 | "medium": { 63 | "cinder": {"gigabytes": 10000, "volumes": 100, "snapshots": 300}, 64 | "nova": { 65 | "metadata_items": 128, 66 | "injected_file_content_bytes": 10240, 67 | "ram": 327680, 68 | "floating_ips": 25, 69 | "key_pairs": 50, 70 | "instances": 50, 71 | "security_group_rules": 400, 72 | "injected_files": 5, 73 | "cores": 100, 74 | "fixed_ips": 0, 75 | "security_groups": 50, 76 | }, 77 | "neutron": { 78 | "security_group_rule": 400, 79 | "subnet": 5, 80 | "network": 5, 81 | "floatingip": 25, 82 | "security_group": 50, 83 | "router": 5, 84 | "port": 250, 85 | }, 86 | "octavia": { 87 | "health_monitor": 50, 88 | "listener": 5, 89 | "load_balancer": 5, 90 | "member": 5, 91 | "pool": 5, 92 | }, 93 | "trove": { 94 | "instances": 10, 95 | "volumes": 10, 96 | "backups": 50, 97 | }, 98 | }, 99 | "large": { 100 | "cinder": {"gigabytes": 50000, "volumes": 200, "snapshots": 600}, 101 | "nova": { 102 | "metadata_items": 128, 103 | "injected_file_content_bytes": 10240, 104 | "ram": 655360, 105 | "floating_ips": 50, 106 | "key_pairs": 50, 107 | "instances": 100, 108 | "security_group_rules": 800, 109 | "injected_files": 5, 110 | "cores": 200, 111 | "fixed_ips": 0, 112 | "security_groups": 100, 113 | }, 114 | "neutron": { 115 | "security_group_rule": 800, 116 | "subnet": 10, 117 | "network": 10, 118 | "floatingip": 50, 119 | "security_group": 100, 120 | "router": 10, 121 | "port": 500, 122 | }, 123 | "octavia": { 124 | "health_monitor": 100, 125 | "listener": 10, 126 | "load_balancer": 10, 127 | "member": 10, 128 | "pool": 10, 129 | }, 130 | "trove": { 131 | "instances": 20, 132 | "volumes": 20, 133 | "backups": 100, 134 | }, 135 | }, 136 | } 137 | 138 | 139 | config_group = groups.ConfigGroup("quota") 140 | 141 | config_group.register_child_config( 142 | fields.DictConfig( 143 | "sizes", 144 | help_text="A definition of the quota size groups that Adjutant should use.", 145 | value_type=types.Dict(value_type=types.Dict()), 146 | check_value_type=True, 147 | is_json=True, 148 | default=DEFAULT_QUOTA_SIZES, 149 | ) 150 | ) 151 | config_group.register_child_config( 152 | fields.ListConfig( 153 | "sizes_ascending", 154 | help_text="An ascending list of all the quota size names, " 155 | "so that Adjutant knows their relative sizes/order.", 156 | default=["small", "medium", "large"], 157 | ) 158 | ) 159 | config_group.register_child_config( 160 | fields.DictConfig( 161 | "services", 162 | help_text="A per region definition of what services Adjutant should manage " 163 | "quotas for. '*' means all or default region.", 164 | value_type=types.List(), 165 | default={"*": ["cinder", "neutron", "nova"]}, 166 | ) 167 | ) 168 | -------------------------------------------------------------------------------- /adjutant/notifications/v1/email.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Catalyst IT Ltd 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 4 | # not use this file except in compliance with the License. You may obtain 5 | # a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | from smtplib import SMTPException 16 | 17 | from django.core.mail import EmailMultiAlternatives 18 | from django.template import loader 19 | from django.utils import timezone 20 | 21 | from confspirator import groups 22 | from confspirator import fields 23 | from confspirator import types 24 | 25 | from adjutant.config import CONF 26 | from adjutant.common import constants 27 | from adjutant.api.models import Notification 28 | from adjutant.notifications.v1 import base 29 | 30 | 31 | class EmailNotification(base.BaseNotificationHandler): 32 | """ 33 | Basic email notification handler. Will 34 | send an email with the given templates. 35 | """ 36 | 37 | config_group = groups.DynamicNameConfigGroup( 38 | children=[ 39 | fields.ListConfig( 40 | "emails", 41 | help_text="List of email addresses to send this notification to.", 42 | item_type=types.String(regex=constants.EMAIL_REGEX), 43 | default=[], 44 | ), 45 | fields.StrConfig( 46 | "from", 47 | help_text="From email for this notification.", 48 | regex=constants.EMAIL_WITH_TEMPLATE_REGEX, 49 | sample_default="bounce+%(task_uuid)s@example.com", 50 | ), 51 | fields.StrConfig( 52 | "reply", 53 | help_text="Reply-to email for this notification.", 54 | regex=constants.EMAIL_REGEX, 55 | sample_default="no-reply@example.com", 56 | ), 57 | fields.StrConfig( 58 | "template", 59 | help_text="Email template for this notification. " 60 | "No template will cause the email not to send.", 61 | default="notification.txt", 62 | ), 63 | fields.StrConfig( 64 | "html_template", 65 | help_text="Email html template for this notification.", 66 | ), 67 | ] 68 | ) 69 | 70 | def _notify(self, task, notification): 71 | conf = self.config(task, notification) 72 | if not conf or not conf["emails"]: 73 | # Log that we did this!! 74 | note = ( 75 | "Skipped sending notification for task: %s (%s) " 76 | "as notification handler conf is None, or no emails " 77 | "were configured." % (task.task_type, task.uuid) 78 | ) 79 | self.logger.info("(%s) - %s" % (timezone.now(), note)) 80 | return 81 | 82 | template = loader.get_template(conf["template"], using="include_etc_templates") 83 | html_template = conf["html_template"] 84 | if html_template: 85 | html_template = loader.get_template( 86 | html_template, using="include_etc_templates" 87 | ) 88 | 89 | context = {"task": task, "notification": notification} 90 | 91 | if CONF.workflow.horizon_url: 92 | task_url = CONF.workflow.horizon_url 93 | notification_url = CONF.workflow.horizon_url 94 | if not task_url.endswith("/"): 95 | task_url += "/" 96 | if not notification_url.endswith("/"): 97 | notification_url += "/" 98 | task_url += "management/tasks/%s" % task.uuid 99 | notification_url += "management/notifications/%s" % notification.uuid 100 | context["task_url"] = task_url 101 | context["notification_url"] = notification_url 102 | 103 | if notification.error: 104 | subject = "Error - %s notification" % task.task_type 105 | else: 106 | subject = "%s notification" % task.task_type 107 | try: 108 | message = template.render(context) 109 | 110 | # from_email is the return-path and is distinct from the 111 | # message headers 112 | from_email = conf["from"] 113 | if not from_email: 114 | from_email = conf["reply"] 115 | elif "%(task_uuid)s" in from_email: 116 | from_email = from_email % {"task_uuid": task.uuid} 117 | 118 | # these are the message headers which will be visible to 119 | # the email client. 120 | headers = { 121 | "X-Adjutant-Task-UUID": task.uuid, 122 | # From needs to be set to be disctinct from return-path 123 | "From": conf["reply"], 124 | "Reply-To": conf["reply"], 125 | } 126 | 127 | email = EmailMultiAlternatives( 128 | subject, message, from_email, conf["emails"], headers=headers 129 | ) 130 | 131 | if html_template: 132 | email.attach_alternative(html_template.render(context), "text/html") 133 | 134 | email.send(fail_silently=False) 135 | notification.acknowledged = True 136 | notification.save() 137 | except SMTPException as e: 138 | notes = {"errors": [("Error: '%s' while sending email notification") % e]} 139 | error_notification = Notification.objects.create( 140 | task=notification.task, notes=notes, error=True 141 | ) 142 | error_notification.save() 143 | -------------------------------------------------------------------------------- /doc/source/development.rst: -------------------------------------------------------------------------------- 1 | ########### 2 | Development 3 | ########### 4 | 5 | Adjutant is built around tasks and actions. 6 | 7 | Actions are a generic database model which knows what 'type' of action it is. 8 | On pulling the actions related to a Task from the database we wrap it into the 9 | appropriate class type which handles all the logic associated with that action 10 | type. 11 | 12 | An Action is both a simple database representation of itself, and a more 13 | complex in memory class that handles all the logic around it. 14 | 15 | Each action class has the functions "prepare", "approve", and 16 | "submit". These relate to stages of the approval process, and any python code 17 | can be executed in those functions, some of which should ideally be validation. 18 | 19 | Multiple actions can be chained together under one Task and will execute in 20 | the defined order. Actions can pass information along via an in memory 21 | cache/field on the task object, but that is only safe for the same stage of 22 | execution. Actions can also store data back to the database if their logic 23 | requires some info passed along to a later step of execution. 24 | 25 | See ``actions.models`` and ``actions.v1`` for a good idea of Actions. 26 | 27 | Tasks, like actions, are also a database representation, and a more complex in 28 | memory class. These classes define what actions the task has, and certain other 29 | elements of how it functions. Most of the logic for task and action processing 30 | is in the base task class, with most interactions with tasks occuring via the 31 | TaskManager. 32 | 33 | See ``tasks.models`` and ``tasks.v1`` for a good idea of Tasks. 34 | 35 | The main workflow consists of three possible steps which can be executed at 36 | different points in time, depending on how the task and the actions within 37 | it are defined. 38 | 39 | The base use case is three stages: 40 | 41 | * Receive Request 42 | * Validate request data against action serializers. 43 | * If valid, setup Task to represent the request, and the Actions specified 44 | for that Task. 45 | * The service runs the "prepare" function on all actions which should do 46 | any self validation to mark the actions themselves as valid or invalid, 47 | and populating the notes in the Task based on that. 48 | * Auto or Admin Approval 49 | * Either a task is set to auto_approve or and admin looks at it to decide. 50 | * If they decide it is safe to approve, they do so. 51 | * If there are any invalid actions approval will do nothing until 52 | the action data is updated and initial validation is rerun. 53 | * The service runs the "approve" function on all actions. 54 | * If any of the actions require a Token to be issued and emailed for 55 | additional data such as a user password, then that will occur. 56 | * If no Token is required, the Task will run submit actions, and be 57 | marked as complete. 58 | * Token Submit 59 | * User submits the Token data. 60 | * The service runs the submit function on all actions, passing along the 61 | Token data, normally a password. 62 | * The action will then complete with the given final data. 63 | * Task is marked as complete. 64 | 65 | There are cases where Tasks auto-approve, and thus automatically do the 66 | middle step right after the first. There are also others which do not need a 67 | Token and thus run the submit step as part of the second, or even all three at 68 | once. The exact number of 'steps' and the time between them depends on the 69 | definition of the Task. 70 | 71 | Actions themselves can also effectively do anything within the scope of those 72 | three stages, and there is even the ability to chain multiple actions together, 73 | and pass data along to other actions. 74 | 75 | Details for adding task and actions can be found on the :doc:`feature-sets` 76 | page. 77 | 78 | 79 | What is an Action? 80 | ================== 81 | 82 | Actions are a generic database model which knows what 'type' of action it is. 83 | On pulling the actions related to a Task from the database we wrap it into the 84 | appropriate class type which handles all the logic associated with that action 85 | type. 86 | 87 | An Action is both a simple database representation of itself, and a more 88 | complex in memory class that handles all the logic around it. 89 | 90 | Each action class has the functions "prepare", "approve", and 91 | "submit". These relate to stages of the approval process, and any python code 92 | can be executed in those functions. 93 | 94 | What is a Task? 95 | =============== 96 | 97 | A task is a top level model representation of the workflow. Much like an Action 98 | it is a simple database representation of itself, and a more complex in memory 99 | class that handles all the logic around it. 100 | 101 | Tasks define what actions are part of a task, and handle the logic of 102 | processing them. 103 | 104 | What is a Token? 105 | ================ 106 | 107 | A token is a unique identifier linking to a task, so that anyone submitting 108 | the token will submit to the actions related to the task. 109 | 110 | What is a DelegateAPI? 111 | ====================== 112 | 113 | DelegateAPIs are classes which extend the base DelegateAPI class. 114 | 115 | They are mostly used to expose underlying tasks as APIs, and they do so 116 | by using the TaskManager to handle the workflow. The TaskManager will 117 | handle the data validation, and raise error responses for errors that 118 | the user should see. If valid the TaskManager will process incoming 119 | data and build it into a Task, and the related Action classes. 120 | 121 | DelegateAPIs can also be used for small arbitary queries, or building 122 | a full suite of query and task APIs. They are built to be flexible, and 123 | easily pluggable into Adjutant. At their base DelegateAPIs are Django 124 | Rest Framework ApiViews, with a helpers for task handling. 125 | 126 | The only constraint with DelegateAPIs is that they should not do any 127 | resourse creation/alteration/deletion themselves. If you need to work with 128 | resources, use the task layer and define a task and actions for it. 129 | Building DelegateAPIs which just query other APIs and don't alter 130 | resources, but need to return information about resources in other 131 | systems is fine. These are useful small little APIs to suppliment any 132 | admin logic you need to expose. 133 | --------------------------------------------------------------------------------